In the previous post, I showed how SSH-J.com solves a specific problem: accessing a machine behind NAT via SSH, without opening ports on the router and without relying on a public IP. The reverse tunnel works well for interactive sessions and file transfers, and SSH-J.com as a jump host makes everything trivial to configure. For SSH, it remains the simplest solution I know.

But SSH is just one piece of the puzzle. Anyone who maintains a homelab — even if it’s just a mini PC under the desk or a Raspberry Pi in the corner of the room — inevitably ends up running web services: an RSS reader, a monitoring dashboard, a Gitea, a Jellyfin, an Immich. These services listen on local HTTP ports and work perfectly as long as you’re on the same network. The problem appears when you want to access them from outside — from the office, from your phone on the bus, from anywhere that isn’t your local network.

The traditional options are the same as always: port forwarding on the router (which runs into CGNAT and exposes ports to the internet), DDNS to deal with dynamic IP (which solves only half the problem), or a VPN like WireGuard or Tailscale (which works but requires a client installed on each device). Cloudflare Tunnel offers an alternative that doesn’t require any of these things: the local service makes an outbound connection to Cloudflare’s network, which in turn publishes the service on a subdomain of your domain, with HTTPS configured automatically. No open ports, no public IP, no certificates to manage. From the outside, access is a normal URL in the browser.

In this post, I show how I set up my own setup: an RSS reader running in an LXC container on Proxmox, published at rss.sarmento.org through a Cloudflare Tunnel managed locally via CLI and maintained by systemd.

How Cloudflare Tunnel works#

The principle is the same as the SSH-J.com reverse tunnel, just applied to HTTP traffic instead of SSH sessions. A daemon called cloudflared runs on your local machine and opens outbound connections to Cloudflare’s edge network. Since the connection originates from inside your network, NAT is not a problem — the router treats it like any other outbound connection. Cloudflare receives the HTTP requests destined for your subdomain and forwards them through the tunnel to cloudflared, which in turn passes them to the local service.

The difference compared to SSH-J.com is that here there is no third-party relay operating with minimal infrastructure — it’s Cloudflare’s network, with over 300 points of presence worldwide, acting as a reverse proxy for your service. HTTPS is provided by Cloudflare using certificates generated automatically for your domain. Traffic between the visitor’s browser and Cloudflare is encrypted normally with TLS. Traffic between Cloudflare and cloudflared on your machine travels through the tunnel connection, which in turn uses QUIC or HTTP/2 with TLS. And between cloudflared and the local service, since both are on the same machine, communication happens over localhost without encryption — which is perfectly acceptable when everything runs on the same host.

There are two ways to manage a Cloudflare Tunnel: through the web dashboard (Zero Trust → Tunnels) or via the CLI. The dashboard is more visual and allows you to configure everything through the browser, but the result is a “remotely managed” tunnel — the configuration lives on Cloudflare and the local cloudflared only needs a token to connect. The CLI creates a “locally managed” tunnel where the configuration lives in a YAML file on the machine, which gives more control and visibility over what’s running. In this post I use the CLI because it’s the path that makes the most sense for anyone already comfortable with the terminal who wants to understand what each piece does.

Prerequisites#

Before starting, you need three things.

A domain of your own with DNS managed by Cloudflare. If your domain is already on Cloudflare (for example, because you use Cloudflare Pages to host a static site), this requirement is already met. If it isn’t, the process is to add the domain on Cloudflare and point your registrar’s nameservers to the ones Cloudflare provides. The official documentation covers this in detail.

A free Cloudflare account. Cloudflare Tunnel is part of the free plan — no paid plan is required for personal use.

A Linux machine running the service you want to expose. In my case it’s an LXC container with Debian 13 on Proxmox, but it can be any distribution with systemd. The service needs to be listening on a local port — in my example, a web app at http://127.0.0.1:8080.

Installing cloudflared#

cloudflared is the daemon that establishes and maintains the tunnel. On Debian, installation can be done through Cloudflare’s official repository or by downloading the .deb directly. I chose to download the package:

wget https://github.com/cloudflare/cloudflared/releases/latest/download/cloudflared-linux-amd64.deb
sudo dpkg -i cloudflared-linux-amd64.deb

Confirm that the installation worked:

cloudflared --version

The output should show the installed version. If you’re on ARM (Raspberry Pi, for example), replace amd64 with arm64 in the download URL.

Authenticating with Cloudflare#

The next step is to link cloudflared to your Cloudflare account. Run:

cloudflared tunnel login

The command will generate a URL and ask you to open it in a browser. On the page that opens, log in to your Cloudflare account and select the domain that will be used for the tunnel. After authorization, cloudflared saves a certificate at ~/.cloudflared/cert.pem that will be used to create and manage tunnels.

If the machine where you’re installing cloudflared doesn’t have a browser (which is the common case for servers), copy the URL displayed in the terminal and open it on any other computer where you’re logged into Cloudflare. The authorization flow happens in the browser, not on the local machine.

Creating the tunnel#

With authentication done, create the tunnel:

cloudflared tunnel create homelab

The name homelab is an identifier you choose — it can be anything descriptive. The command creates the tunnel in your Cloudflare account and generates a credentials file at ~/.cloudflared/<UUID>.json, where <UUID> is the tunnel’s unique identifier. Note this UUID — you’ll need it for the configuration.

To confirm the tunnel was created:

cloudflared tunnel list

The output shows the name, UUID, and status of each tunnel associated with your account.

Configuring the routing#

The tunnel exists, but it doesn’t yet know where to route traffic. This configuration is done in a YAML file. Create ~/.cloudflared/config.yml:

tunnel: <UUID>
credentials-file: /root/.cloudflared/<UUID>.json

ingress:
  - hostname: rss.sarmento.org
    service: http://127.0.0.1:8080
  - service: http_status:404

Replace <UUID> with the actual UUID of your tunnel and adjust the hostname and port for your case. The ingress block defines the routing rules: requests for rss.sarmento.org are forwarded to the local service on port 8080, and any other request receives a 404. The last rule with service: http_status:404 is mandatory — cloudflared requires a catch-all rule at the end of the ingress.

If you want to expose more than one service through the same tunnel, just add entries to the ingress before the catch-all rule:

ingress:
  - hostname: rss.sarmento.org
    service: http://127.0.0.1:8080
  - hostname: git.sarmento.org
    service: http://127.0.0.1:3000
  - service: http_status:404

Each hostname needs a DNS record on Cloudflare, which is the next step.

Creating the DNS record#

cloudflared has a command that automatically creates the CNAME record pointing the subdomain to the tunnel:

cloudflared tunnel route dns homelab rss.sarmento.org

This command creates a CNAME record in Cloudflare’s DNS pointing rss.sarmento.org to <UUID>.cfargotunnel.com. From this moment on, any request to rss.sarmento.org will hit Cloudflare’s network, which will look for an active tunnel with that UUID to forward it to.

You can verify the record in the Cloudflare dashboard, under DNS → Records for your domain. The CNAME record should appear there with the proxied status active (orange cloud).

Testing manually#

Before creating the systemd service, test the tunnel manually to confirm everything works:

cloudflared tunnel run homelab

cloudflared should connect to Cloudflare’s network and start showing logs in the terminal. Open https://rss.sarmento.org in the browser — the local service should appear, served with HTTPS, without any additional certificate configuration. The terminal will show the requests being forwarded. When you’re satisfied that everything works, stop it with Ctrl+C.

Running as a service with systemd#

cloudflared has a built-in command that creates and configures the systemd service automatically:

sudo cloudflared service install

This command does three things: copies the configuration from ~/.cloudflared/config.yml to /etc/cloudflared/config.yml, copies the credentials file to /etc/cloudflared/, and creates the unit file at /etc/systemd/system/cloudflared.service with the appropriate content. The generated unit file looks like this:

[Unit]
Description=cloudflared
After=network.target

[Service]
TimeoutStartSec=0
Type=notify
ExecStart=/usr/bin/cloudflared --no-autoupdate --config /etc/cloudflared/config.yml tunnel run
Restart=on-failure
RestartSec=5s

[Install]
WantedBy=multi-user.target

Type=notify indicates that cloudflared notifies systemd when it’s ready to receive traffic — a more sophisticated integration than the Restart=always we used in the SSH-J.com post. The --no-autoupdate flag disables cloudflared’s automatic update mechanism, which is the correct behavior when you manage packages through the operating system.

Enable and start the service:

sudo systemctl enable --now cloudflared

Confirm it’s running:

sudo systemctl status cloudflared

The output should show active (running) and the connection logs. From now on the tunnel starts automatically on boot and reconnects on failure.

Differences compared to SSH-J.com#

It’s worth putting the two solutions side by side to understand when to use each one.

SSH-J.com is ideal for SSH access to machines behind NAT. It doesn’t require an account, doesn’t require a domain, and the entire configuration is a single SSH command. The trade-off is that traffic passes through a third-party server (although encrypted end-to-end) and the service is limited to forwarding TCP connections — it can’t expose web applications with HTTPS.

Cloudflare Tunnel is for exposing HTTP and HTTPS services on the internet with your own hostname. HTTPS comes for free, DNS is managed by Cloudflare, and traffic passes through their edge network. On the other hand, it requires a domain with DNS on Cloudflare, installation of cloudflared on the machine, and a somewhat more involved configuration. It’s possible to use Cloudflare Tunnel for SSH too (Cloudflare has specific documentation for that), but the complexity is considerably greater than simply using SSH-J.com.

In practice, the two solutions coexist without conflict. I use SSH-J.com to access machines via SSH when I’m away from home, and Cloudflare Tunnel to publish homelab web apps with a domain and HTTPS. Each tool solves the problem it was designed for.

When something goes wrong#

The tunnel connects but the site doesn’t load#

The most common problem is a mismatch between the hostname configured in the ingress and the DNS record created on Cloudflare. If the CNAME points to a different UUID than what’s in config.yml, traffic doesn’t reach the right tunnel. Check with:

cloudflared tunnel info homelab

Compare the UUID shown with what’s in config.yml and in the CNAME record on the dashboard.

Another frequent scenario is the local service not running or listening on a different address or port than what’s configured. If the ingress points to http://127.0.0.1:8080 but the service is on port 3000, cloudflared will get a connection refused and return a 502 error to the browser. Test the service locally before blaming the tunnel:

curl http://127.0.0.1:8080

If curl works but the browser doesn’t, the problem is between cloudflared and Cloudflare, not between cloudflared and the local service.

The systemd service keeps restarting#

If systemctl status cloudflared shows cycles of startfailedauto-restart, the problem is almost certainly in the configuration. Check the logs:

journalctl -u cloudflared -n 50 --no-pager

The most common errors are: credentials file not found (cloudflared service install didn’t copy correctly, or the path in config.yml is wrong), invalid YAML in config.yml (wrong indentation, missing field), or a missing catch-all rule at the end of the ingress.

Invalid SSL certificate in the browser#

If the browser shows a certificate error when accessing the subdomain, check in the Cloudflare dashboard whether the SSL/TLS mode is set to “Full” or “Flexible” — not “Off”. Cloudflare generates the certificate automatically for your domain, but the SSL mode needs to be active for it to be served. This is normally already the default for domains with active proxy.