Using Caddy as a Reverse Proxy in Your Homelab
Caddy is a web server and reverse proxy that handles HTTPS automatically. You tell it which domain maps to which backend service, and it obtains and renews TLS certificates without any configuration on your part. No certbot cron jobs, no manual certificate management, no forgetting to renew and having your services go down.
Photo by Brett Jordan on Unsplash
For homelabs, Caddy hits the sweet spot between Nginx Proxy Manager's simplicity and Traefik's power. The Caddyfile format is readable enough that you can understand it without documentation, yet flexible enough to handle wildcard certificates, WebSocket proxying, header manipulation, and load balancing.
This guide covers setting up Caddy as the single entry point for all your homelab services.

Installation
Standalone Binary
# Debian/Ubuntu
sudo apt install -y debian-keyring debian-archive-keyring apt-transport-https
curl -1sLf 'https://dl.cloudsmith.io/public/caddy/stable/gpg.key' | sudo gpg --dearmor -o /usr/share/keyrings/caddy-stable-archive-keyring.gpg
curl -1sLf 'https://dl.cloudsmith.io/public/caddy/stable/debian.deb.txt' | sudo tee /etc/apt/sources.list.d/caddy-stable.list
sudo apt update && sudo apt install caddy
# Fedora
sudo dnf install caddy
Docker (Recommended for Homelabs)
Most homelab services run in Docker, and running Caddy in Docker alongside them simplifies networking. Create a docker-compose.yaml:
services:
caddy:
image: caddy:latest
container_name: caddy
restart: unless-stopped
ports:
- "80:80"
- "443:443"
- "443:443/udp" # HTTP/3
volumes:
- ./Caddyfile:/etc/caddy/Caddyfile
- caddy_data:/data
- caddy_config:/config
volumes:
caddy_data:
caddy_config:
The caddy_data volume is important -- it stores TLS certificates. Don't lose this volume or Caddy will need to re-obtain all certificates (which can hit Let's Encrypt rate limits if you have many domains).
Basic Caddyfile Configuration
The Caddyfile is where you define your reverse proxy routes. Create one in the same directory as your docker-compose.yaml:
# Caddyfile
jellyfin.home.lab {
reverse_proxy jellyfin:8096
}
gitea.home.lab {
reverse_proxy gitea:3000
}
grafana.home.lab {
reverse_proxy grafana:3000
}
nextcloud.home.lab {
reverse_proxy nextcloud:8080
}
That's it. Four services, four blocks, four lines each. Caddy automatically obtains HTTPS certificates for each domain and proxies traffic to the correct backend. If you're coming from Nginx, this is roughly equivalent to 80+ lines of Nginx configuration with SSL settings, proxy headers, and upstream blocks.
Proxy Headers
Caddy automatically sets X-Forwarded-For, X-Forwarded-Proto, and X-Forwarded-Host headers. Most applications work correctly without any additional header configuration. If a service needs specific headers:
service.home.lab {
reverse_proxy backend:8080 {
header_up X-Real-IP {remote_host}
header_up X-Forwarded-Port {server_port}
}
}
WebSocket Support
Caddy proxies WebSocket connections transparently. No special configuration needed. If your service uses WebSockets (Gitea, Nextcloud Talk, Home Assistant, etc.), it just works.
Path-Based Routing
If you want multiple services under one domain:
homelab.example.com {
handle /grafana/* {
reverse_proxy grafana:3000
}
handle /gitea/* {
reverse_proxy gitea:3000
}
handle {
reverse_proxy homepage:3000
}
}
Docker Integration
Shared Docker Network
For Caddy to reach your backend services by container name, they need to be on the same Docker network:
# docker-compose.yaml
services:
caddy:
image: caddy:latest
ports:
- "80:80"
- "443:443"
- "443:443/udp"
volumes:
- ./Caddyfile:/etc/caddy/Caddyfile
- caddy_data:/data
networks:
- proxy
jellyfin:
image: jellyfin/jellyfin:latest
networks:
- proxy
# No need to expose ports -- Caddy reaches it via Docker network
gitea:
image: gitea/gitea:latest
networks:
- proxy
networks:
proxy:
name: caddy-proxy
volumes:
caddy_data:
Notice that the backend services don't need to publish ports. Caddy reaches them through the Docker network, and only Caddy's ports 80 and 443 are exposed to the host. This is a security benefit -- your services aren't directly accessible, even on the LAN.
Services in Separate Compose Files
If your services are in different compose files (which they probably should be), create an external Docker network:
docker network create caddy-proxy
Then reference it in each compose file:
# In Caddy's compose file
networks:
proxy:
external: true
name: caddy-proxy
# In each service's compose file
networks:
proxy:
external: true
name: caddy-proxy
Like what you're reading? Subscribe to HomeLab Starter — free weekly guides in your inbox.
Wildcard Certificates with DNS Challenge
For homelabs using a local domain like *.home.lab or a real domain like *.homelab.example.com, wildcard certificates are cleaner than individual certificates per service. Instead of Caddy hitting Let's Encrypt for each subdomain separately, one wildcard cert covers everything.
Wildcard certs require the DNS-01 challenge, which means Caddy needs API access to your DNS provider to create TXT records.
Build Caddy with DNS Plugin
The official Caddy Docker image doesn't include DNS provider plugins. Build a custom image:
# Dockerfile
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
Build it:
docker build -t caddy-dns .
Configure Wildcard Cert
# Caddyfile with wildcard cert
(tls_config) {
tls {
dns cloudflare {env.CLOUDFLARE_API_TOKEN}
}
}
*.home.example.com {
import tls_config
@jellyfin host jellyfin.home.example.com
handle @jellyfin {
reverse_proxy jellyfin:8096
}
@gitea host gitea.home.example.com
handle @gitea {
reverse_proxy gitea:3000
}
@grafana host grafana.home.example.com
handle @grafana {
reverse_proxy grafana:3000
}
handle {
respond "Service not found" 404
}
}
Pass the Cloudflare API token as an environment variable:
# docker-compose.yaml
services:
caddy:
image: caddy-dns
environment:
- CLOUDFLARE_API_TOKEN=your-scoped-token-here
ports:
- "80:80"
- "443:443"
volumes:
- ./Caddyfile:/etc/caddy/Caddyfile
- caddy_data:/data
Supported DNS Providers
Caddy has DNS plugins for most providers: Cloudflare, AWS Route53, Google Cloud DNS, DigitalOcean, Hetzner, Namecheap, and many more. Check the caddy-dns GitHub organization for the full list.
For internal-only domains (like .home.lab) that don't exist in public DNS, you'll need a local DNS server (Pi-hole, AdGuard Home, dnsmasq) to resolve the domains to Caddy's IP. Caddy can use internal TLS with self-signed certificates for these:
*.home.lab {
tls internal
# ... proxy rules
}
Useful Caddyfile Patterns
Basic Authentication
admin.home.lab {
basicauth {
admin $2a$14$hash... # Generate with: caddy hash-password
}
reverse_proxy portainer:9000
}
Rate Limiting
api.home.lab {
rate_limit {
zone api_zone {
key {remote_host}
events 100
window 1m
}
}
reverse_proxy api-service:8080
}
Redirect HTTP to HTTPS
Caddy does this automatically. No configuration needed. Every HTTP request is redirected to HTTPS by default.
Static File Serving
files.home.lab {
root * /srv/files
file_server browse
}
Load Balancing
app.home.lab {
reverse_proxy node1:8080 node2:8080 node3:8080 {
lb_policy round_robin
health_uri /health
health_interval 30s
}
}
Caddy vs Traefik vs Nginx Proxy Manager
All three are solid choices. Here's when each makes sense for a homelab:
Choose Caddy if: you want the simplest possible configuration with automatic HTTPS. The Caddyfile format is more readable than Traefik's labels or Nginx config blocks. Caddy's memory footprint (20-40MB) is also the smallest of the three.
Choose Traefik if: you want fully automatic Docker integration. Traefik discovers new services via Docker labels without editing any config file. Add a label to a container, and Traefik routes to it. This is powerful in dynamic environments where services come and go frequently.
Choose Nginx Proxy Manager if: you want a web GUI and don't want to edit config files at all. NPM wraps Nginx in a point-and-click interface. It's the easiest to get started with, but the least flexible for complex configurations.
| Feature | Caddy | Traefik | Nginx Proxy Manager |
|---|---|---|---|
| Config format | Caddyfile (text) | YAML + Docker labels | Web GUI |
| Auto HTTPS | Built-in, zero-config | Built-in, needs config | Built-in, GUI-driven |
| Docker discovery | Manual (Caddyfile) | Automatic (labels) | Manual (GUI) |
| Wildcard certs | Yes (DNS plugins) | Yes (DNS plugins) | Yes (DNS challenge) |
| Memory usage | 20-40 MB | 50-80 MB | 100-150 MB |
| Dashboard | None (API only) | Built-in (read-only) | Full management GUI |
| Learning curve | Low | Medium-high | Very low |
Operational Tips
Reload without downtime. After editing the Caddyfile:
docker exec caddy caddy reload --config /etc/caddy/Caddyfile
This applies changes without dropping existing connections. No restart needed.
Validate before reloading. Check for syntax errors:
docker exec caddy caddy validate --config /etc/caddy/Caddyfile
Access logs. Caddy logs to stdout by default (visible via docker logs caddy). For structured logging to a file:
(logging) {
log {
output file /data/access.log {
roll_size 100mb
roll_keep 5
}
format json
}
}
service.home.lab {
import logging
reverse_proxy backend:8080
}
Monitor certificate expiry. Caddy handles renewal automatically, but you can verify certificate status:
docker exec caddy caddy list-certificates
Caddy renews certificates 30 days before expiry. If renewal fails, it retries with exponential backoff and logs warnings. As long as you're watching the logs (or have a log alerting pipeline), you'll know about problems well before certificates expire.
Caddy's philosophy is "it should just work," and for homelab reverse proxy use, it delivers on that promise. The Caddyfile configuration is small enough to fit in your head, HTTPS happens automatically, and the Docker integration is straightforward. Start with a basic Caddyfile, add services as you deploy them, and let Caddy handle the TLS complexity.
