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
}
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'
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