Zero-Trust Access to Your Homelab Using Cloudflare Tunnels and Self-Hosted Authelia

Zero-Trust Access to Your Homelab Using Cloudflare Tunnels and Self-Hosted Authelia

We earn commissions when you shop through the links on this page, at no additional cost to you. Learn more.

Port forwarding used to be the standard way to expose homelab services to the internet, and I used to do it exactly that way — Jellyfin on 8096, Nextcloud on 443, Vaultwarden on 8443, all punched through my router. Then I had a serious wake-up call when a misconfigured Nginx rule briefly exposed my Gitea admin panel without authentication. That was the last time I forwarded a port. Since then my entire homelab is accessed through a combination of Cloudflare Tunnels and Authelia, and the difference in my peace of mind is night and day.

This tutorial walks through the full setup: installing cloudflared as a Docker container, running Authelia as your authentication middleware, wiring it all together with a Caddy reverse proxy inside Docker Compose, and configuring forward auth so every subdomain requires a login before Cloudflare even touches your server. No open inbound ports required — not even 80 or 443.

How This Architecture Actually Works

The key insight is that Cloudflare Tunnels create an outbound-only connection from your machine to Cloudflare's edge. Your server dials out; nothing dials in. Cloudflare terminates HTTPS at the edge, forwards requests down the tunnel to cloudflared, which hands them to your local reverse proxy (Caddy, in my setup). Caddy then checks with Authelia before passing the request to the actual service.

Authelia handles the full authentication layer: username/password with bcrypt hashing, TOTP two-factor, and optional Duo push. It also provides a single-sign-on session cookie, so once you authenticate on auth.yourdomain.com you don't have to log in again when you navigate to jellyfin.yourdomain.com or nextcloud.yourdomain.com within the same browser session.

The traffic flow looks like this:

# Request flow (no ports open on your router):
# Browser → Cloudflare Edge (HTTPS) → cloudflared tunnel (outbound from your server)
#   → Caddy (local, port 80) → Authelia forward-auth check
#     → If authenticated: upstream service
#     → If not: redirect to auth.yourdomain.com login portal

Prerequisites

Tip: If you're running this on a VPS rather than a homelab machine, DigitalOcean Droplets are a solid choice — a $6/month basic Droplet handles Authelia, Caddy, and cloudflared without breaking a sweat, and you still get the same zero-trust benefits since the tunnel eliminates the need to open firewall ports.

Step 1 — Create a Cloudflare Tunnel

Log into your Cloudflare dashboard, go to Zero Trust → Access → Tunnels, and click Create a tunnel. Give it a name like homelab. Cloudflare will show you a token — copy it. You don't need to run their installer; you'll pass the token directly to the Docker container.

While you're in the tunnel config, add your public hostnames. For each service you want to expose, map a subdomain to an internal address. Since Caddy will be handling routing internally, all your tunnels point at the same Caddy container:

Step 2 — Docker Compose Stack

I keep everything in a single Compose file for simplicity. All services share a custom bridge network so Caddy can reach both Authelia and the actual app containers by name. Here's the complete stack:

# docker-compose.yml
version: "3.8"

networks:
  proxy:
    driver: bridge

services:

  cloudflared:
    image: cloudflare/cloudflared:latest
    restart: unless-stopped
    command: tunnel --no-autoupdate run
    environment:
      - TUNNEL_TOKEN=${CLOUDFLARE_TUNNEL_TOKEN}
    networks:
      - proxy

  caddy:
    image: caddy:2-alpine
    restart: unless-stopped
    volumes:
      - ./Caddyfile:/etc/caddy/Caddyfile:ro
      - caddy_data:/data
      - caddy_config:/config
    networks:
      - proxy

  authelia:
    image: authelia/authelia:latest
    restart: unless-stopped
    volumes:
      - ./authelia:/config
    environment:
      - TZ=Europe/London
    networks:
      - proxy

  jellyfin:
    image: jellyfin/jellyfin:latest
    restart: unless-stopped
    volumes:
      - ./jellyfin/config:/config
      - /mnt/media:/media:ro
    networks:
      - proxy

volumes:
  caddy_data:
  caddy_config:

Store your tunnel token in a .env file in the same directory: CLOUDFLARE_TUNNEL_TOKEN=your_token_here. Never commit that file to git.

Step 3 — Configure Authelia

Create an authelia/ directory and add a configuration.yml. The critical piece is the session domain — it must match your root domain so the cookie works across all subdomains:

# authelia/configuration.yml
server:
  host: 0.0.0.0
  port: 9091

log:
  level: info

theme: dark

jwt_secret: "REPLACE_WITH_LONG_RANDOM_STRING"

default_redirection_url: https://auth.yourdomain.com

authentication_backend:
  file:
    path: /config/users_database.yml
    password:
      algorithm: bcrypt
      iterations: 12

session:
  name: authelia_session
  secret: "REPLACE_WITH_ANOTHER_LONG_RANDOM_STRING"
  expiration: 1h
  inactivity: 10m
  domain: yourdomain.com

regulation:
  max_retries: 5
  find_time: 2m
  ban_time: 10m

storage:
  local:
    path: /config/db.sqlite3

notifier:
  filesystem:
    filename: /config/notification.txt

access_control:
  default_policy: deny
  rules:
    - domain: auth.yourdomain.com
      policy: bypass
    - domain: jellyfin.yourdomain.com
      policy: two_factor
    - domain: nextcloud.yourdomain.com
      policy: two_factor

Then create authelia/users_database.yml with your hashed password. Generate the bcrypt hash with: docker run --rm authelia/authelia:latest authelia crypto hash generate bcrypt --password 'yourpassword'. Paste the output hash into the file.

Step 4 — Caddy Forward Auth Configuration

Caddy's job is to intercept every request and ask Authelia whether it's allowed before proxying upstream. The forward_auth directive handles this neatly:

# Caddyfile
{
  admin off
}

(authelia_forward_auth) {
  forward_auth authelia:9091 {
    uri /api/verify?rd=https://auth.yourdomain.com
    copy_headers Remote-User Remote-Groups Remote-Name Remote-Email
  }
}

auth.yourdomain.com {
  reverse_proxy authelia:9091
}

jellyfin.yourdomain.com {
  import authelia_forward_auth
  reverse_proxy jellyfin:8096
}

nextcloud.yourdomain.com {
  import authelia_forward_auth
  reverse_proxy nextcloud:80
}
Watch out: Caddy is running inside Docker without any port bindings to the host — it only needs to be reachable by cloudflared on the same Docker network. If you accidentally bind port 80 or 443 to the host with ports: in your Compose file, you've re-opened your server to direct internet traffic, defeating the whole point. Leave the ports: section out of the Caddy service entirely.

Step 5 — Bring It Up and Test

With your .env, docker-compose.yml, Caddyfile, and authelia/ directory all in place, start the stack:

docker compose up -d

# Watch the logs to confirm cloudflared connects and Authelia starts cleanly
docker compose logs -f cloudflared authelia

# You should see cloudflared report:
# "Registered tunnel connection" with your tunnel name

# And Authelia should show:
# "Authelia v4.x.x is starting"
# "Starting HTTP server on :9091"

Open https://jellyfin.yourdomain.com in your browser. You should be redirected to https://auth.yourdomain.com, where Authelia will prompt for your username, password, and TOTP code. After authenticating, you'll be sent back to Jellyfin automatically. The session cookie works across all your subdomains until it expires or you click logout.

Hardening and Tuning Tips

A few things I do on top of the base setup: I enable Cloudflare's WAF and Bot Fight Mode at the Cloudflare dashboard level — since all traffic goes through Cloudflare anyway, you might as well use their free DDoS and bot mitigation. I also set the Cloudflare SSL/TLS mode to Full (not Full Strict — since Caddy isn't presenting a cert to cloudflared, it's HTTP inside the tunnel). For extra paranoia, you can add Cloudflare Access policies on top of Authelia, requiring a one-time email PIN before the tunnel even lets requests through — that's genuinely two independent authentication layers.

On the Authelia side, I strongly recommend enabling email or SMTP notifications instead of the filesystem notifier — the filesystem notifier is fine for testing but you want real password reset emails in production. Authelia integrates cleanly with any SMTP relay including Mailgun, Postmark, or a self-hosted Maddy instance.

Wrapping Up

This setup has been running in my homelab for over a year without a single incident. My router has zero forwarded ports, Cloudflare absorbs any scanning or probing before it reaches my network, and Authelia means even if someone obtained my Cloudflare credentials they'd still need my TOTP device to access any service. The whole stack — cloudflared, Caddy, Authelia, and your actual apps — fits comfortably on a machine with 2GB of RAM.

Your immediate next steps: add the rest of your services to the Caddyfile and Authelia access_control rules, then set the Authelia default_policy to deny permanently so anything you forget to configure is blocked by default rather than open. After that, look into Authelia's OpenID Connect support — you can use Authelia as an OIDC provider for apps like Gitea and Nextcloud, giving you proper SSO rather than just forward-auth header injection.

If you need a reliable VPS to host this stack rather than running it on local hardware, create a DigitalOcean account — their Droplets are straightforward to spin up and the $6 tier is more than enough for a personal Authelia + Caddy gateway.

Discussion