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

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:

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
Watch out: Never commit your .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:

On the policy screen, create a policy named Email Allowlist:

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.

Tip: You can add Google or GitHub OAuth as an identity provider in the Zero Trust dashboard under Settings → Authentication → Login methods. Once configured, the login prompt shows a "Continue with Google" button instead of the email OTP flow, which is significantly more convenient for daily use.

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