How to Expose Your Home Server with Cloudflare Tunnel
Securely expose home services with Cloudflare Tunnel, set up systemd, configure Access policies, and verify without opening inbound ports.
Before you start: versions, package names and storage identifiers change over time. Check the commands against the official documentation for your setup before running them, especially anything that creates, deletes or overwrites data.
A Cloudflare Tunnel gives a service on your home server a public hostname without opening a single inbound port on your router. cloudflared makes an outbound connection to Cloudflare and traffic comes back down it, so your firewall stays shut and your home IP stays off the DNS record.
This guide covers the throwaway version, the permanent version, and the distinction between them that causes the most confusion once a tunnel is running.
Decide which kind of tunnel you want first
This is the choice that catches people out later, so make it deliberately. There are two ways to configure a named tunnel, and they are not interchangeable:
| Remotely-managed | Locally-managed | |
|---|---|---|
| Configured from | The Cloudflare dashboard | config.yml on your server |
| Routes edited in | Dashboard UI | The file, then restart |
| Good for | A few services, managed from anywhere | Many services, config in version control |
If you set up a tunnel locally and then go looking for its routes in the dashboard, Cloudflare tells you:
This tunnel is locally managed. Routes are configured via the local configuration file and cannot be modified from the dashboard.
That is not an error and nothing is broken — it is the expected message for a locally-managed tunnel. Edit config.yml on the server and restart the service instead. Everything below uses the locally-managed route, because it is the one that fits a homelab where you want the config in a file you can back up.
The 30-second version: quick tunnels
Before the permanent setup, know this exists. To expose a local port right now with no account, no domain and no config:
cloudflared tunnel --url http://127.0.0.1:3000
cloudflared prints a random *.trycloudflare.com URL that forwards to that port.
It is genuinely useful for showing a colleague a dev server or testing a webhook. It is also the wrong tool for anything permanent: the hostname is random, it changes every run, there is no access control, and the tunnel dies with the process. Use it as a demo, not as hosting.
Prerequisites for the permanent setup
- An always-on Linux server (x86_64 or ARM) running the service you want to expose — say
http://127.0.0.1:3000 - A domain on Cloudflare nameservers, with permission to add DNS records
- Outbound HTTPS from the server. Cloudflare Tunnel needs to reach port 7844; if you are behind a restrictive firewall, confirm that is allowed
sudoon the server
Step 1: Authenticate
cloudflared tunnel login
This opens a browser to pick the zone. On a headless box, copy the printed URL to a browser elsewhere — it writes a certificate into ~/.cloudflared/ when you are done.
Step 2: Create the tunnel
cloudflared tunnel create homelab
This prints a tunnel UUID and writes a credentials JSON file into ~/.cloudflared/<UUID>.json. Note the UUID; you need it next.
That JSON file is the tunnel’s secret. Back it up somewhere sensible and do not commit it alongside your config.
Step 3: Write config.yml
By default cloudflared expects its configuration at $HOME/.cloudflared/config.yml. A minimal single-service file:
tunnel: <Tunnel-UUID>
credentials-file: /home/youruser/.cloudflared/<Tunnel-UUID>.json
ingress:
- hostname: app.example.com
service: http://127.0.0.1:3000
- service: http_status:404
Two rules about ingress worth internalising now rather than debugging later:
- Rules are matched in order, first match wins.
- The last rule must be a catch-all with no
hostname— thehttp_status:404line above. Without itcloudflaredrefuses to start.
Adding a second service is just another block above the catch-all:
ingress:
- hostname: app.example.com
service: http://127.0.0.1:3000
- hostname: grafana.example.com
service: http://127.0.0.1:3001
- service: http_status:404
Step 4: Point DNS at the tunnel
cloudflared tunnel route dns homelab app.example.com
This creates the proxied CNAME in your zone. Repeat per hostname. If the record already exists, delete it first or the command will not overwrite it.
Step 5: Test in the foreground
cloudflared tunnel run homelab
Load the hostname in a browser. When it works, stop it with Ctrl+C and make it permanent — do not skip the foreground run, because errors here are far easier to read than in journald.
To confirm what Cloudflare thinks the tunnel is doing:
cloudflared tunnel info homelab
Step 6: Install as a service
sudo cloudflared service install
There is a specific trap here, and it is the most common failure in the whole process. Run with sudo, $HOME points to /root, so cloudflared looks for /root/.cloudflared/config.yml and does not find the file you wrote in your own home directory. The service installs and then fails to start.
Pass the path explicitly:
sudo cloudflared --config /home/youruser/.cloudflared/config.yml service install
Then:
sudo systemctl enable --now cloudflared
sudo systemctl status cloudflared
sudo journalctl -u cloudflared -f
On Windows the equivalent config path is %USERPROFILE%\.cloudflared\config.yml, and on macOS cloudflared installs as a launch agent that starts when you log in — which means it is not running when nobody is logged in, a poor fit for a server.
Put access control in front of it
A tunnel makes a service reachable from the internet. That is the whole point, and it is also the risk: a Grafana or Portainer panel with a default password is now exposed to everyone, not just your LAN.
For anything administrative, add a Cloudflare Access policy on the hostname so Cloudflare authenticates the request before it ever enters the tunnel. This is the step people postpone, and it is the one that matters most. The tunnel removes your router as a barrier; Access is what replaces it.
Troubleshooting the common failures
| Symptom | Usual cause |
|---|---|
| Service installs but will not start | sudo and $HOME — pass --config explicitly (above) |
cloudflared exits immediately on start |
Missing catch-all ingress rule, or invalid YAML indentation |
Tunnel shows Inactive or Down |
The server cannot reach Cloudflare on port 7844 |
| Hostname returns 502 | Tunnel is up but the local service is not listening on the port in service: |
| Routes missing from the dashboard | Expected — the tunnel is locally managed |
Sources
- Cloudflare docs — Create a locally-managed tunnel
- Cloudflare docs — Tunnel configuration file
- Cloudflare docs — Run as a service on Linux (the
sudo/$HOMEcaveat) - Cloudflare docs — Create a remotely-managed tunnel (port 7844 requirement)
Related reading
Some links on this page may be affiliate links. If you buy through them we may earn a commission at no extra cost to you. See our affiliate disclosure.