Nazeem.Me

A blog about technology, football and all the other random stuff in my life

Letting People In Without Opening a Port

Written by

in

·

Everything on my home network that has a web interface sits behind a reverse proxy, on a domain that never appears in public DNS. That works well until I leave the house.

The usual fix is a VPN, and I run one. But my work laptop can’t install Tailscale, and some of what I want from outside is just a browser tab: my notes in Trilium, my library in Calibre-web, trip plans in Trek, my AI agent Hermes, and copyparty for file transfers.

I had one rule: no port forwards on the router. Nothing on my WAN address should answer a scan.


Two pieces, two jobs

Cloudflare Tunnel gets traffic in. A small daemon, cloudflared, runs inside my network and dials out to Cloudflare, holding a few connections open. When someone requests one of my public hostnames, Cloudflare sends the request back down those connections. The router has no inbound rules at all.

Cloudflare Access decides who gets in. It sits at Cloudflare’s edge, in front of the tunnel. Before a request reaches my network, Access checks for a valid session. No session, and you get a login page: an email allow-list and a one-time PIN sent to that address. The request never leaves Cloudflare.

You need both. A tunnel on its own publishes an app to the whole internet, protected only by whatever login the app ships with. Access on its own has nothing to protect.

I also keep two domains. The LAN domain carries a wildcard certificate and lives only on my Pi-holes. A separate domain is for external access. If a hostname exists on the external domain, I put it there on purpose, and that list is short enough to read in one go.


The setup

The tunnel is remotely managed: you create it in the Zero Trust dashboard, and every connector runs with the same token and pulls its config from Cloudflare. There is no config file to keep in sync.

Each app gets a public hostname that points at a LAN IP and port, never localhost. My connectors don’t run on the same machines as the apps, so localhost would mean the connector’s own box. Even where they share a box, a LAN IP keeps the rule valid when you add a second connector somewhere else.

Each hostname then gets its own Access application rather than one wildcard policy. Each app has its own audience tag (AUD), so I can prove a hostname is gated by its own rules and isn’t quietly inheriting someone else’s. Session length is per app too: 24 hours for file transfer, longer for low-risk things like the book library.

The last layer is the one most guides skip: make the tunnel check the Access token as well. By default, Access is enforced only at the edge. With origin enforcement on, cloudflared validates the Access JWT on every request and refuses anything without a valid one for that app. If I ever delete an Access app by mistake or misconfigure a policy, the tunnel still says no. In a local config file it looks like this:

ingress:
  - hostname: files.example.com
    service: http://192.168.1.50:3923
    originRequest:
      access:
        required: true
        teamName: your-team
        audTag:
          - <the app's AUD>
  - service: http_status:404

In the dashboard it’s the “Protect with Access” setting under the hostname’s origin configuration. For copyparty that makes three locks in a row: Access at the edge, the JWT check in the tunnel, then copyparty’s own password.


Two connectors, one tunnel

My first connector ran on a NAS. That meant one DSM update reboot took down external access to everything at once.

The second connector is a tiny container (one core, 512 MB) on a different host, using the same tunnel token. That detail matters. The same token makes it a replica of the same tunnel, and Cloudflare spreads requests across both. A newly created tunnel would be a separate tunnel and give no failover at all.

The test was simple: stop the NAS connector, make a dozen requests, check they all succeed. They did.

The trap is reachability. Because requests are spread across connectors, every replica must be able to reach every app. If a firewall lets one connector through and blocks the other, roughly half your requests fail, at random, and it looks like a flaky app. Before adding the second connector I checked every origin port from its address first.


The tests that lied to me

This part took longer than the build.

A redirect proves nothing. curl an Access-protected hostname and you get a 302 to the login page. That response comes from Cloudflare’s edge, before anything touches the tunnel. It proves Access is configured. It says nothing about whether the app behind it is reachable. The same goes for “I opened it in my browser and it worked”: often what worked was the login page.

The client IP can’t tell you the path. I wanted to know which connector had served a request. My network runs native IPv6, so a phone on home Wi-Fi has the same public address whether it went through the LAN proxy or out to Cloudflare and back. What works is the TCP peer on the app’s side (ss -tn on the origin): the LAN proxy, connector one and connector two each show up as a different address.

Check the AUD, not the login page. To confirm a new hostname has its own Access app, decode the meta JWT in the redirect’s Location header and compare its aud claim against another app’s. Different AUD, different app.

The dashboard said “saved”. On one hostname the origin JWT setting didn’t persist the first time. The UI looked right. cloudflared logs the entire ingress config every time the dashboard pushes a change, so journalctl -u cloudflared | grep "Updated to new configuration" is the honest version of the settings page.

Apps need to trust the connectors. copyparty logged every visitor as the connector’s IP until I listed both connectors as trusted proxies, so it would read Cloudflare’s client-IP header. Add a connector, and you have to update that list too.


What breaks behind Access

  • Big uploads. Cloudflare’s free plan caps request bodies at 100 MB. Trek accepts 500 MB backup restores, so restores happen on the LAN hostname. copyparty is fine because it uploads in chunks: a 102 MB test went through and survived two mid-upload disconnects.
  • Anything that isn’t a browser. Access is a browser login. Trek’s MCP integration uses an OAuth flow that Access breaks. This is also why Plex is the one service on the tunnel without Access: it has its own account system, and a TV app can’t complete a web login page.
  • Sessions and passkeys are per domain. LAN and external are two separate logins. A passkey enrolled on the LAN hostname won’t work on the external one.
  • Corporate networks. The work laptop’s proxy blocks WebSockets, so pages load and then won’t stay connected: Trilium sync and parts of Hermes suffer. The company also inspects TLS. The Access login survives it, but my employer’s certificate sits in the path, so I’m careful about what I open there.

What it costs

  • Trust. Cloudflare terminates TLS at its edge and sees the traffic decrypted. That’s the price of the design, so decide whether you’re comfortable with it.
  • A dependency. If Cloudflare has a bad day, external access goes with it. The LAN side keeps working. I can connect through a Wireguard tunnel if it comes to that.
  • Speed. It’s fine for web apps. For bulk transfers, I’d use the VPN.
  • Money. Nothing so far. The free Zero Trust plan covers up to 50 users.
  • Video is a grey area. Cloudflare’s terms have long restricted serving large volumes of video on the free tier. Plenty of people run Plex or Jellyfin through a tunnel anyway, but read the current terms before you make it your main streaming path.

Would I do it again?

Yes. Zero open ports, five apps behind one login page, two connectors, and nothing to install on the laptop I don’t control.

The lesson I’d pass on has little to do with Cloudflare: verify at the origin. Everything in front of your app is designed to answer quickly and look healthy. The only proof a request got through is the app seeing it arrive.