OptiMoss.ai ← Resources
Stack Series · 6 of 9 Practical · May 2026

Remote access: Cloudflare Tunnel

Reach your stack from a phone in a coffee shop without opening a port on your router, exposing a public IP, or asking a guest to install a VPN client. A cloudflared service builder, the nginx pattern for routing multiple subdomains, an email-PIN gate at the edge, and an honest comparison to Tailscale Funnel and Pangolin for readers who would rather not have Cloudflare in the data path.

§ 0

What this is

At this point the stack runs locally: chat interface, model backend, search. It only answers on localhost, which is exactly what you want for a service holding your conversations. What you also want, eventually, is to use it from somewhere else.

The naive option — forward port 443 on the router to the box — works and is a bad idea. It puts a publicly-discoverable surface on a home IP, asks you to run a TLS terminator with a real certificate, and makes whatever credentials your apps ship with the only thing between an internet scanner and your data.

The shape this article teaches: a small daemon on your machine opens an outbound connection to a tunnelling provider, the provider accepts inbound HTTPS on a hostname you own, and traffic gets handed to the daemon over the existing connection. No port forwarding, no public IP, no DNS for your home network. An identity check at the edge means an unauthenticated request never reaches your box at all.

§ 1

Why Cloudflare, and why not

I run Cloudflare Tunnel with Cloudflare Access in front of it. The honest version of why: it's free at the volume a personal stack uses, the dashboard is straightforward, and the auth layer at the edge does what I need without a second piece of infrastructure.

Cloudflare is not the obvious answer if your reason for self-hosting is independence from large vendors. The tunnel terminates TLS at Cloudflare's edge, which means the plaintext of every request flows through their network before reaching your box. You're trading one trust boundary (your ISP, your router's firmware, every scanner on the open internet) for another (Cloudflare). That trade is good — Cloudflare is a more capable steward of edge traffic than my router is — but it's still a trade.

Two alternatives are worth knowing about — both reasonable, either a drop-in for this article's Cloudflare pieces without disturbing anything else in the stack.

Option What you give up What you get
Cloudflare Tunnel Cloudflare sees plaintext at the edge; you depend on their DNS and account. Free for personal use, identity-aware access policies, the simplest path from zero to working.
Tailscale Funnel Capped to a few well-known ports; Tailscale's coordination server is in the loop. End-to-end TLS to your machine; the same WireGuard mesh you'd use for device-to-device access.
Pangolin You run a cheap VPS as the public endpoint and maintain it. No third-party in the data path; fully self-hosted; open source.

If you've already got Tailscale on your devices, Funnel is a lighter add-on than learning the Cloudflare dashboard. If you have a strong principled objection to your traffic passing through any third party, Pangolin is the right shape. For everyone else, including me, Cloudflare wins on minutes-to-working.

§ 2

Before you start

§ 3

Nginx as the local front door

With one app, you could point the tunnel straight at open-webui:8080 and be done. That stops working the moment you add a second public hostname. The briefing system needs news.example.com, the homepage needs example.com, maybe a Grafana instance needs metrics.example.com — and Cloudflare's per-hostname ingress would have to know about every container's internal port.

The cleaner pattern is a single internal nginx that the tunnel hands every request to. Nginx routes by Host header to whichever backing container should handle it. New subdomain later? One server block in nginx, one DNS record at Cloudflare, no change to the tunnel itself.

Browseryour phone
Cloudflare edgeTLS terminates here
public internet
cloudflaredoutbound tunnel
nginxroutes by Host
your machine
open-webui:8080
·
briefing:8000

cloudflared never accepts an inbound connection from the internet — it dials out to Cloudflare and traffic comes back down the same socket. Nginx and the apps never see the open web.

Add nginx to the Compose file as a service, sharing a network with whatever it needs to reach.

# add to docker-compose.yml — alongside open-webui
services:
  nginx:
    image: nginx:1.27-alpine
    container_name: nginx
    restart: unless-stopped
    ports:
      - # no host ports; only cloudflared talks to it
    volumes:
      - ./nginx/conf.d:/etc/nginx/conf.d:ro
    networks:
      - default

No port mapping. Nginx is reachable only from other containers on the same Docker network; the only thing that should talk to it is cloudflared, in the next section.

Drop a site config in ./nginx/conf.d/owui.conf. Replace owui.example.com with the hostname you'll use.

# nginx/conf.d/owui.conf
server {
  listen 80;
  server_name owui.example.com;

  client_max_body_size 100M;   # file uploads in Open WebUI

  location / {
    proxy_pass http://open-webui:8080;
    proxy_http_version 1.1;
    proxy_set_header Host $host;
    proxy_set_header X-Real-IP $remote_addr;
    proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
    proxy_set_header X-Forwarded-Proto https;

    # Open WebUI uses WebSockets for streaming responses
    proxy_set_header Upgrade $http_upgrade;
    proxy_set_header Connection "upgrade";
    proxy_read_timeout 3600s;
  }
}

The WebSocket upgrade headers and the long proxy_read_timeout matter: without them, streaming chat responses hang or cut off mid-token. The client_max_body_size bump matters as soon as anyone uploads a file. These are the kind of defaults that take a couple of debugging sessions to learn the hard way.

Bring nginx up:

$ docker compose up -d nginx

From the host, docker exec nginx wget -qO- http://open-webui:8080/health should return a body, not a connection error. That confirms nginx can reach the app over the Docker network.

§ 4

The tunnel itself

The cloudflared daemon needs a tunnel to attach to. The current path is dashboard-first — create the tunnel, copy the token, paste it into your Compose file. We'll deliberately leave the public hostname unconfigured for now: that's the switch that makes the tunnel reachable from the internet, and we want the Access policy (§5) in place before we flip it.

  1. In the Zero Trust dashboard, go to Networks → Tunnels, click Create a tunnel, pick Cloudflared as the connector type, give it a name (home-stack works).
  2. The next screen shows install instructions for various platforms. Ignore the install commands — you'll run cloudflared in Docker. The piece you need is the long token string in the Docker tab, after --token. Copy that.
  3. On the Public Hostnames step, skip past it without adding anything. Click through to finish. The tunnel exists, has no hostnames attached, and is not reachable from the internet.

Put the token in a .env file next to your docker-compose.yml:

$ echo 'CLOUDFLARE_TUNNEL_TOKEN=paste-the-long-string-here' >> .env
$ chmod 600 .env

Then generate the cloudflared service block:

Cloudflared · Compose service builder

What the service is called on the Docker network. No reason to change the default unless you run more than one tunnel on the same host.

Cloudflare ships cloudflared updates roughly every few weeks. The latest tag plus pull_policy: always means the nightly cron pull from article 2 (or a manual docker compose pull) will keep you current. Pinning a version is the safer call if a regression bites you, since the connector protocol does change occasionally.

The tunnel is the front door to your stack. unless-stopped means Docker brings it back after a crash or a host reboot, but respects an explicit docker compose stop when you're working on it.

docker-compose.yml (append)

      

Bring it up:

$ docker compose up -d cloudflared
$ docker compose logs -f cloudflared

In the logs you want to see Registered tunnel connection repeated three or four times — cloudflared opens redundant connections to nearby Cloudflare points of presence. Within a few seconds the dashboard tunnel page flips to Healthy.

Nothing is publicly reachable yet. The tunnel is up but has no public hostnames pointing into it, so there's no DNS, no URL to visit. The next section puts the auth in place before we add the hostname that makes it live.

§ 5

Gating it with Cloudflare Access

Open WebUI has its own login screen. That's a real layer, but it's the last layer — bugs in the app or in a dependency become exploitable from the open internet the moment the hostname resolves. What matters is keeping unauthenticated traffic from ever reaching your box.

Cloudflare Access does that at the edge. Before a request is allowed down the tunnel, the visitor has to satisfy a policy you define. The simplest useful policy is an email one-time PIN: enter your email, Cloudflare sends a six-digit code, paste it back, you're through. No identity provider to wire up, no passwords for Access to store. Works fine for a single user or a handful of trusted addresses.

The order matters. Access applications are tied to a hostname string, not to existing DNS — you can create the policy for a hostname that doesn't yet resolve, and it'll sit waiting. We do that first, then add the public hostname to the tunnel in the last step. There's never a window where the app is exposed without auth in front of it.

Create the Access application.

  1. In Zero Trust, go to Access → Applications, click Add an application, pick Self-hosted.
  2. Name it (Open WebUI is fine), set Session duration to something reasonable — I use 24 hours, which means one auth per day on each device.
  3. Add the hostname: owui.example.com. The parent domain dropdown lists your Cloudflare zones. The dashboard may warn that no DNS record exists yet; that's expected.
  4. On the policies step, add a policy named Allow me. Action: Allow. Under Include, pick Emails and add your address (and anyone else who should get in). Save.
  5. On the identity providers step, the default One-time PIN is enabled. That's enough; you don't need to add anything else for this setup. Save the application.

Now wire the hostname into the tunnel. Back in Networks → Tunnels, open your tunnel, go to Public Hostnames, click Add a public hostname. Pick the subdomain (owui) and the parent domain. For the service, set type HTTP and URL nginx:80 — that's the container name and port from §3. Save.

Cloudflare creates the DNS record (a CNAME to a cfargotunnel.com hostname) automatically. The Access policy you created a minute ago immediately starts gating the traffic.

Visit https://owui.example.com. You'll see Cloudflare's login screen — enter your email, check inbox, paste the code. After the PIN, you land on Open WebUI's own login. Two doors, two unrelated credentials, neither of them a port on your router.

Test the lockout, then trust it

Open the hostname in a private window with an unallowed email address and confirm Access refuses you. A misconfigured policy is the kind of thing you want to find before you stop checking.

Power user: identity providers, service tokens, and bypass rules

The PIN flow is the right default for one-to-few users. For a household or a small team, wiring Google or GitHub as an identity provider replaces the PIN with single sign-on against an account people already have. Same Access policy, different login experience.

If something on your stack needs non-interactive access through the tunnel — a script, a CI job, the briefing system reaching back in — Access supports service tokens. The caller presents a client ID and secret as request headers; Access checks them and lets the request through without an identity prompt.

One footgun: a Bypass policy on an Access application disables auth for matching traffic entirely. Useful for a public webhook endpoint on the same hostname; dangerous as a debugging shortcut you forget about. If you create one, comment why in the policy name.

For a small team or organization

Access + Tunnel together replace the most common reason small organizations end up running a VPN: giving a handful of people remote access to an internal web app. The friction profile is genuinely different — there's no client to install, no profile to ship to a new laptop, no helpdesk ticket when someone's certificate expires. A new joiner gets added to an Access policy and works the next minute.

The questions to ask before standardising on this:

  • Is your identity already in Google Workspace, Microsoft 365, or Okta? Access integrates with all three, so the Access policy ends up as "members of group X" and de-provisioning happens via the directory you already update.
  • Are any of the apps stateful in ways that need sticky sessions or per-user IP allowlisting on the backend? Cloudflare adds Cf-Access-Authenticated-User-Email and a signed JWT to each request, but if a backend expects to see the user's real IP, it'll see Cloudflare's.
  • What's the compliance posture? Cloudflare's compliance page lists current attestations; whether they cover what your sector needs is a question for whoever signs off on vendors.
§ 6

When it breaks

Three failure modes account for most of the trouble.

Error 1033 in the browser. Cloudflare's edge accepted the request but couldn't find a healthy tunnel to hand it to. The tunnel is down or the daemon hasn't reconnected yet. docker compose logs cloudflared usually shows the reason — most often a token that got truncated when pasted into the .env file, or an outbound firewall on the network blocking the connector ports.

Error 502 or "Bad Gateway" inside Cloudflare's chrome. The tunnel connected, but the service it's pointing at refused or timed out. Common when the public hostname is set to nginx:80 but nginx isn't on the same Docker network as cloudflared, or hasn't started yet, or the site config inside nginx doesn't match the Host header.

The chat hangs mid-response. WebSocket upgrade headers are missing, or proxy_read_timeout is too short, somewhere in the nginx config. The values in §3 cover Open WebUI; other apps may need their own tuning. The browser's network tab is the fastest way to confirm — a failed WebSocket shows up as a 101 that never completes.

One general check: curl -I https://owui.example.com from a network that isn't your own. A 302 to cloudflareaccess.com means the gate is in front; a direct 200 from the app means Access isn't gating you and the application should be reviewed before you trust it.

§ 7

Where this fits

The stack is now reachable from the outside, gated at the edge, with the host firewall still closed. The next article — Security and privacy — zooms out from this single component to the wider posture: what data sits where, what a sensible threat model looks like for a setup of this shape, and the handful of mistakes that turn an otherwise solid build into an embarrassment.