← All blogs

Expose a Home Server Safely with Cloudflare Tunnel and Cockpit

Sep 2026 · 12 min read · Infrastructure

Home labs usually die at the same hurdle: getting traffic in. The traditional answer — forward ports on the router, chase a dynamic IP, buy a certificate, hope nobody scans you — is a lot of moving parts held together by optimism. Cloudflare Tunnel replaces all of it with a single outbound connection. This is the setup I run, written so you can reproduce it on a clean Ubuntu box in under an hour.

Diagram: a browser connects over HTTPS to the Cloudflare edge, which reaches a home server through an outbound-only tunnel, with router port forwarding crossed out.
The server dials out to Cloudflare. Nothing dials in — so there is no inbound attack surface on your router.

Why a tunnel instead of port forwarding

A tunnel inverts the direction of trust. The cloudflared daemon on your server opens an outbound TLS connection to Cloudflare's edge and keeps it alive. Requests arrive at the edge, get matched against your DNS records, and are handed back down that existing connection. Your firewall stays fully closed to inbound traffic.

  • No inbound ports. Nothing to scan, nothing to misconfigure on the router.
  • No static IP, no DDNS. The tunnel re-registers itself when your IP changes.
  • TLS and DDoS protection at the edge, included, with automatic renewal.
  • Identity in front of everything. Cloudflare Access can require a verified email before a request ever reaches your box.
  • Your origin IP is never published. DNS points at a tunnel CNAME, not at your house.
Conventions used below. Replace example.com with your own zone, youruser with your Linux username, and <TUNNEL_UUID> with the UUID Cloudflare issues in Step 3. Every credential in this article is a placeholder — no real identifiers, hostnames, or tokens appear anywhere in it.

What you'll end up with

HostnameRoutes toPurpose
ping.example.comhttp_status:200Smoke test — proves the tunnel works before any real service exists
admin.example.comhttps://localhost:9090Cockpit web administration

Keep the hostname list minimal and boring. Admin panels are the highest-value target on a home network, and a guessable name is free reconnaissance for anyone enumerating your zone.

Prerequisites

  • Ubuntu 24.04 or newer with sudo access
  • A domain added to Cloudflare, with its nameservers already delegated there
  • Outbound HTTPS on port 443 (allowed by default on virtually every ISP)

Step 1 — Patch first

Start from a known-good baseline. If a kernel lands, reboot before continuing.

sudo apt update && sudo apt -y upgrade
sudo reboot   # only if a new kernel was installed

Step 2 — Install cloudflared from Cloudflare's signed repo

Use the apt repository rather than a downloaded binary, so you get security updates through your normal patch cycle.

curl -fsSL https://pkg.cloudflare.com/cloudflare-main.gpg \
  | sudo tee /usr/share/keyrings/cloudflare-main.gpg >/dev/null

echo "deb [signed-by=/usr/share/keyrings/cloudflare-main.gpg] \
https://pkg.cloudflare.com/cloudflared noble main" \
  | sudo tee /etc/apt/sources.list.d/cloudflared.list

sudo apt update && sudo apt install -y cloudflared
cloudflared --version
Codename gotcha. Cloudflare only publishes directories for stable LTS codenames. On a very fresh non-LTS release, apt update will 404 against your actual codename — pin noble as shown. The binary is standard glibc and runs fine on newer releases. Switch back once your codename appears upstream.

Step 3 — Authenticate and create the tunnel

cloudflared tunnel login

A browser tab opens; pick your zone and authorize. This writes a certificate to ~/.cloudflared/cert.pem. On a headless box, the command prints a login URL — open it on your laptop and the cert is fetched on the next retry.

cloudflared tunnel create home-server

Cloudflare returns a UUID and writes a credentials JSON file next to your cert. Capture the UUID for the rest of the session:

export TUNNEL_UUID=$(basename "$(ls ~/.cloudflared/*.json | head -1)" .json)
echo "$TUNNEL_UUID"
Treat that JSON file as a secret. It is a bearer credential for your tunnel — anyone holding it can publish traffic under your hostnames. Never commit it, paste it into an issue, or include it in a screenshot. Keep it at mode 600, and if it leaks, run cloudflared tunnel delete and create a fresh tunnel rather than trying to rotate around it.

Step 4 — Describe your routes in config

mkdir -p ~/.cloudflared
nano ~/.cloudflared/config.yml
tunnel: <TUNNEL_UUID>
credentials-file: /home/youruser/.cloudflared/<TUNNEL_UUID>.json

ingress:
  # Smoke test
  - hostname: ping.example.com
    service: http_status:200

  # Cockpit admin panel
  - hostname: admin.example.com
    service: https://localhost:9090
    originRequest:
      noTLSVerify: true   # Cockpit ships a self-signed cert for the server hostname

  # Required catch-all — must be last
  - service: http_status:404

Two details matter here. Rules are evaluated in order, so the catch-all has to sit at the bottom or it will swallow everything above it. And noTLSVerify applies only to the hop between cloudflared and a service on localhost — the public leg of the connection is still fully verified TLS. Scope it per-route; never set it globally.

Step 5 — Run it in the foreground first

Always validate interactively before you hand control to systemd.

cloudflared tunnel run home-server

You should see four Registered tunnel connection lines. Four is correct — Cloudflare establishes redundant connections across edge locations so a single one dropping is invisible to users.

Step 6 — Create the DNS records

In a second terminal, leaving the tunnel running in the first:

cloudflared tunnel route dns home-server ping.example.com
cloudflared tunnel route dns home-server admin.example.com

Each command creates a proxied CNAME pointing at <TUNNEL_UUID>.cfargotunnel.com. Propagation is typically well under a minute. Verify:

dig +short ping.example.com
dig +short admin.example.com

Now load https://ping.example.com — a bare HTTP 200 confirms the whole path works. The admin hostname will return 502 or refuse the connection, which is expected: Cockpit isn't installed yet. Getting a clean 200 from the placeholder before adding real services is what makes the next failure easy to attribute.

Step 7 — Promote to a systemd service

Stop the foreground run with Ctrl+C, then move the config and credentials to the system location so the daemon starts at boot without a user session.

sudo mkdir -p /etc/cloudflared
sudo cp ~/.cloudflared/config.yml /etc/cloudflared/config.yml
sudo cp ~/.cloudflared/"$TUNNEL_UUID".json /etc/cloudflared/
sudo chown -R root:root /etc/cloudflared
sudo chmod 600 /etc/cloudflared/"$TUNNEL_UUID".json

# Repoint credentials-file at the system copy
sudo sed -i "s|/home/youruser/.cloudflared/|/etc/cloudflared/|" \
  /etc/cloudflared/config.yml

sudo cloudflared service install
sudo systemctl enable --now cloudflared
systemctl status cloudflared

The chmod 600 is not optional. Under /etc the credentials file is world-readable by default, which hands every local account a working tunnel credential.

Step 8 — Install Cockpit

sudo apt install -y cockpit
sudo apt install -y cockpit-podman     # container management
sudo apt install -y cockpit-machines   # KVM guests, if you run VMs

sudo systemctl enable --now cockpit.socket
ss -tlnp | grep 9090

sudo systemctl restart cloudflared     # re-resolve the now-live origin

Load https://admin.example.com and you'll get the Cockpit login. Authenticate as any local Linux user; sudo members get full administrative access.

Step 9 — Tell Cockpit to trust the tunnel origin

Cockpit refuses WebSocket connections whose Origin header doesn't match its own certificate hostname. Because Cloudflare terminates TLS at the edge and re-opens a fresh connection to localhost:9090, the browser presents the public tunnel hostname as the origin — which Cockpit rejects by default. The result is a login screen that renders but hangs on "Connecting..." and never completes a session.

Whitelist the hostname you routed in Step 6:

sudo nano /etc/cockpit/cockpit.conf
[WebService]
Origins = https://admin.example.com
sudo systemctl restart cockpit
Origins is a whitelist, not a redirect. It tells Cockpit which external hostnames are allowed to talk to it; the tunnel still routes the actual traffic. List multiple hostnames space-separated on the same line if you ever expose Cockpit on more than one. Adding a hostname you don't actually route to the tunnel is harmless — Cockpit will simply never see a request that matches it.

Step 10 — Put identity in front of it

This is the step people skip, and it's the one that actually matters. As configured, your Cockpit login page — a root-equivalent entry point — is reachable by the entire internet, and the only thing between an attacker and your server is a password form. Cloudflare Access fixes that in about five minutes, free for small teams.

  1. Open the Cloudflare Zero Trust dashboard
  2. Access → Applications → Add an application → Self-hosted
  3. Set the application hostname to admin.example.com
  4. Add a policy: Allow → Emails → your own address (or an entire verified domain)
  5. Save, then reload the URL

You'll now hit a one-time-PIN challenge at the edge before Cockpit is reachable at all. Unauthenticated requests are rejected by Cloudflare and never touch your hardware — which means a leaked Cockpit password is no longer sufficient to get in, and credential-stuffing bots never reach the form.

Rule of thumb: if a hostname would let someone run commands on your machine, it gets an Access policy. Public read-only services can go without; admin surfaces cannot.

Step 11 — Verify end to end

systemctl status cloudflared
cloudflared tunnel info home-server

systemctl status cockpit
ss -tlnp | grep 9090

dig +short ping.example.com
dig +short admin.example.com

Both hostnames should resolve to your tunnel CNAME, and both services should report active.

Troubleshooting

SymptomCause and fix
apt update 404s on the cloudflared repoUnsupported codename in the sources file — pin noble (Step 2)
No browser opens during tunnel loginHeadless box. Open the printed URL from your laptop.
Hostname doesn't resolveWait a minute, then dig +short <host>. Confirm the CNAME exists in the dashboard.
502 Bad GatewayOrigin service is down. systemctl status cockpit.
Connection refusedNothing listening on that port, or the service is bound to a different interface.
TLS errors in cloudflared logsSelf-signed origin without originRequest.noTLSVerify: true on that route.
permission denied on credentialssudo chmod 600 /etc/cloudflared/<TUNNEL_UUID>.json
tunnel not foundUUID in config.yml doesn't match cloudflared tunnel list.
Cockpit: "Unable to log in"Local account locked or password expired — reset it on the server.
Cockpit: "WebSocket disconnected"Proxy buffering. Add a larger bufferSize under that route's originRequest.
Cockpit hangs on "Connecting..." after the login screen rendersOrigin not whitelisted. Add the public hostname to Origins = in /etc/cockpit/cockpit.conf and restart Cockpit (Step 9).

Useful commands

# Tunnel
cloudflared tunnel list
cloudflared tunnel info home-server
sudo journalctl -u cloudflared -f
sudo systemctl restart cloudflared

# Cockpit
systemctl status cockpit
sudo journalctl -u cockpit -f
sudo systemctl restart cockpit

# Confirm DNS from Cloudflare's own resolver
curl -s "https://cloudflare-dns.com/dns-query?name=ping.example.com&type=CNAME" \
  -H "accept: application/dns-json"

Adding another service

Three steps, every time. Insert the ingress block above the catch-all:

- hostname: service.example.com
  service: http://localhost:PORT      # https:// for a TLS origin
  originRequest:
    noTLSVerify: true                 # only for self-signed HTTPS origins
cloudflared tunnel route dns home-server service.example.com
sudo systemctl restart cloudflared

Common origins: NAS web UIs on :5000, browser-based SSH on :8022, container managers on :9443.

Closing notes

What makes this setup hold up over time isn't the tunnel — it's the discipline around it: routes declared in one reviewable config file, credentials at mode 600 and never in version control, an Access policy on every administrative hostname, and a placeholder route you can curl when something breaks. The tunnel removes the attack surface at your router. Those four habits keep you from quietly rebuilding it somewhere else.

← All blogs