Caddy Reverse Proxy: HTTPS Without the Headache
Discover why Caddy's automatic HTTPS and simple Caddyfile syntax make it the easiest reverse proxy for homelab setups. No Certbot, no manual certificate management—just write your domain and go.
Table of Contents
- The Certificate Management Problem
- What Makes Caddy Different
- The Caddyfile: Configuration That Humans Can Read
- Getting Started: Docker Compose Setup
- Your First Reverse Proxy
- Behind the Magic: Caddy’s Architecture
- CertMagic: The Secret Sauce
- Caddyfile Directives
- reverse_proxy
- file_server
- encode
- handle
- Basic Authentication
- Path Matching and Subpaths
- Named Matchers
- Wildcards and DNS Challenge
- Building Caddy with xcaddy
- Cloudflare Configuration
- Real-World Homelab Configuration
- Proxmox Integration
- Option 1: Dedicated Caddy LXC
- Option 2: Docker in LXC
- Option 3: Cloudflare Tunnel
- Caddy vs Tailscale Funnel
- Advanced: Multiple Upstreams and Load Balancing
- Performance and Observability
- Metrics Endpoint
- Admin API
- Performance Benchmarks
- When to Choose Caddy
- Troubleshooting Common Issues
- Certificates Not Obtaining
- DNS Challenge Failing
- Permission Denied on Ports 80/443
- Conclusion: The Simplicity Dividend
- Quick Reference
If you’ve ever spent an afternoon wrestling with Nginx config files, debugging why Let’s Encrypt certificates won’t renew, or trying to remember which port needs to be forwarded… Caddy is here to end that suffering. It’s the reverse proxy that just works—automatic HTTPS included.
The Certificate Management Problem
Every self-hoster knows the dance:
- Set up Nginx or Apache
- Install Certbot
- Run Certbot with the right flags
- Set up a cron job for renewal
- Debug when renewal silently fails six months later
- Wonder why your site shows security warnings
Certificate expiration is one of the most common causes of website downtime. Let’s Encrypt certificates only last 90 days—if renewal fails, your site breaks silently.
Caddy eliminates this entire workflow. It obtains, installs, and renews TLS certificates automatically. You don’t think about certificates. You don’t configure cron jobs. You just… write your domain name.
What Makes Caddy Different
Caddy was created by Matthew Holt in 2014 with a simple but revolutionary idea: a web server that uses HTTPS by default. Not “HTTPS available” or “HTTPS with configuration”—HTTPS is the baseline behavior.
Here’s what sets it apart from Traefik and Nginx:
| Feature | Caddy | Traefik | Nginx |
|---|---|---|---|
| Automatic HTTPS | ✅ Built-in, zero config | ✅ Built-in, needs config | ❌ Manual (Certbot) |
| Config Format | Caddyfile (intuitive) | YAML/TOML (verbose) | nginx.conf (complex) |
| Learning Curve | Low | Medium | High |
| HTTP/3 | ✅ On by default | ✅ Available | ❌ Manual compile |
| DNS Challenge | Module needed | Built-in providers | Manual setup |
| Docker Labels | ❌ No | ✅ Primary method | ❌ No |
| Static Binary | ✅ Yes | ❌ No | ✅ Yes |
If you prefer Docker labels and have a dynamic container fleet that changes frequently, Traefik’s label-based discovery might suit you better. But for simpler setups, Caddy’s Caddyfile approach is significantly more readable.
The Caddyfile: Configuration That Humans Can Read
Caddy uses a configuration format called the Caddyfile. It’s designed to be read and written by humans. Compare:
Nginx:
server {
listen 80;
server_name example.com;
return 301 https://$host$request_uri;
}
server {
listen 443 ssl http2;
server_name example.com;
ssl_certificate /etc/letsencrypt/live/example.com/fullchain.pem;
ssl_certificate_key /etc/letsencrypt/live/example.com/privkey.pem;
location / {
proxy_pass http://localhost:8080;
proxy_set_header Host $host;
proxy_set_header X-Real-IP $remote_addr;
}
}
Caddy:
example.com {
reverse_proxy localhost:8080
}
That’s it. Three lines. Caddy handles:
- HTTP → HTTPS redirect
- TLS certificate provisioning
- Certificate renewal
- HTTP/2 and HTTP/3
- Proper headers
Getting Started: Docker Compose Setup
Let’s set up Caddy the right way. Create the directory structure:
mkdir -p ~/caddy/{data,config}
cd ~/caddy
touch Caddyfile
Here’s a production-ready docker-compose.yml:
version: "3.8"
services:
caddy:
image: caddy:latest
container_name: caddy
restart: unless-stopped
ports:
- "80:80"
- "443:443"
- "443:443/udp" # HTTP/3 support
volumes:
- ./Caddyfile:/etc/caddy/Caddyfile:ro
- ./data:/data
- ./config:/config
environment:
# Uncomment if using Cloudflare DNS challenge
# - CF_API_TOKEN=your-cloudflare-api-token
# Example backend service
app:
image: nginx:alpine
restart: unless-stopped
# No labels needed! Just reference in Caddyfile
The :ro flag on Caddyfile makes it read-only in the container—you don’t want Caddy modifying your config.
Your First Reverse Proxy
Create a Caddyfile in the same directory:
yourdomain.com {
reverse_proxy app:80
}
Run it:
docker compose up -d
Caddy will:
- Detect
yourdomain.comneeds HTTPS - Request a certificate from Let’s Encrypt
- Set up automatic HTTP → HTTPS redirect
- Enable HTTP/2 and HTTP/3
- Proxy requests to your
appcontainer
No certificate management. No manual port configuration. It just works.
Behind the Magic: Caddy’s Architecture
Caddy doesn’t use configuration files the way Nginx does. Its architecture is fundamentally different:
┌─────────────────────────────────────────────────────────────────┐
│ CADDY SERVER │
│ │
│ ┌──────────────────────────────────────────────────────────┐ │
│ │ CERTMAGIC (Automatic TLS) │ │
│ │ • Let's Encrypt & ZeroSSL (with automatic failover) │ │
│ │ • Certificate caching and renewal │ │
│ │ • On-demand TLS for dynamic domains │ │
│ └──────────────────────────────────────────────────────────┘ │
│ │ │
│ ▼ │
│ ┌──────────────────────────────────────────────────────────┐ │
│ │ HTTP SERVER │ │
│ │ • HTTP/1.1, HTTP/2, HTTP/3 (QUIC) │ │
│ │ • Request routing and matching │ │
│ │ • Middleware chain execution │ │
│ └──────────────────────────────────────────────────────────┘ │
│ │ │
│ ▼ │
│ ┌──────────────────────────────────────────────────────────┐ │
│ │ HANDLERS │ │
│ │ • reverse_proxy • file_server • respond │ │
│ │ • encode • templates • php_fastcgi │ │
│ └──────────────────────────────────────────────────────────┘ │
└─────────────────────────────────────────────────────────────────┘
CertMagic: The Secret Sauce
Caddy’s automatic HTTPS is powered by CertMagic, a separate Go library Matthew Holt developed. It handles:
- Multi-issuer fallback: Try Let’s Encrypt, fall back to ZeroSSL automatically
- Background renewal: Certificates are renewed 30 days before expiration
- Error recovery: Network issues? Caddy retries with exponential backoff
- Rate limiting: Built-in throttle to avoid CA rate limits
CertMagic is available as a standalone Go library. You can use it in any Go project for automatic certificate management.
Caddyfile Directives
Caddyfile has a simple but powerful directive system. Here are the essential ones:
reverse_proxy
The workhorse. Proxies requests to backend services:
app.example.com {
reverse_proxy localhost:8080
}
With health checks:
app.example.com {
reverse_proxy {
to app1:8080
to app2:8080
health_uri /health
health_interval 30s
}
}
file_server
Serve static files:
example.com {
root * /var/www/html
file_server
}
With browsing enabled:
files.example.com {
root * /var/www/files
file_server browse
}
encode
Enable compression:
example.com {
encode gzip zstd
reverse_proxy localhost:8080
}
handle
Conditional routing:
example.com {
handle /api/* {
reverse_proxy localhost:8000
}
handle {
root * /var/www/html
file_server
}
}
Basic Authentication
Protect routes:
admin.example.com {
basicauth {
admin $2a$14$hashedpassword
}
reverse_proxy localhost:8080
}
Generate passwords with Caddy’s built-in command:
caddy hash-password --plaintext "yourpassword"
Path Matching and Subpaths
Caddy’s matchers let you route based on paths, headers, and more:
example.com {
# API goes to backend
reverse_proxy /api/* localhost:8000
# WebSocket connections
reverse_proxy /ws/* localhost:3000
# Everything else: static files
root * /var/www/html
file_server
}
Named Matchers
For complex matching logic:
example.com {
@api {
path /api/*
method GET POST
}
reverse_proxy @api localhost:8000 {
header_up X-Custom-Header "value"
}
}
Wildcards and DNS Challenge
For wildcard certificates like *.example.com, you need the DNS challenge. This requires building Caddy with a DNS provider module.
Building Caddy with xcaddy
# Install xcaddy
go install github.com/caddyserver/xcaddy/cmd/xcaddy@latest
# Build with Cloudflare DNS module
xcaddy build --with github.com/caddy-dns/cloudflare
# Or use Docker
docker build -t caddy-cloudflare - <<'EOF'
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
EOF
Cloudflare Configuration
Create an API token with these permissions:
- Zone.Zone:Read
- Zone.DNS:Edit
Then configure Caddyfile:
{
email [email protected]
}
*.example.com {
tls {
dns cloudflare {env.CF_API_TOKEN}
}
@app host app.example.com
handle @app {
reverse_proxy localhost:8080
}
@api host api.example.com
handle @api {
reverse_proxy localhost:8000
}
}
DNS challenge is required for wildcard certificates. HTTP-01 and TLS-ALPN-01 challenges can’t validate wildcards.
Real-World Homelab Configuration
Here’s a complete Caddyfile for a typical homelab stack:
{
# Global options
email [email protected]
acme_ca https://acme-v02.api.letsencrypt.org/directory
# For internal services behind VPN
# local_certs
}
# Photo backup - Immich
photos.example.com {
reverse_proxy localhost:2283
}
# Media server - Jellyfin
media.example.com {
reverse_proxy localhost:8096 {
# Required for Jellyfin WebSocket connections
header_up Host {host}
}
}
# Home Assistant
home.example.com {
reverse_proxy localhost:8123
}
# Docker management - Portainer
portainer.example.com {
reverse_proxy localhost:9443 {
transport http {
# Portainer uses self-signed certs
tls_insecure_skip_verify
}
}
}
# Document manager - Paperless
docs.example.com {
reverse_proxy localhost:8000
}
# Password manager - Vaultwarden
vault.example.com {
reverse_proxy localhost:3012 {
header_up X-Real-IP {remote_host}
}
}
# Monitoring - Uptime Kuma
status.example.com {
reverse_proxy localhost:3001
}
For internal services that shouldn’t be exposed publicly, use Tailscale or a VPN. Caddy can also generate local CA certificates for .local domains if you use the local_certs option.
Proxmox Integration
Running services on Proxmox? Caddy works great in an LXC container or VM:
Option 1: Dedicated Caddy LXC
# Create privileged LXC on Proxmox
# Then install Caddy directly:
apt update
apt install -y debian-keyring debian-archive-keyring apt-transport-https
curl -1sLf 'https://dl.cloudsmith.io/public/caddy/stable/gpg.key' | gpg --dearmor -o /usr/share/keyrings/caddy-stable-archive-keyring.gpg
curl -1sLf 'https://dl.cloudsmith.io/public/caddy/stable/debian.deb.txt' > /etc/apt/sources.list.d/caddy-stable.list
apt update
apt install caddy
Option 2: Docker in LXC
Run Docker inside a privileged LXC, then use the Docker Compose setup from earlier.
Option 3: Cloudflare Tunnel
For homelabs behind CGNAT or without public IP:
# Use Cloudflare Tunnel for external access
# Caddy only handles internal routing
*.lan.example.com {
tls {
dns cloudflare {env.CF_API_TOKEN}
}
# Internal services on local network
@photos host photos.lan.example.com
handle @photos {
reverse_proxy 192.168.1.100:2283
}
}
Caddy vs Tailscale Funnel
If you’re using Tailscale for VPN access, you might consider Tailscale Funnel for exposing services. Here’s the trade-off:
| Feature | Caddy + DNS Challenge | Tailscale Funnel |
|---|---|---|
| Setup complexity | Medium | Low |
| Custom domains | ✅ Full control | ❌ ts.net only |
| Performance | ✅ Direct | ❌ Relayed through Tailscale |
| Privacy | ✅ Your server | ❌ Through Tailscale infra |
| Cost | Free (Let’s Encrypt) | Free tier limits |
| Best for | Permanent services | Quick testing, internal demos |
Use Caddy for production services you want accessible long-term. Use Tailscale Funnel for quick demos or internal-only services.
Advanced: Multiple Upstreams and Load Balancing
Caddy supports multiple backends with automatic health checking:
app.example.com {
reverse_proxy {
to app1:8080
to app2:8080
to app3:8080
# Health checks
health_uri /health
health_interval 30s
health_timeout 5s
# Retry failed requests
lb_try_duration 5s
# Load balancing: random (default), first, round_robin, least_conn
lb_policy least_conn
}
}
Performance and Observability
Metrics Endpoint
Enable Prometheus metrics:
{
servers {
metrics
}
}
app.example.com {
reverse_proxy localhost:8080
}
Access metrics at http://localhost:2019/metrics.
Admin API
Caddy exposes an admin API on port 2019 by default:
# View current config
curl localhost:2019/config/
# Reload configuration
curl -X POST localhost:2019/load -H "Content-Type: application/json" -d @config.json
# List certificates
curl localhost:2019/certificates
The admin API should never be exposed publicly. Bind to localhost only or use authentication.
Performance Benchmarks
Caddy is highly performant—roughly comparable to Nginx in most workloads:
- Static files: ~150,000 req/s on modern hardware
- Reverse proxy: ~120,000 req/s (depends on backend)
- Memory overhead: ~15-30MB baseline
- Latency: Sub-millisecond added latency
For most homelab setups, Caddy will never be your bottleneck.
When to Choose Caddy
Choose Caddy if:
- You want zero-config HTTPS
- You prefer simple config files over labels
- You’re running services on bare metal, LXC, or a small number of Docker containers
- You want HTTP/3 enabled by default with no extra work
- You’re tired of certificate management
Consider Traefik instead if:
- You have a large, dynamic Docker Swarm or Kubernetes cluster
- You rely heavily on container labels for configuration
- You need Kubernetes ingress controller features
Stick with Nginx if:
- You have complex caching requirements
- You need advanced load balancing features (though Caddy covers most cases)
- You’re already deeply invested in Nginx expertise
Troubleshooting Common Issues
Certificates Not Obtaining
# Check Caddy logs
docker logs caddy
# Common causes:
# 1. DNS not pointing to your server
# 2. Firewall blocking port 80/443
# 3. Rate limited (wait 1 hour, use staging)
DNS Challenge Failing
# Verify API token works
curl -X GET "https://api.cloudflare.com/client/v4/user/tokens/verify" \
-H "Authorization: Bearer YOUR_TOKEN"
# Check DNS propagation
dig @1.1.1.1 _acme-challenge.example.com TXT
Permission Denied on Ports 80/443
# Allow Caddy to bind low ports
sudo setcap cap_net_bind_service=+ep $(which caddy)
Conclusion: The Simplicity Dividend
Caddy’s philosophy is that modern web serving shouldn’t require a certification to configure. HTTPS should be automatic. Config files should be readable. Renewal should be invisible.
For homelab setups, this simplicity dividend compounds:
- No more certificate fires: Set it and forget it
- No more config file dread: The Caddyfile is genuinely easy to read
- No more “why is this broken?”: Fewer moving parts = fewer failures
- More time for what matters: Your actual services
If Traefik is the Docker-native choice, Caddy is the human-native choice. Both are excellent—but if certificate management has ever caused you pain, Caddy is worth your attention.
Quick Reference
# Install
docker pull caddy:latest
# Basic Caddyfile
example.com {
reverse_proxy localhost:8080
}
# Validate config
caddy validate --config Caddyfile
# Reload config
caddy reload --config Caddyfile
# Format Caddyfile
caddy fmt --overwrite Caddyfile
# View certificates
curl localhost:2019/certificates
Resources:

Comments
Powered by GitHub Discussions