Setting Up a Reverse Proxy with Authentication for Multiple Self-Hosted Services

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:

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:

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

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

Tip: For production, enable TOTP in Authelia's configuration and require it in the access_control rules (change policy: one_factor to policy: two_factor). This adds a second factor of authentication without modifying your backend services.

Handling Services That Don't Trust the Proxy