~$ klumbsyd / tech / posts / self-hosting / cloudflare-tunnel-setup.md
post.config
1# post metadata
2title="Cloudflare Tunnels: exposing services without port forwarding"
3author="dustin"
4date="2026-03-10"
5tags=["networking", "cloudflare", "nginx", "self-hosting"]
6read_time="3 min"
7word_count="431"
8status="published"

Cloudflare Tunnels: exposing services without port forwarding

Opening ports on your home router is a non-starter if you care about security. Dynamic DNS is fragile. Cloudflare Tunnels solve both problems – your server connects outbound to Cloudflare, and they handle routing, SSL, and DDoS protection.

How it works

The cloudflared daemon runs on your server and establishes an outbound connection to Cloudflare’s network. No inbound ports needed. Traffic flows:

Internet → Cloudflare → cloudflared tunnel → nginx → static files

I run the tunnel as a Docker container (figro/unraid-cloudflared-tunnel) on UnRAID. All public hostnames route through a single tunnel – one container handles everything. The tunnel authenticates to Cloudflare via a token passed as an environment variable (TUNNEL_TOKEN) in the container template. No config file needed, no local credentials on disk.

One thing to be careful about: that token is sensitive. Anyone who can run docker inspect on your server can read it. Keep it out of scripts and shared configs – the UnRAID container template is the right place for it.

The actual routing

The tunnel points at my runestone-nginx container (port 8082), which serves all three subdomains: klumbsyd.com, tech.klumbsyd.com, and games.klumbsyd.com. Every public request goes through nginx – not directly to any app container.

This means the full chain for every page load is:

Cloudflare → cloudflared → nginx:8082 → static files on disk

Adding a new site means adding a server block in nginx.conf and a Public Hostname in the Cloudflare Zero Trust dashboard. Cloudflare auto-creates the DNS record. No router config, no certbot, no port forwards.

Nginx security config

Security headers and hardening live at the http {} block level so they apply to every site automatically:

server_tokens off;
add_header X-Frame-Options "SAMEORIGIN" always;
add_header X-Content-Type-Options "nosniff" always;
add_header X-XSS-Protection "1; mode=block" always;
add_header Referrer-Policy "strict-origin-when-cross-origin" always;

server_tokens off strips the nginx version from response headers. Small thing, free win.

The default server block drops any request that doesn’t match a known hostname:

server {
    listen 80 default_server;
    server_name _;
    return 444;
}

444 closes the connection with no response. Scanners and unknown subdomains get nothing.

SSL

Cloudflare handles SSL termination between the user and their edge. The tunnel connection itself is also encrypted. The only unencrypted hop is from the tunnel to nginx inside the server – that hop never leaves the machine.

Update management

The container runs with NO_AUTOUPDATE=true. Cloudflare pushes cloudflared updates frequently and the auto-update mechanism has occasionally caused unexpected restarts. I check for new releases manually and update on my own schedule. For a tunnel serving public sites, an unexpected restart mid-traffic is worse than running a slightly older binary.