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
- A domain name with DNS managed by Cloudflare (free tier works fine)
- Docker and Docker Compose installed on your homelab machine
- A Cloudflare account — the tunnel feature is free
- Basic familiarity with Docker Compose YAML
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:
auth.yourdomain.com→http://caddy:80jellyfin.yourdomain.com→http://caddy:80nextcloud.yourdomain.com→http://caddy:80
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
}
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