Implementing Zero-Trust Security with a Self-Hosted Reverse Proxy

Implementing Zero-Trust Security with a Self-Hosted Reverse Proxy

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

Zero-trust security isn't just for Fortune 500 companies anymore. I've spent the last six months hardening my homelab, and implementing zero-trust principles has been the single biggest improvement to my infrastructure. Instead of trusting anything inside my network boundary, I now verify every single request—whether it comes from my local machine, a VPS, or anywhere else. This guide shows you exactly how I did it using a self-hosted reverse proxy with industry-standard tools.

Why Zero-Trust Matters for Your Homelab

Traditional network security relied on a hard perimeter: trust everything inside, block everything outside. That model breaks down fast in a homelab where you're managing multiple services, accessing from different locations, and potentially exposing applications to the internet. Zero-trust flips this: verify every request, every time, regardless of source.

I learned this the hard way when I discovered a misconfigured Docker container had been leaking internal traffic for weeks. The access logs showed requests from machines I didn't recognize. With zero-trust in place, those requests would have been challenged and logged before they ever reached my service.

The core principles are simple: authenticate users, authorize access per application, encrypt everything in transit, and log every decision. My setup uses Caddy as the reverse proxy (I prefer it over Nginx because the config is cleaner), Authelia for authentication and authorization, and Docker Compose to tie it all together.

Architecture Overview

Here's what we're building: all external traffic flows through Caddy. Before any request reaches your internal services, Caddy checks with Authelia. Authelia verifies the user's identity, checks against access policies, and either allows or denies the request. Internal services never handle authentication directly—they sit behind Caddy completely protected.

For your infrastructure, I recommend running this on a dedicated VPS. You can pick up a reliable 2-core, 2GB RAM instance from providers like RackNerd for around $40/year, which is more than sufficient for this setup. Alternatively, if you're running from home, this works on bare metal or even a Raspberry Pi—just ensure your reverse proxy and Authelia boxes can handle the throughput.

Setting Up Caddy and Authelia

The Docker Compose configuration below pulls together everything you need. I'm using environment variables for secrets rather than hardcoding them—never put passwords in your git repos.

version: '3.8'

services:
  caddy:
    image: caddy:2.7-alpine
    container_name: caddy-proxy
    restart: always
    ports:
      - "80:80"
      - "443:443"
    environment:
      - DOMAIN=example.com
      - AUTHELIA_HOST=authelia:9091
    volumes:
      - ./Caddyfile:/etc/caddy/Caddyfile:ro
      - caddy_data:/data
      - caddy_config:/config
    depends_on:
      - authelia
    networks:
      - zeronet

  authelia:
    image: authelia/authelia:4.38.0
    container_name: authelia
    restart: always
    environment:
      - TZ=UTC
    volumes:
      - ./authelia:/config
    expose:
      - "9091"
    networks:
      - zeronet
    healthcheck:
      test: ["CMD", "wget", "--quiet", "--tries=1", "--spider", "http://localhost:9091/api/health"]
      interval: 30s
      timeout: 10s
      retries: 3

  # Example internal service (Nextcloud, Jellyfin, etc.)
  protected_service:
    image: nginx:alpine
    container_name: test_service
    volumes:
      - ./public_html:/usr/share/nginx/html:ro
    expose:
      - "80"
    networks:
      - zeronet

volumes:
  caddy_data:
  caddy_config:

networks:
  zeronet:
    driver: bridge

Now let's configure the Caddyfile. This is where the magic happens—each virtual host checks with Authelia before allowing access.

{
  email [email protected]
  acme_dns cloudflare {env.CLOUDFLARE_API_TOKEN}
}

example.com {
  encode gzip
  reverse_proxy authelia:9091 {
    uri /api/authz/forward-auth
  }
}

app1.example.com {
  forward_auth authelia:9091 {
    uri /api/authz/forward-auth
    copy_headers Remote-User Remote-Groups Remote-Name Remote-Email
  }
  reverse_proxy protected_service:80 {
    header_down -X-Remote-User
    header_down -X-Remote-Groups
    header_down -X-Remote-Name
    header_down -X-Remote-Email
  }
}

app2.example.com {
  forward_auth authelia:9091 {
    uri /api/authz/forward-auth
    copy_headers Remote-User Remote-Groups Remote-Name Remote-Email
  }
  reverse_proxy protected_service:80
}
Watch out: The forward_auth directive is case-sensitive and requires Authelia to be accessible from Caddy. If authentication silently fails, check that Caddy can resolve authelia:9091 and that the health check passes. Test with docker exec caddy caddy validate.

Authelia Configuration

Authelia's config file lives in a directory you mount into the container. Here's a minimal but functional setup that uses OIDC for SSO and supports multiple users with group-based access control.

---
jwt:
  secret: "${JWT_SECRET}"
  expiration: 1h
  refresh_expiration: 168h

default_redirection_url: https://example.com

server:
  host: 0.0.0.0
  port: 9091
  path_prefix: ""

authentication_backend:
  file:
    path: /config/users_database.yml
    password:
      algorithm: pbkdf2
      iterations: 310000
      salt_length: 16
      memory: 64
      parallelism: 8

access_control:
  default_policy: deny
  rules:
    - domain: example.com
      policy: bypass

    - domain: "app1.example.com"
      policy: two_factor
      subject:
        - "group:admin"

    - domain: "app2.example.com"
      policy: one_factor
      subject:
        - "group:users"

session:
  name: authelia_session
  secret: "${SESSION_SECRET}"
  expiration: 3600
  inactivity: 300
  cookies:
    - domain: example.com
      authelia_url: https://example.com/authelia
      default: true
      secure: true
      httponly: true

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

notifier:
  disable_startup_check: true
  smtp:
    host: smtp.gmail.com
    port: 587
    username: "${SMTP_USER}"
    password: "${SMTP_PASSWORD}"
    sender: [email protected]
    identifier: example.com
    subject: "[Authelia] {title}"
    startup_check_address: [email protected]

totp:
  issuer: example.com
  period: 30
  skew: 1

The users database file defines your actual user accounts. Here's the format:

---
users:
  admin:
    displayname: "Admin User"
    password: "$pbkdf2-sha512$310000$..."  # Generate with authelia hash-password
    email: [email protected]
    groups:
      - admin
      - users

  john:
    displayname: "John Doe"
    password: "$pbkdf2-sha512$310000$..."
    email: [email protected]
    groups:
      - users

Generate password hashes by running:

docker run --rm authelia/authelia:4.38.0 authelia hash-password 'YourPassword123'
Tip: Set the JWT_SECRET and SESSION_SECRET environment variables to strong random values. Generate them with openssl rand -base64 32. Store these in a .env file that Docker Compose loads, never commit them to version control.

Deploying to Your VPS

If you're running this on a VPS (which I strongly recommend for any internet-facing deployment), start with a minimal instance. Clone your configuration repo, create the .env file with secrets, and bring up the stack:

git clone https://your-repo/zero-trust-stack.git
cd zero-trust-stack
cp .env.example .env
# Edit .env with your secrets
docker-compose up -d

# Verify Caddy is running
docker-compose logs -f caddy

# Test authentication
curl -I https://app1.example.com/
# Should redirect to /authelia/login

Caddy automatically handles Let's Encrypt certificate provisioning, but you need to configure DNS (I use Cloudflare). Set the CLOUDFLARE_API_TOKEN in your .env, and Caddy will renew certificates automatically every 60 days.

Testing and Hardening

Before exposing this to the internet, test thoroughly. Log in as a regular user and verify you can't access admin-only resources. Try accessing with invalid credentials and confirm you're locked out after the configured attempt limit. Check that all traffic to your protected services is encrypted and that HTTP requests redirect to HTTPS.

Review Authelia's logs to ensure policies are being applied correctly:

docker-compose logs -f authelia | grep "Access control"

Set up log aggregation—I ship logs to a local Loki instance, but even basic file rotation with logrotate is better than nothing. Every denied access attempt, every successful login, and every policy decision should be logged and retained for at least 90 days.

Finally, enable rate limiting at the reverse proxy level to prevent brute force attacks. Add this to your Caddyfile:

app1.example.com {
  rate_limit /authelia/login 5r/m
  forward_auth authelia:9091 {
    uri /api/authz/forward-auth
    copy_headers Remote-User Remote-Groups Remote-Name Remote-Email
  }
  reverse_proxy protected_service:80
}

Next Steps

Once this is running smoothly, consider extending it. Add OIDC providers like Keycloak for more sophisticated identity management. Integrate with your VPS monitoring so failed authentication attempts trigger alerts. And remember: zero-trust is a journey, not a destination. Regularly audit your policies, rotate secrets, and update container images to patch vulnerabilities.

The infrastructure I've described here costs minimal resources—if you're using RackNerd or similar providers, you're looking at $40-60/year for the VPS, plus whatever domain registration costs. That's real security for less than a cup of coffee per month.

Discussion