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.
- What a tunnel actually does
- The whole configuration
- Running it as a service
- The four things that catch people out
- What a tunnel does not solve
- Two things worth adding on day one
- Is it the right call?
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.