Reverse Proxy for a Homelab: Caddy Done Right
Reverse Proxy for a Homelab: Caddy Done Right
You’ve got services running on different ports: Jellyfin on 8096, Grafana on 3000, Nextcloud on 443, and a dozen others. Remembering server:8096 is annoying. Typing IPs is worse. And exposing random ports to the internet? That’s a security incident waiting to happen.
A reverse proxy solves all of this: one entry point, clean URLs, automatic HTTPS, and centralized access control.
What a reverse proxy actually does
A reverse proxy sits in front of your backend services and forwards requests to them. From the outside, users see jellyfin.yourdomain.com — they never know it’s actually running on port 8096 on some box in your closet.
Key benefits:
- Clean URLs —
grafana.home.localinstead of192.168.1.50:3000 - TLS termination — HTTPS handled in one place, not per-app
- Access control — authentication, rate limiting, IP restrictions
- Flexibility — swap backends without changing URLs
A forward proxy sits in front of clients (like a corporate web filter). A reverse proxy sits in front of servers. Same concept, opposite direction.
Why Caddy (and not Nginx or Traefik)
I’ve used all three. Here’s why I settled on Caddy:
| Feature | Caddy | Nginx | Traefik |
|---|---|---|---|
| Automatic HTTPS | Built-in, zero config | Manual (certbot, cron) | Built-in |
| Config syntax | Simple, readable | Verbose, arcane | YAML/labels, learning curve |
| Hot reload | Yes | Requires reload | Yes |
| Best for | Simplicity + power | Raw performance, legacy | Container-native, k8s |
Caddy’s killer feature: automatic HTTPS. It handles Let’s Encrypt certificates, renewal, and OCSP stapling without any configuration. You just define your domains, and it works.
For homelabs, that’s huge. Less toil, fewer moving parts.
Basic setup: one domain, one backend
Let’s start simple. You want jellyfin.yourdomain.com to point to Jellyfin running on port 8096.
Docker Compose
services:
caddy:
image: caddy:latest
container_name: caddy
ports:
- "80:80"
- "443:443"
volumes:
- ./Caddyfile:/etc/caddy/Caddyfile
- ./caddy-data:/data
- ./caddy-config:/config
restart: unless-stopped
networks:
- proxy
jellyfin:
image: jellyfin/jellyfin
container_name: jellyfin
volumes:
- ./jellyfin-config:/config
- /media:/media
networks:
- proxy
# No ports exposed — only accessible through Caddy
networks:
proxy:
name: proxy
Caddyfile
jellyfin.yourdomain.com {
reverse_proxy jellyfin:8096
}
That’s it. Caddy will:
- Obtain a certificate from Let’s Encrypt
- Redirect HTTP to HTTPS
- Proxy requests to Jellyfin
- Handle TLS termination
No certbot. No cron jobs. No nginx.conf nightmares.
Multiple services, one proxy
Extend the pattern for more services:
jellyfin.yourdomain.com {
reverse_proxy jellyfin:8096
}
grafana.yourdomain.com {
reverse_proxy grafana:3000
}
nextcloud.yourdomain.com {
reverse_proxy nextcloud:80
}
# Wildcard for internal services
*.home.yourdomain.com {
tls {
dns cloudflare {env.CF_API_TOKEN}
}
@jellyfin host jellyfin.home.yourdomain.com
handle @jellyfin {
reverse_proxy jellyfin:8096
}
@grafana host grafana.home.yourdomain.com
handle @grafana {
reverse_proxy grafana:3000
}
}
Put all your services on a proxy network so Caddy can reach them by container name. This keeps ports internal — no need to expose 8096 to the host.
Internal domains (no public DNS needed)
For services you only access locally, you don’t need public DNS. Use a local domain like *.home.local or *.lan.
Option 1: Local DNS (Pi-hole, AdGuard Home)
If you run Pi-hole or AdGuard Home, add DNS records pointing your internal domains to your server’s IP:
jellyfin.home.local → 192.168.1.50
grafana.home.local → 192.168.1.50
Option 2: Split-horizon DNS
Use a real domain (e.g., home.yourdomain.com) but resolve it differently internally:
- Externally: doesn’t resolve (or points to nothing)
- Internally: points to your homelab server
Your DNS server (or router) handles the internal resolution.
HTTPS for internal domains
Here’s the catch: Let’s Encrypt can’t verify domains it can’t reach. For internal-only domains, you have two options:
DNS challenge (recommended):
*.home.yourdomain.com {
tls {
dns cloudflare {env.CF_API_TOKEN}
}
# ... your handlers
}
Caddy proves domain ownership by creating a DNS TXT record, not by responding to an HTTP request. Works for internal domains as long as you control the DNS.
Self-signed / internal CA:
Run your own certificate authority and trust it on your devices. More work, but fully offline.
-
Get a Cloudflare API token with
Zone:DNS:Editpermissions for your domain. -
Use the Caddy image with DNS plugins:
services:
caddy:
image: caddy:latest # or build custom with dns.providers.cloudflare
build:
context: .
dockerfile: Dockerfile.caddy# Dockerfile.caddy
FROM caddy:builder AS builder
RUN xcaddy build --with github.com/caddy-dns/cloudflare
FROM caddy:latest
COPY --from=builder /usr/bin/caddy /usr/bin/caddy- Set the environment variable:
environment:
- CF_API_TOKEN=your-cloudflare-api-token- Configure TLS in your Caddyfile:
*.home.yourdomain.com {
tls {
dns cloudflare {env.CF_API_TOKEN}
}
# handlers...
}Now Caddy can obtain wildcards certs for internal domains using DNS validation.
Authentication (forward auth)
Caddy integrates cleanly with forward auth providers like Tinyauth or Authelia. This protects apps that don’t have their own authentication.
dashboard.yourdomain.com {
forward_auth tinyauth:3000 {
uri /api/auth/caddy
copy_headers Remote-User Remote-Email
}
reverse_proxy heimdall:80
}
The copy_headers directive passes the authenticated user’s identity to the backend. Some apps can use this for user identification.
For a full SSO setup, see my SSO for Self-Hosted Apps guide.
Headers and security
Caddy sets sensible defaults, but you can add security headers:
(security_headers) {
header {
# Security headers
Strict-Transport-Security "max-age=31536000; includeSubDomains; preload"
X-Content-Type-Options "nosniff"
X-Frame-Options "SAMEORIGIN"
Referrer-Policy "strict-origin-when-cross-origin"
# Remove server identification
-Server
}
}
jellyfin.yourdomain.com {
import security_headers
reverse_proxy jellyfin:8096
}
Use snippets (the (name) { } syntax) to avoid repeating yourself.
WebSocket support
Some apps use WebSockets for real-time features (chat, notifications, live updates). Caddy handles this automatically for most cases, but explicit configuration helps:
chat.yourdomain.com {
reverse_proxy chat-app:3000 {
# WebSocket-friendly settings
header_up Host {host}
header_up X-Real-IP {remote_host}
header_up X-Forwarded-For {remote_host}
header_up X-Forwarded-Proto {scheme}
}
}
Handling large uploads
Media servers and file sync services need larger upload limits:
nextcloud.yourdomain.com {
request_body {
max_size 10GB
}
reverse_proxy nextcloud:80
}
Load balancing (multiple backends)
If you have multiple instances of a service:
api.yourdomain.com {
reverse_proxy api-1:8080 api-2:8080 api-3:8080 {
lb_policy round_robin
health_uri /health
health_interval 30s
}
}
Caddy will distribute requests and remove unhealthy backends from rotation.
Path-based routing
Route different paths to different backends:
app.yourdomain.com {
handle /api/* {
reverse_proxy api-server:8080
}
handle /static/* {
root * /var/www/static
file_server
}
handle {
reverse_proxy frontend:3000
}
}
The order matters: Caddy evaluates handle blocks top-to-bottom, and the last handle without a matcher acts as the default.
Debugging and troubleshooting
Enable debug logging
{
debug
}
yourdomain.com {
# ...
}
Or check logs:
docker logs caddy --tail 100 -f
Common issues
“502 Bad Gateway”
- Backend isn’t running or isn’t reachable
- Check container networking:
docker exec caddy ping jellyfin - Verify the backend port matches your config
“Certificate error” on internal domains
- You need DNS challenge for domains that aren’t publicly reachable
- Or trust a self-signed cert on your devices
“Redirect loop”
- Usually happens when the backend also does HTTPS redirect
- Make sure the backend serves HTTP, let Caddy handle HTTPS
Changes not applying
- Caddy hot-reloads, but check for syntax errors:
docker exec caddy caddy validate --config /etc/caddy/Caddyfile
My production Caddyfile (annotated)
Here’s a simplified version of what I actually run:
# Global options
{
email you@yourdomain.com # For Let's Encrypt notifications
acme_ca https://acme-v02.api.letsencrypt.org/directory
}
# Snippets for reuse
(security_headers) {
header {
Strict-Transport-Security "max-age=31536000; includeSubDomains"
X-Content-Type-Options "nosniff"
X-Frame-Options "SAMEORIGIN"
-Server
}
}
(forward_auth) {
forward_auth tinyauth:3000 {
uri /api/auth/caddy
copy_headers Remote-User Remote-Email
}
}
# Public services
jellyfin.yourdomain.com {
import security_headers
reverse_proxy jellyfin:8096
}
# Protected internal services
grafana.yourdomain.com {
import security_headers
import forward_auth
reverse_proxy grafana:3000
}
# Auth endpoint (no forward_auth on this one!)
auth.yourdomain.com {
reverse_proxy tinyauth:3000
}
# Wildcard for internal network (DNS challenge)
*.home.yourdomain.com {
tls {
dns cloudflare {env.CF_API_TOKEN}
}
import security_headers
@nas host nas.home.yourdomain.com
handle @nas {
reverse_proxy nas:5000
}
@printer host printer.home.yourdomain.com
handle @printer {
reverse_proxy 192.168.1.100:80
}
}
Caddy vs Tailscale Serve
If you’re already using Tailscale, you might wonder: why not just use Tailscale Serve?
| Consideration | Caddy | Tailscale Serve |
|---|---|---|
| HTTPS for tailnet | DIY (DNS challenge) | Automatic (*.ts.net) |
| Custom domains | Full control | Limited |
| Public exposure | You manage | Use Funnel |
| Advanced routing | Full power | Basic |
| Forward auth | Yes | No |
Use Tailscale Serve when:
- You only need access from your tailnet
- You want zero-config HTTPS
- Simple single-service exposure
Use Caddy when:
- You need custom domains
- You’re exposing services publicly
- You need authentication, path routing, or advanced features
They’re not mutually exclusive — I use both. Tailscale Serve for quick internal access, Caddy for anything with custom domains or public exposure.
Performance considerations
Caddy is plenty fast for homelab workloads. If you’re serving high-traffic production, consider:
- Connection pooling: Caddy does this by default for HTTP/1.1 and HTTP/2
- Gzip/Brotli compression:
encode gzipin your Caddyfile - Caching: Use Cloudflare for public assets, or Caddy’s
cachedirective (experimental)
api.yourdomain.com {
encode gzip
reverse_proxy api:8080
}
Further reading
- Caddy documentation — comprehensive and well-written
- Caddyfile tutorial
- Caddy Docker guide
- DNS providers for Caddy — for DNS challenge
- Self-hosting with Tailscale — secure access without exposing ports
Summary
A reverse proxy is the front door to your homelab. Caddy makes it trivial:
- Define your domains in a Caddyfile
- Point DNS to your server
- Let Caddy handle HTTPS
Add authentication when you need it. Add more backends as you grow. Keep everything behind one clean entry point.
How to set up single sign‑on for your homelab using Pocket ID, Tinyauth, and a reverse proxy, so you're not juggling dozens of passwords.
A practical guide to self‑hosting services and reaching them securely with Tailscale, no port forwarding required.
A practical, human‑readable breakdown of how Tailscale works, why it's different, and when it's the right tool.