Setting Up a Reverse Proxy with Authentication for Multiple Self-Hosted Services
We earn commissions when you shop through the links on this page, at no additional cost to you. Learn more.
If you're running multiple self-hosted services on your homelab or a small VPS, exposing each one on its own port is messy and unsafe. I prefer putting everything behind a single reverse proxy with centralized authentication—that way, whether someone's accessing Nextcloud, Vaultwarden, or Jellyfin, they authenticate once through Authelia, and the proxy handles the routing. This tutorial walks through a production-ready setup using Caddy as the reverse proxy, Authelia for authentication, and Docker Compose to tie it all together. You can get a reliable public VPS for around $40 per year from providers like RackNerd, which is perfect for this exact use case.
Why Layer Reverse Proxy + Authentication?
Running services directly on ports like 8080, 8081, 8082 creates several problems:
- No central authentication — each app manages its own users, or worse, none at all.
- Exposed to brute force — every service is a separate attack surface.
- Ugly URLs — clients remember
myapp.example.com:8080instead ofmyapp.example.com. - SSL/TLS chaos — each service needs its own certificate, or none at all.
A reverse proxy sitting in front solves this. Caddy automatically handles SSL with Let's Encrypt, and Authelia enforces authentication and session management across all your services without modifying them.
Architecture Overview
Here's what we're building:
- Caddy (port 80/443) — listens on the public internet, terminates TLS, routes traffic to backend services.
- Authelia (port 9091, internal only) — central authentication server; issues session cookies Caddy verifies.
- Your Services (Nextcloud, Vaultwarden, Jellyfin, etc.) — only accessible through the proxy, on internal Docker network.
- Redis (optional but recommended) — stores session state if you have multiple proxy instances or need persistence.
When a user visits nextcloud.example.com, Caddy checks if they have a valid Authelia session cookie. If not, they're redirected to the Authelia login page. After login, they're sent back to their original service, and the proxy sets a cookie valid for all subdomains.
Setting Up Caddy with Authelia
I'm using Caddy because it's the simplest to configure: automatic HTTPS, straightforward syntax, and built-in support for authentication backends. This is my Caddyfile:
{
email [email protected]
default_sni example.com
}
# Authelia auth portal
authelia.example.com {
reverse_proxy localhost:9091
}
# Nextcloud with forward auth
nextcloud.example.com {
forward_auth localhost:9091 {
uri /api/authz/forward-auth
copy_headers Remote-User Remote-Groups
}
reverse_proxy localhost:8080
}
# Vaultwarden with forward auth
vault.example.com {
forward_auth localhost:9091 {
uri /api/authz/forward-auth
copy_headers Remote-User Remote-Groups
}
reverse_proxy localhost:8081
}
# Jellyfin (allows unauthenticated access)
jellyfin.example.com {
reverse_proxy localhost:8082
}
# Protected admin dashboard
admin.example.com {
forward_auth localhost:9091 {
uri /api/authz/forward-auth
copy_headers Remote-User Remote-Groups
}
reverse_proxy localhost:3000
}
The key directive is forward_auth: Caddy forwards incoming requests to Authelia's forward-auth endpoint. If Authelia responds with a 200 status, the request proceeds; anything else redirects to the login page. The copy_headers line ensures your backend services can see who logged in (useful for logging).
Docker Compose: Bringing It All Together
Here's the complete stack. I'm storing secrets in a .env file (never commit this):
version: '3.8'
services:
caddy:
image: caddy:2.8-alpine
container_name: caddy
restart: unless-stopped
ports:
- "80:80"
- "443:443"
volumes:
- ./Caddyfile:/etc/caddy/Caddyfile:ro
- caddy_data:/data
- caddy_config:/config
environment:
ACME_AGREE: "true"
networks:
- proxy
depends_on:
- authelia
authelia:
image: authelia/authelia:latest
container_name: authelia
restart: unless-stopped
environment:
TZ: UTC
AUTHELIA_JWT_SECRET: ${AUTHELIA_JWT_SECRET}
AUTHELIA_SESSION_SECRET: ${AUTHELIA_SESSION_SECRET}
AUTHELIA_STORAGE_POSTGRES_PASSWORD: ${POSTGRES_PASSWORD}
volumes:
- ./authelia/configuration.yml:/config/configuration.yml:ro
- ./authelia/users_database.yml:/config/users_database.yml:ro
networks:
- proxy
expose:
- 9091
depends_on:
- postgres
- redis
redis:
image: redis:7-alpine
container_name: redis_session
restart: unless-stopped
command: redis-server --requirepass ${REDIS_PASSWORD}
volumes:
- redis_data:/data
networks:
- proxy
expose:
- 6379
postgres:
image: postgres:16-alpine
container_name: authelia_db
restart: unless-stopped
environment:
POSTGRES_DB: authelia
POSTGRES_USER: authelia
POSTGRES_PASSWORD: ${POSTGRES_PASSWORD}
volumes:
- postgres_data:/var/lib/postgresql/data
networks:
- proxy
expose:
- 5432
# Example: Nextcloud
nextcloud:
image: nextcloud:latest-apache
container_name: nextcloud
restart: unless-stopped
environment:
MYSQL_HOST: nextcloud_db
MYSQL_DATABASE: nextcloud
MYSQL_USER: nextcloud
MYSQL_PASSWORD: ${NEXTCLOUD_DB_PASSWORD}
volumes:
- nextcloud_data:/var/www/html
- nextcloud_config:/var/www/html/config
networks:
- proxy
expose:
- 80
depends_on:
- nextcloud_db
nextcloud_db:
image: mysql:8
container_name: nextcloud_db
restart: unless-stopped
environment:
MYSQL_ROOT_PASSWORD: ${NEXTCLOUD_DB_PASSWORD}
MYSQL_DATABASE: nextcloud
MYSQL_USER: nextcloud
MYSQL_PASSWORD: ${NEXTCLOUD_DB_PASSWORD}
volumes:
- nextcloud_db_data:/var/lib/mysql
networks:
- proxy
expose:
- 3306
# Example: Vaultwarden
vaultwarden:
image: vaultwarden/server:latest
container_name: vaultwarden
restart: unless-stopped
environment:
DOMAIN: https://vault.example.com
LOG_LEVEL: info
ROCKET_ADDRESS: 0.0.0.0
volumes:
- vaultwarden_data:/data
networks:
- proxy
expose:
- 80
depends_on:
- caddy
# Example: Jellyfin
jellyfin:
image: jellyfin/jellyfin:latest
container_name: jellyfin
restart: unless-stopped
environment:
JELLYFIN_CACHE_DIR: /cache
volumes:
- jellyfin_config:/config
- jellyfin_cache:/cache
- /mnt/media:/media:ro
networks:
- proxy
expose:
- 8096
networks:
proxy:
driver: bridge
volumes:
caddy_data:
caddy_config:
redis_data:
postgres_data:
nextcloud_data:
nextcloud_config:
nextcloud_db_data:
vaultwarden_data:
jellyfin_config:
jellyfin_cache:
Create a .env file in the same directory with strong random values:
AUTHELIA_JWT_SECRET=your-random-64-char-string-here
AUTHELIA_SESSION_SECRET=your-random-32-char-string-here
REDIS_PASSWORD=your-redis-password
POSTGRES_PASSWORD=your-postgres-password
NEXTCLOUD_DB_PASSWORD=your-nextcloud-db-password
Generate these with: openssl rand -base64 32
.env file to version control. Add it to .gitignore. Also, these volumes should be backed up—losing your Authelia database means losing all user accounts.Configuring Authelia
Create authelia/configuration.yml:
---
server:
address: 'tcp://0.0.0.0:9091'
asset_path: /config/assets/
totp:
issuer: CompactHost
period: 30
skew: 1
webauthn:
display_name: CompactHost
attestation_conveyance_preference: indirect
user_verification: preferred
session:
name: authelia_session
domain: example.com
same_site: lax
expiration: 1h
inactivity: 15m
remember_me: 4w
cookies:
- name: authelia_session
domain: example.com
authelia_url: https://authelia.example.com
redis:
host: redis_session
port: 6379
password: ${REDIS_PASSWORD}
database_index: 0
storage:
postgres:
host: postgres
port: 5432
database: authelia
username: authelia
password: ${POSTGRES_PASSWORD}
ssl:
mode: disable
authentication_backend:
file:
path: /config/users_database.yml
watch: true
password:
algorithm: argon2
iterations: 1
salt_length: 16
parallelism: 8
memory: 64
access_control:
default_policy: deny
rules:
- domain: authelia.example.com
policy: bypass
- domain:
- nextcloud.example.com
- vault.example.com
- admin.example.com
policy: one_factor
- domain: jellyfin.example.com
policy: bypass
notifier:
disable_startup_check: true
filesystem:
filename: /config/notifications.txt
Create authelia/users_database.yml with hashed passwords (generate hashes with authelia crypto hash generate argon2 --password 'yourpassword'):
---
users:
john:
displayname: "John Doe"
password: "$argon2id$v=19$m=65540,t=1,p=8$YOUR_HASH_HERE"
email: [email protected]
groups:
- admin
- users
jane:
displayname: "Jane Smith"
password: "$argon2id$v=19$m=65540,t=1,p=8$YOUR_HASH_HERE"
email: [email protected]
groups:
- users
Running and Testing
Start everything:
docker-compose up -d
Check logs:
docker-compose logs -f caddy
docker-compose logs -f authelia
If Caddy reports "permission denied" on ports 80/443, either run with sudo or configure your firewall to forward those ports. On a VPS, this isn't an issue.
Visit https://authelia.example.com and log in with the credentials you created. You should see your profile and the option to enable TOTP (two-factor authentication) if you want extra security.
Then visit https://nextcloud.example.com. You'll be redirected to the login page, authenticate through Authelia, and be sent back to Nextcloud with a valid session. The beauty here: Nextcloud never sees a password. Authelia handles it.
policy: one_factor to policy: two_factor). This adds a second factor of authentication without modifying your backend services.