You deployed Open WebUI or an n8n instance on a VPS, and now there are two bad options: open ports 80 and 443 to the world and hope your reverse proxy config is right, or keep everything private and lose access from your phone. Cloudflare Tunnel is a third option. Your server dials out to Cloudflare, Cloudflare answers the public hostname, and nothing on the VPS listens on a public port for that traffic.
This post sets that up for an AI app: a compose file, how the ingress rules work, how to put a login in front, and the limits that matter for LLM traffic. I checked the claims against Cloudflare's own documentation on 2026-10-09 (fetched with curl as Markdown from developers.cloudflare.com). I validated the compose file and the ingress configuration with the real cloudflared 2026.10.0 binary and ran the containers in a Docker sandbox. I did not have a Cloudflare account or a domain in this session, so a live tunnel, DNS and Access login are unrun and described from the docs.
If you haven't hardened the box yet, start with the VPS hardening checklist, because a tunnel doesn't fix weak passwords inside your app.
What does Cloudflare Tunnel do, and when is it the wrong tool?
Per Cloudflare's Tunnel overview (last updated 2026-09-11 on the page I read), cloudflared runs on your server and creates outbound-only, persistent connections to Cloudflare. You then map public hostnames to local services, for example app.example.com to http://localhost:8080. The docs say tunnels are available on all plans, that you need a domain on Cloudflare to publish applications, and that each tunnel keeps four long-lived connections to two Cloudflare data centres.
That gives you a clear decision table:
| Situation | Tunnel fits? | Why |
|---|---|---|
| Private tool you open from a few devices (n8n editor, Open WebUI for your team) | Yes, with Access | No open ports, and a login before traffic reaches your app |
| Public chat widget for customers | Maybe | Works, but all traffic crosses Cloudflare's proxy; check timeouts and terms |
| A webhook receiver for a payment provider | Works, but do not put an interactive login in front of it | The caller can't log in; protect it with signature checks or a service credential |
| Raw TCP, SSH, databases for other people | Needs cloudflared on the client | The get-started page notes non-HTTP services require it on the client side |
| You don't want a third party in the traffic path | No | Cloudflare terminates and proxies requests; use a VPN or your own reverse proxy |
| One VPS, one public site, Caddy already working | Not needed | A tunnel adds a dependency; see nginx vs Caddy vs Traefik |
The last two rows are the honest ones. A tunnel moves trust to Cloudflare. For prompts that contain sensitive business or customer data, decide that on purpose, and read the local vs cloud LLM privacy guide.
What do you need before you start?
From the get-started page (updated 2026-09-30): a Cloudflare account, a domain on Cloudflare, and a server with internet access where you install cloudflared. The page also tells you to check that your server can reach Cloudflare on port 7844 if it sits behind a restrictive firewall.
That last point collides with advice in other hardening guides. If you restrict outbound traffic on the VPS, allow egress on port 7844 (TCP and UDP) to the Cloudflare tunnel endpoints. The firewall-rules section of Cloudflare's tunnel configuration page lists the destinations (region1.v2.argotunnel.com and region2.v2.argotunnel.com, with fixed IP lists) and says port 443 is optional, used for update checks and Access JWT validation. Don't copy IP lists from a blog post. Read the current ones on that page.
What does the Docker Compose setup look like?
Create the tunnel in the dashboard first (Networking, then Tunnels, then Create Tunnel). Cloudflare gives you a token. The docs' Docker example is:
docker run cloudflare/cloudflared:latest tunnel --no-autoupdate run --token <TUNNEL_TOKEN>
In Compose, with the token in an env file and your app on a private network, this is the file I validated and ran:
services:
cloudflared:
image: cloudflare/cloudflared:2026.10.0
restart: unless-stopped
command: tunnel --no-autoupdate run
environment:
TUNNEL_TOKEN: ${TUNNEL_TOKEN:?set TUNNEL_TOKEN in .env}
networks:
- edge
mem_limit: 256m
app:
# stand-in for Open WebUI, LiteLLM, n8n or your FastAPI app
image: traefik/whoami:v1.10.3
restart: unless-stopped
command: --port=8080
networks:
- edge
# no ports: section on purpose. cloudflared reaches it as http://app:8080
networks:
edge:
Notes on the choices:
- Image tag pinned.
2026.10.0is the version mycloudflared --versionreported (built 2026-10-05) and the Docker Hub tag pulled fine. The docs' example useslatest; I pin so a restart can't change the daemon. Cloudflare's downloads page says it supportscloudflaredversions within one year of the most recent release, so you do need to update eventually. TUNNEL_TOKENthrough the environment. The${VAR:?message}syntax makes Compose refuse to start when the token is missing. With an empty environment,docker compose config -qprintedrequired variable TUNNEL_TOKEN is missing a value: set TUNNEL_TOKEN in .env. Treat the token like a password:chmod 600 .env, keep it out of git. Anyone holding it can run a connector for your tunnel.- No
ports:on the app. Both services share theedgenetwork, socloudflaredreaches the app by service name. From the host,curl http://127.0.0.1:8080failed withConnection refused(curl exit code 7), while a throwaway container on the same network got a normal response fromhttp://app:8080. That is the whole point: nothing published, nothing for Docker to punch through ufw. The service list showed only80/tcpunder the app, which is the image's declared port and not a published one. - The audit script is happy. I ran the compose audit script from the hardening post on this file and it reported
no findings: no published ports, nodocker.sock, a pinned image, and the token read from a variable, not written in the file.
With a made-up token, the container logs Provided Tunnel token is not valid. and restarts in a loop (status Restarting (255)). That is what you'd see if you mis-paste the token. A real token connects, and the dashboard shows the tunnel as Healthy. I couldn't test that step.
How do you add the public hostname?
In the dashboard: your tunnel, then Routes, then Add route, then Published application. Choose a subdomain and domain, and set the Service URL to where cloudflared can reach the app. In the Compose setup above, that is http://app:8080. The docs' own examples use http://localhost:80, which works when cloudflared runs on the host network or as a host service. Inside a container, localhost is the container itself, so use the service name.
Cloudflare's docs say that in a full DNS setup the DNS record is created for you when you add the route. If your domain uses a partial CNAME setup, you must create the CNAME at your DNS provider yourself.
Keep the origin on plain HTTP across the private Docker network unless you have a reason not to. If your origin serves HTTPS or redirects HTTP to HTTPS, the get-started page links a troubleshooting page for choosing the Service URL and origin settings; read it before turning off certificate verification.
What if you prefer a config file in git?
The other way is a locally-managed tunnel with an ingress file. The format, from Cloudflare's configuration-file page (updated 2026-09-30):
tunnel: 6ff42ae2-765d-4adf-8112-31c55c1551ef
credentials-file: /etc/cloudflared/6ff42ae2-765d-4adf-8112-31c55c1551ef.json
ingress:
- hostname: chat.example.com
service: http://open-webui:8080
- hostname: api.example.com
service: http://litellm:4000
- service: http_status:404
(The tunnel ID is the docs' example ID. Use your own.) Two rules from the docs: rules are evaluated top to bottom, and the last rule must be a catch-all with no hostname or path filter. I ran Cloudflare's own validator on this file:
$ cloudflared tunnel --config config.yml ingress validate
Validating rules from config.yml
OK
Then on a copy with the catch-all line deleted:
Validation failed: The last ingress rule must match all URLs (i.e. it should not have a hostname or path filter)
And the matcher, which tells you which rule a URL will hit:
$ cloudflared tunnel --config config.yml ingress rule https://chat.example.com/login
Matched rule #0
hostname: chat.example.com
service: http://open-webui:8080
$ cloudflared tunnel --config config.yml ingress rule https://evil.example.com/
Matched rule #2
service: http_status:404
The second case is why the catch-all returns 404 and not forwards to your most sensitive app. Run both commands in CI or before every config change. A related docs detail: cloudflared matches request paths but forwards the full path to your service, with no stripping. If your app needs a prefix removed, do it in a local reverse proxy.
I would not choose between the two modes on features I haven't compared. The token route is the one the get-started page walks through; the file route suits people who want everything reviewable in a repo.
How do you put a login in front of the app?
Don't rely on a hidden hostname. Cloudflare Access sits in front of a published application and makes users authenticate before a request reaches your origin. From Cloudflare's "Publish a self-hosted application" page (updated 2026-04-17):
- Create the Access application first. The page says that without one, the published application is available to anyone on the internet.
- Add an Allow policy. Access applications are deny by default; a user must match an Allow policy.
- Optionally enable independent MFA and pick a session duration.
- Validate the Access token at the origin. One option is the "Protect with Access" setting for the tunnel, so
cloudflaredvalidates it for you; the alternative is to check the JWT in your app.
I didn't use Access in this session, and I haven't checked what the free plan allows today, so check the current plan limits before you plan around it. Access policies and an app's own login are separate layers. Keep both.
Where Access gets in the way: webhooks. A payment provider can't complete a browser login. Put callbacks on a separate hostname, verify the provider's signature in your code (see Razorpay webhooks and idempotency for the pattern), and keep the interactive admin tools on the Access-protected hostname.
Which limits matter for LLM traffic?
Traffic through a tunnel hostname goes through Cloudflare's proxy, so proxy limits apply. From the connection-limits page (updated 2026-07-23):
| Limit | Value | What the visitor sees |
|---|---|---|
| Proxy read timeout | 125 seconds (configurable for Enterprise zones only) | 524 |
| Proxy write timeout | 30 seconds | 524 |
| Proxy idle timeout | 900 seconds | 520 |
| Request header total | 128 KB | n/a |
| URL length | 16 KB | n/a |
The 524 page defines the 125-second timeout as the origin not providing an HTTP response within that time. That is the number to design around for slow reasoning models and long agent runs. The usual remedies are in the Cloudflare free plan post: stream, use a job ID and polling, or serve that endpoint from a DNS-only hostname. I didn't test streaming through a tunnel, so try it with your own app, including WebSocket features such as live chat updates.
How do you shut the front door?
When the tunnel works and Access is on, you can stop accepting public web traffic on the VPS. With the ufw setup from the hardening post, these are the commands (unrun):
sudo ufw delete allow 80/tcp
sudo ufw delete allow 443/tcp
sudo ufw status verbose
Then check from outside the server, as the hardening post recommends, with nmap -Pn -p 22,80,443 your.server.ip. Port 22 should be the only open one. Open a second SSH session before you change firewall rules, and test the public hostname before and after.
If you want to leave a way back, keep a provider-level firewall rule you can re-enable from the provider's panel.
What can go wrong?
- Everything on the tunnel is public by default. Add Access before adding the route.
- One tunnel, one failure point. If
cloudflaredstops, so does your public access. The docs say you can run multiple replicas of a tunnel (up to 25, each with four connections) for high availability. For a small setup,restart: unless-stoppedand a monitor are the realistic baseline. - Wrong service address.
localhostin a container isn't the host. Use Docker service names, as above. - Token in a screenshot or repo. Rotate it if it leaks.
- Egress blocked. If you lock down outbound traffic and forget port 7844, the tunnel never connects.
- Streaming and long responses. Test before you depend on them.
What did I not verify?
- A live tunnel: account, domain, DNS, Access login, Healthy status. None were available.
- Behaviour of WebSockets, streaming responses and large uploads through a tunnel.
- Free-plan limits for Access or Zero Trust. The pricing page didn't give readable text when I fetched it.
- Whether Cloudflare's terms allow your type of traffic. Read them for your use case.
Checklist
- Cloudflare account and the domain on Cloudflare.
- Access application and Allow policy created before the route.
- Tunnel token in a
chmod 600env file, image tag pinned. - App containers publish no ports;
cloudflaredreaches them by service name. - If using a config file:
ingress validatepasses and the catch-all is last. - Egress to port 7844 allowed if you restrict outbound traffic.
- Webhooks on a separate hostname with signature verification.
- Slowest real request tested against the 125-second limit.
- Ports 80 and 443 closed in ufw and verified with an outside port scan.
- A monitor watching the public hostname (see the Uptime Kuma post).



