Zero-Trust Access to Self-Hosted Apps Using Cloudflare Tunnel and Docker
We earn commissions when you shop through the links on this page, at no additional cost to you. Learn more.
Punching holes in your router firewall to expose self-hosted services is a habit that needs to die. Every open inbound port is an attack surface — and if you're running Vaultwarden, Immich, or Uptime Kuma directly on a home IP, you're one misconfigured service away from a very bad day. Cloudflare Tunnel solves this elegantly: an outbound-only encrypted connection from your server to Cloudflare's edge, no inbound ports required, with optional Cloudflare Access policies layered on top for true zero-trust identity enforcement.
In this tutorial I'll walk you through deploying cloudflared as a Docker container alongside your existing services, wiring up a subdomain, and then locking that subdomain down with Cloudflare Access so only people with a verified email address can reach it. The whole stack runs in Docker Compose and costs nothing beyond your existing Cloudflare free plan.
How Cloudflare Tunnel Actually Works
When cloudflared starts, it opens four outbound QUIC/TCP connections to Cloudflare's closest PoPs on port 7844. Cloudflare proxies incoming HTTPS requests down those connections to your service. From the internet's perspective, traffic terminates at Cloudflare. Your home IP is never exposed — not even in DNS. The DNS record for your subdomain points to a Cloudflare-internal address, not your public IP.
Cloudflare Access sits in front of that tunnel. Before any request reaches cloudflared, Cloudflare's edge checks for a valid JWT issued by an identity provider — Google OAuth, GitHub, a one-time PIN sent to an email address, or a full SAML provider if you're on Teams. For a home lab, the email OTP option is perfect: anyone not on your approved list gets a login prompt, not your app.
Prerequisites
- A domain added to Cloudflare (free plan is fine)
- Docker and Docker Compose installed on your server or homelab machine
- An existing self-hosted app already running in Docker (I'll use Uptime Kuma on port 3001 as the example target)
- A Cloudflare account — you'll need access to the Zero Trust dashboard at one.dash.cloudflare.com
Step 1: Create a Tunnel in the Cloudflare Dashboard
Head to one.dash.cloudflare.com → Networks → Tunnels → Create a tunnel. Give it a memorable name like homelab-tunnel and select Cloudflared as the connector type. On the next screen, Cloudflare shows you a token — a long base64 string starting with eyJ. Copy it; you'll need it in a moment. Do not share this token — it authenticates your server to your Cloudflare account.
Still in the dashboard, click Next and add a public hostname. Set:
- Subdomain:
status - Domain: your domain, e.g.
example.com - Type: HTTP
- URL:
uptime-kuma:3001
Notice I'm using the Docker service name uptime-kuma as the hostname, not localhost. That only works when both containers share a Docker network, which we'll set up next.
Step 2: The Docker Compose File
I keep cloudflared in the same Compose file as my apps so it shares the same internal network. Here's a minimal but complete example that runs Uptime Kuma alongside the tunnel connector:
mkdir -p ~/homelab/tunnel && cd ~/homelab/tunnel
nano docker-compose.yml
version: "3.8"
services:
uptime-kuma:
image: louislam/uptime-kuma:1
container_name: uptime-kuma
restart: unless-stopped
volumes:
- uptime-kuma-data:/app/data
networks:
- tunnel-net
# No ports: block — we do NOT expose this to the host
cloudflared:
image: cloudflare/cloudflared:latest
container_name: cloudflared
restart: unless-stopped
command: tunnel --no-autoupdate run
environment:
- TUNNEL_TOKEN=${TUNNEL_TOKEN}
networks:
- tunnel-net
depends_on:
- uptime-kuma
networks:
tunnel-net:
driver: bridge
volumes:
uptime-kuma-data:
Notice there is no ports: directive on the uptime-kuma service. It is completely unreachable from outside Docker. Only cloudflared, which is on the same tunnel-net bridge network, can reach it by service name. This is the key security win — the app never touches your host's network stack.
Create a .env file in the same directory and paste your tunnel token:
echo "TUNNEL_TOKEN=eyJhIjoiY...your-full-token-here" > .env
chmod 600 .env
.env file to a public Git repository. Add it to .gitignore immediately. The tunnel token gives whoever holds it the ability to create tunnel connections authenticated as your Cloudflare account.Now bring everything up:
docker compose up -d
docker compose logs -f cloudflared
You should see lines like Registered tunnel connection with four connection entries. If you see authentication errors, double-check that the token in your .env doesn't have any trailing newline or whitespace.
Step 3: Lock It Down with Cloudflare Access
Right now status.example.com is publicly reachable by anyone who knows the URL. That's better than an open port — Cloudflare's WAF is still in front — but it's not zero-trust. Let's add an Access policy.
In the Cloudflare Zero Trust dashboard go to Access → Applications → Add an application → Self-hosted. Configure it like this:
- Application name: Uptime Kuma
- Application domain:
status.example.com - Session duration: 24 hours (I prefer this over the default 1 hour for internal tools)
On the policy screen, create a policy named Email Allowlist:
- Action: Allow
- Rule type: Emails
- Value: your email address (and anyone else you want to permit)
Save and test. Visiting status.example.com now redirects to a Cloudflare-hosted login page. Enter your allowed email, receive a one-time PIN, enter it, and you're in. The session is stored as a signed cookie — no username or password required for your services, and no VPN client to install on remote devices.
Step 4: Adding More Services
Once the tunnel is running, adding a second service is a one-minute job. Add the container to the same tunnel-net network in your Compose file, then add another public hostname entry in the Cloudflare dashboard (Tunnels → your tunnel → Edit → Public Hostname → Add). For example, to add Vaultwarden:
vaultwarden:
image: vaultwarden/server:latest
container_name: vaultwarden
restart: unless-stopped
volumes:
- vaultwarden-data:/data
environment:
- WEBSOCKET_ENABLED=true
networks:
- tunnel-net
# Again, no ports: — intentional
In the Cloudflare dashboard, add a hostname: subdomain vault, type HTTP, URL vaultwarden:80. Vaultwarden handles its own HTTPS upgrade internally, but since Cloudflare terminates TLS at the edge and sends HTTP internally, this works perfectly. Create a second Access application for vault.example.com if you want a separate policy — or skip Access entirely for Vaultwarden since it has its own strong authentication built in. I personally leave Vaultwarden without an Access policy so mobile apps don't get confused by the Access redirect.
Tunnel Health and Monitoring
The Cloudflare dashboard shows tunnel status at Networks → Tunnels. You'll see green for healthy, orange for degraded (fewer than four connections active), and red for down. For proactive alerting, set up a Cloudflare notification under Notifications → Create and choose Tunnel health event — it can email or webhook you if a connector goes offline.
I also set up an Uptime Kuma monitor pointing at the Access-protected URL using an HTTP keyword check. Since Uptime Kuma lives behind the same tunnel, I monitor it from an external Uptime Kuma instance — a bit recursive, but it works. Alternatively, Cloudflare's own synthetic monitoring (formerly called Browser Rendering) can do this from within the Zero Trust dashboard.
A Note on Where to Run This
This setup works just as well on a cloud VPS as it does on a homelab machine. If you want a persistent, always-on endpoint without worrying about home IP changes or ISP restrictions, a small Droplet on DigitalOcean is ideal — the $6/month 1 vCPU / 1 GB RAM Droplet runs this entire Compose stack with room to spare. You get a stable public IP, no ISP port-blocking headaches, and the Cloudflare Tunnel connector handles all ingress so you still never need to open any inbound ports on the Droplet's firewall.
Wrapping Up
Cloudflare Tunnel with Docker Compose is, in my opinion, the cleanest way to expose self-hosted services in 2026. You get automatic TLS, DDoS protection, and identity-gated access — all without touching your router's firewall rules. The only tradeoff is that all your traffic passes through Cloudflare's infrastructure, so it's not appropriate for services where you need true end-to-end privacy from Cloudflare itself. For everything else — dashboards, media interfaces, dev tools, password managers — it's hard to beat.
As a next step, explore Cloudflare Access's Service Auth feature for machine-to-machine API calls, or set up a WARP-to-Tunnel configuration so your devices connect to your homelab over Cloudflare's WARP client instead of a traditional VPN. Both are covered in the Cloudflare Zero Trust docs and slot naturally into the Compose-based setup you've built here.
Discussion