Skip to content
selfhostr

guide · 5 min read

Setting up Cloudflare Tunnel

Reach your self-hosted services over HTTPS without opening a single port on your router, and without a reverse proxy to maintain. Including what it actually costs.

Any self-hosted service you want to reach from outside raises the same question: how do you make it reachable without opening your router to the world? The usual answer — forward ports 80 and 443 to a reverse proxy — works, but it leaves you with two things to carry: a proxy to maintain, and a public IP that scanners find within hours.

Cloudflare Tunnel reverses the direction of the connection. A small daemon runs on your machine and dials out to Cloudflare's network; it is the one holding the link open. Nothing comes in. No port is open on the router, and your public IP appears nowhere.

Budget about fifteen minutes, DNS propagation aside.

What you need

  • A domain name, from any registrar.
  • A Cloudflare account. The free plan covers everything below.
  • Docker on the machine that hosts your services.

Step 1 — Hand the domain to Cloudflare

In the Cloudflare dashboard, Add a domain. Cloudflare gives you two name servers; go and set them at your registrar, replacing the ones already there.

Propagation takes as long as it takes — expect one to two hours, certificate included. There is nothing to retry: until the name servers have switched, none of the following will work.

Step 2 — Create the tunnel

Zero Trust → Networking → Tunnels → Create a tunnel. Name it after the machine rather than the service — home-server ages better than immich, because one tunnel will soon carry all of them.

Cloudflare then offers several install methods: Docker, Kubernetes, system package. Pick Docker, and keep the token it shows you. It is the one and only thing authenticating your tunnel.

Step 3 — Start the connector

The command Cloudflare hands you works as is, but a docker-compose.yml is easier to read back and restart:

services:
  cloudflared:
    image: cloudflare/cloudflared:latest
    container_name: cloudflared
    restart: unless-stopped
    # --no-autoupdate: you update the image, not the binary
    # inside it. A container that rewrites itself restarts
    # at the worst possible moment.
    command: tunnel --no-autoupdate run
    environment:
      - TUNNEL_TOKEN=${TUNNEL_TOKEN}

The token goes in a .env file next to it, never in the docker-compose.yml itself: it grants the right to publish anything under your domain.

echo "TUNNEL_TOKEN=your-token" > .env
docker compose up -d

The tunnel connects on its own. It turns Healthy in the dashboard within seconds.

Step 4 — Expose a service

In your tunnel, Add a public hostname:

Field What goes in
Subdomain immich, git, cloud — whatever you like
Domain your domain
Service the internal address of the service, as the cloudflared container sees it

That is it. Online, over HTTPS, without touching the router.

The trap: which address goes in "Service"

This is where most first attempts fail. The address is the one the cloudflared container sees, not the one your browser sees. Two cases:

Situation Address Example
The service shares a Docker network with cloudflared by container name http://opencloud-opencloud-1:9200
The service lives elsewhere on the machine by IP and published port http://192.168.0.11:2283

The first form is preferable: it does not depend on the machine's IP, and it works even when the service publishes no port at all. To get it, join cloudflared to the service's network:

services:
  cloudflared:
    networks:
      - default
      - immich_default

networks:
  immich_default:
    external: true

Always http://, never https://: encryption stops at Cloudflare's network, and the last hop stays on your own LAN.

Know this before you move everything

The tunnel is not free of consequences. Three things are worth knowing before you put a service behind it, not after.

Uploads are capped at 100 MB on the Free and Pro plans (200 MB on Business). The limit applies to the request body, so to what you send — downloads are not affected. For Immich or Nextcloud this is the first thing to break: a video shot on a phone often goes past it. See the Cloudflare documentation.

Streaming video through the free CDN is not what the terms allow. Cloudflare reserves the right to limit access for a site using it "to serve video or a disproportionate percentage of pictures, audio files, or other large files" without the matching paid services. A Jellyfin exposed this way works — until the day it does not. See the service-specific terms.

TLS terminates at Cloudflare. The certificate is theirs, decryption happens on their machines, and your traffic travels in the clear inside their infrastructure before being re-encrypted towards you. That is the price of the model, and it deserves saying out loud: leaving the cloud to take back control of your data, then routing all of that data through an intermediary, should at least be a deliberate choice. For anything genuinely sensitive, a mesh VPN (Tailscale, WireGuard) routes your traffic through nobody.

What it replaces, and what it does not

It replaces the exposed reverse proxy and port forwarding — that guide spells out what an open port actually costs you. It replaces neither your backups nor your services' own authentication: a tunnel makes an application reachable, it does not make it safe. Cloudflare Access, on the same free plan, adds an authentication layer in front of services that have none worth the name — the natural companion to this, and the subject of another guide.

Video

logicBase Labs sets up a Cloudflare tunnel end to end without opening a single port — a walkthrough of exactly what this guide describes in text.

Nothing is sent to YouTube until you press play. This site sets no cookies of its own.Watch on YouTube