Reboot-Proof Cloudflare Named Tunnels: The systemd Setup I Run in Production
The exact create, route, config, and systemd flow I use to expose a self-hosted app with zero open ports.

I host several small apps on a box in my flat, and not one of them has an open inbound port. Everything reaches the internet through a Cloudflare named tunnel, and the tunnel itself is held up by a systemd unit that has restarted cleanly through every reboot and power cut for the past few weeks. The build I run in production is [email protected], installed straight from Cloudflare's apt repo. This is the flow I actually use, start to finish: login, create, route, config, and the systemd unit that makes it reboot-proof.
The reason I bother with a named tunnel instead of the one-line quick tunnel is the part most guides skip, so I will start there.
Quick tunnel versus named tunnel
When I first tried to share a local app, I reached for the quick tunnel, because it is one command and needs no account:
cloudflared tunnel --url http://localhost:8080
That spins up an ephemeral connector and prints a random https://<words>.trycloudflare.com address. It works for a thirty-second demo. The catch bit me the first time I restarted the process: the old URL was dead and a fresh random one took its place. There is no stable hostname, no credentials on disk, nothing survives a restart, and you cannot put it behind a systemd unit in any sane way because the address changes on every launch.
A named tunnel fixes all of that. It has a fixed UUID, a credentials file written to disk, and a real DNS record you choose, so the same hostname keeps working across restarts, reboots, and re-deploys.
| Property | Quick tunnel | Named tunnel |
|---|---|---|
| Account needed | No | Yes |
| Hostname | Random trycloudflare.com | Your own, on your domain |
| Survives a restart | No, new URL each time | Yes, same hostname |
| Credentials on disk | None | UUID JSON file |
| Fit for systemd | Poor | Built for it |
| Good for | A throwaway demo | Anything you keep |
The rule I settled on: a quick tunnel for a thirty-second look, a named tunnel for everything I intend to keep running.
Install cloudflared
On Ubuntu I install from Cloudflare's package repo rather than a loose binary, so apt keeps it current:
sudo mkdir -p --mode=0755 /usr/share/keyrings
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 any main" \
| sudo tee /etc/apt/sources.list.d/cloudflared.list
sudo apt update && sudo apt install -y cloudflared
cloudflared --version
That last line is my sanity check. It printed cloudflared version 2026.5.2 (built 2026-05-27) on my box, and I pin that exact version in my notes so a future me knows what the working setup was built against.
Login, create, route
Three commands stand up the tunnel. First, authenticate the machine. This opens a browser, asks you to pick the zone, and writes a certificate to ~/.cloudflared/cert.pem:
cloudflared tunnel login
Next, create the tunnel. I name mine after the box, something like homelab. This writes a credentials file, ~/.cloudflared/<UUID>.json, which is the secret that lets this connector run the tunnel. Guard it like a key:
cloudflared tunnel create homelab
The command prints the tunnel UUID. Note it down. Then route a hostname to the tunnel. This creates the DNS CNAME for you, pointing your chosen name at <UUID>.cfargotunnel.com:
cloudflared tunnel route dns homelab app.example.com
One precedence detail I learned the hard way: if your zone already has a wildcard record covering *.example.com, a specific record like app.example.com has to win, and a specific CNAME does take priority over a wildcard. So the route command's record overrides the wildcard, which is what you want. If the route command complains that the record exists, delete the stale one in the dashboard and run it again.
The config file
The connector reads ~/.cloudflared/config.yml by default. This is where ingress rules live, mapping each hostname to a local service. Mine looks like this for a single app, with the mandatory catch-all at the bottom:
# ~/.cloudflared/config.yml
tunnel: homelab
credentials-file: /home/aditya/.cloudflared/2f1c8a90-abcd-1234-ef56-7890abcdef12.json
ingress:
- hostname: app.example.com
service: http://localhost:8080
- service: http_status:404
Ingress rules match top to bottom, so any specific hostname has to sit above the final http_status:404, which catches everything unmatched. For a second app, add another hostname block above the catch-all and point it at its own local port. Test the tunnel in the foreground before you wrap it in anything:
cloudflared tunnel run homelab
Open https://app.example.com in a browser. In the foreground logs I watch for four lines that read Registered tunnel connection, because cloudflared opens four outbound connections to the Cloudflare edge for high availability, two each to two nearby data centres. Four connections up means the tunnel is healthy. Stop it with Ctrl-C once you have confirmed the app loads.
The systemd unit that makes it reboot-proof
The foreground run is fine for a test and useless for production, because it dies the moment you close the shell. cloudflared ships a cloudflared service install helper, but I prefer to write my own unit so I control restart behaviour and logging. Here is the unit I run:
# /etc/systemd/system/homelab-tunnel.service
[Unit]
Description=Cloudflare named tunnel for app.example.com
After=network-online.target
Wants=network-online.target
[Service]
Type=simple
User=aditya
ExecStart=/usr/bin/cloudflared --no-autoupdate tunnel run homelab
Restart=on-failure
RestartSec=5
TimeoutStartSec=0
[Install]
WantedBy=multi-user.target
Three fields carry the reboot-proofing. After=network-online.target with Wants=network-online.target holds the tunnel until the box actually has a network, so it does not race the interface on boot. Restart=on-failure with RestartSec=5 brings the connector back five seconds after any crash. WantedBy=multi-user.target is what enables it to start on every boot. I add --no-autoupdate because I want apt, not the binary, deciding when cloudflared changes under me.
Enable and start it in one step, then confirm:
sudo systemctl daemon-reload
sudo systemctl enable --now homelab-tunnel.service
systemctl status homelab-tunnel.service
journalctl -u homelab-tunnel.service -f
The status line should read active (running). To prove the reboot-proofing rather than assume it, I rebooted the box and watched the hostname come back on its own. It did, in under ten seconds after the network came up, with no shell open and nobody logged in. That single reboot test is the difference between hoping and knowing.
The localhost trap if cloudflared runs in a container
One sharp edge worth stating, because it has eaten an evening of mine on a different machine. When you run cloudflared directly on the host as above, http://localhost:8080 in the config means the host's loopback, which is correct. But if you run cloudflared inside a Docker container, that same localhost means the container, not the host, and you get a 502 Bad Gateway from the tunnel while every curl on the host works fine. The fix in that case is to address the host across the Docker bridge gateway instead of loopback, and to bind your app to 0.0.0.0 so the bridge can reach it. On a plain systemd host setup, localhost is right and you can ignore this entirely.
The token alternative
There is a second way to run a named tunnel that I use when the box is meant to be managed from Cloudflare's dashboard rather than the local config file. You create the tunnel in the Zero Trust dashboard, define ingress there, and run the connector with the token it hands you:
cloudflared tunnel run --token <TUNNEL_TOKEN>
The same systemd unit works, with ExecStart changed to the token form. I keep the token in a root-only file rather than inline in the unit. Dashboard-managed tunnels are convenient when ingress changes often, because you edit rules in the UI with no file to redeploy. Local config is better when I want the whole setup in version control. Both are named tunnels and both survive reboots under systemd.
When I reach for which
I use a quick tunnel only to show someone a local app for a minute, never for anything that has to be there tomorrow. For a self-hosted dashboard, a personal API, a media server, a status page, or any internal tool I want reachable from my phone, I use a named tunnel under systemd every time. Zero open inbound ports, a stable hostname on my own domain, Cloudflare's edge in front, and a connector that comes back on its own after a reboot. That is the whole point of doing it properly once.
Quick takeaways
- A quick tunnel hands you a throwaway random URL that dies on restart. A named tunnel keeps a fixed hostname and credentials across reboots.
- The flow is four steps:
login,tunnel create,tunnel route dns, then aconfig.ymlwith ingress rules ending inhttp_status:404. - Make it reboot-proof with a systemd unit using
After=network-online.target,Restart=on-failure, andWantedBy=multi-user.target, then actually reboot once to prove it. - Four
Registered tunnel connectionlines in the log mean the connector is healthy. - If cloudflared runs in a container,
localhostis the container, not the host. Use the bridge gateway and bind the app to0.0.0.0.
More Automation

Programmatic PDF Table Extraction and OCR with Adobe PDF Services REST: The Auth, the Extract Call, and Parsing the Output
I wired Adobe PDF Services REST into my stack as a local tool and pointed it at the scanned invoices and merged-header statements that pdfplumber turned into soup. Here is the exact auth flow, the extract call, and the structuredData.json parsing I run in production, with the real latency and free-tier limits.

I Gave My AI Agent Eyes and Hands on Native Linux Apps With AT-SPI2
I was tired of my agent missing buttons because a window shifted a few pixels. So I pointed it at the AT-SPI2 accessibility tree instead, the same data a screen reader consumes, and had it act by element name and role. This walks through driving a GTK dialog and a native Save dialog, then reading the value back to prove the action actually landed.

I Run Gemma 3 Vision On A 6GB GTX 1660 For Screenshot OCR: The Real VRAM And Latency Numbers
I host Gemma 3 4B vision on a single 6GB GTX 1660 for screenshot OCR and invoice extraction. Here are the install steps, the exact model tag, the VRAM it actually eats, and the cold versus warm latency I measured this week on my own desktop.