Aug 19, 2026 · 6 min read

Self-hosting behind a Cloudflare Tunnel

A production stack online with no open ports and no static IP: the ingress rules for websockets and subdomains, and four things that catch people out.

Everything we serve to the public runs on one machine in an office, behind a residential connection, with no ports open to the internet and no static IP. The whole public surface goes through a single cloudflared process: a website, an employee dashboard on its own subdomain, a self-hosted analytics install, and a WebSocket server carrying live multiplayer games.

This is the setup, what it is good at, and where it is the wrong tool.

What a tunnel actually does

cloudflared makes an outbound connection from your machine to Cloudflare's edge and keeps it open. Requests for your hostnames arrive at Cloudflare, get handed down that existing connection, and your daemon forwards them to a local port.

Nothing listens on your public IP. Your router forwards nothing. Your ISP can hand you a new address every week and nothing breaks.

That last part is the reason most people end up here. If you are behind CGNAT or a double NAT, port forwarding is not available to you at all, and a tunnel is one of the few options that does not involve renting a VPS to proxy through.

The whole configuration

This is the real ingress block, lightly trimmed:

tunnel: 2f1c9b40-0e7a-4a3d-9c11-8b6d5e4f7a20
credentials-file: /home/you/.cloudflared/2f1c9b40-....json

ingress:
  - hostname: example.com
    path: ^/app/
    service: http://localhost:8085     # WebSocket server
  - hostname: example.com
    service: http://localhost:80       # the web app
  - hostname: internal.example.com
    service: http://localhost:80       # same app, routed by host
  - hostname: analytics.example.com
    service: http://localhost:8080     # nginx in front of Plausible
  - service: http_status:404

Four things worth pulling out of that.

Rules match in order and the first hit wins. The path rule for ^/app/ has to come before the bare example.com rule, or every request including the WebSocket upgrade goes to port 80.

Path routing is per-hostname. That is how one domain serves a normal web app and a WebSocket server on different ports with no second subdomain and no nginx in the middle.

Two hostnames can point at the same service. internal.example.com and example.com both hit the same application, which routes by Host header internally. The tunnel does not care.

The catch-all matters. Ending with http_status:404 means a hostname you have not configured gets a 404 instead of leaking whatever the first rule happens to be. Cloudflared requires a catch-all rule anyway; make it a refusal.

Running it as a service

[Unit]
Description=Cloudflare Tunnel for example.com
After=network.target

[Service]
User=you
ExecStart=/usr/bin/cloudflared tunnel --config /home/you/.cloudflared/config.yml run my-tunnel
Restart=on-failure
RestartSec=10

[Install]
WantedBy=multi-user.target

Run it as an unprivileged user. It needs no root, it binds no ports, and the credentials it reads should belong to that user and nobody else. The two files in ~/.cloudflared (cert.pem and <tunnel-id>.json) are the tunnel's identity. Anyone holding them can serve traffic as you, so treat them like an SSH private key and never put them in a repo.

Restart=on-failure matters more here than for most services, because this process is your entire public presence. If it dies, everything is down, and the site will look fine from inside the network the whole time.

The four things that catch people out

1. Your app thinks every request is HTTP

TLS terminates at Cloudflare. Your application receives a plain HTTP request from localhost. If it force-redirects HTTP to HTTPS, and it does not know to read X-Forwarded-Proto, it will redirect a request that already arrived over HTTPS, forever. That is the classic tunnel redirect loop.

In Laravel that means trusting the proxy explicitly:

$middleware->trustProxies(
    at: '*',
    headers: Request::HEADER_X_FORWARDED_FOR
        | Request::HEADER_X_FORWARDED_HOST
        | Request::HEADER_X_FORWARDED_PORT
        | Request::HEADER_X_FORWARDED_PROTO
);

at: '*' looks alarming and is correct here, because the only thing that can reach the app is cloudflared on the same host. If your app is also reachable some other way, restrict it.

The same applies to logging. Without the forwarded headers, every visitor in your logs is 127.0.0.1. Cloudflare also sends CF-Connecting-IP with the real client address.

2. Restarting the tunnel takes everything down at once

One process, every hostname. systemctl restart cloudflared is a full outage for the seconds it takes to reconnect, including any subdomain you were not thinking about.

Consolidation is the tunnel's main convenience and its main risk. Treat a restart of it as a production event, not as something you do while debugging one subdomain.

3. The free plan has a body size limit

At the time of writing, Cloudflare's free plan caps request bodies at 100 MB. That is invisible until the day someone uploads a large file to your self-hosted storage and gets a 413 that your application never saw.

If you are tunnelling a file-sync or backup service, check this before you build on it.

4. Cloudflare can be blocking bots you want

Cloudflare's dashboard has a managed robots.txt feature, and the "block AI bots" toggle in it will serve rules blocking GPTBot, ClaudeBot, CCBot, Google-Extended, and others, whether or not you have a robots.txt of your own in your document root.

We had that on without realising, and the effect is exactly what you would expect: none of those crawlers could read anything we published. If you care about being cited by AI assistants, check that toggle. It is a dashboard setting, so nothing on your server will ever tell you about it.

What a tunnel does not solve

It carries HTTP and WebSockets. It is not a general-purpose way to expose a service.

We also run a game relay that needs raw UDP on a specific port. A Cloudflare Tunnel does nothing for that, and behind a double NAT the port is unreachable no matter what we do on the machine. The fix there is a different tool (a VPS with a real IP, or a mesh network like Tailscale or WireGuard), and it is worth knowing the boundary before you plan around it.

Rough rule: if it speaks HTTP, a tunnel is an excellent answer. If it needs arbitrary TCP or UDP, look at Cloudflare's cloudflared access client model or at a mesh VPN instead.

Two things worth adding on day one

Authentication in front of anything private. Cloudflare Access can put a login in front of a hostname before a request reaches your machine, which is the easiest way to expose an admin panel safely. We do it a level lower, with an nginx auth_request that asks our own application whether the current session is allowed to see the analytics dashboard, because we already had the accounts and the permission model. Either is fine. Nothing is not.

A firewall anyway. ufw is enabled on our box even though no ports are forwarded to it. The tunnel protects you from the internet, not from whatever else is on your LAN.

Is it the right call?

For self-hosting a web application on a connection you do not control, we would do it again without hesitating. No port forwarding, no dynamic DNS, no certificate renewal, no static IP.

The trade is that Cloudflare sits in front of your traffic and terminates your TLS, and one process failing takes every hostname with it. Both are real. For a small stack behind a residential line, they have been the right trade for us.


Fopull LLC is a software studio in Knoxville, TN. We build and run self-hosted infrastructure like this, including for people who would rather not run it themselves. Tell us what you need.

Written by Ty Johnston at Fopull LLC, a software studio in Knoxville, TN. We build custom software and ship our own — Floptle, Storage Sifter, and more.

Read next