Reverse Proxy with Caddy on Raspberry Pi
Set up Caddy as a reverse proxy on Raspberry Pi with automatic HTTPS. Route multiple services through one domain with subdomains.
Introduction
A reverse proxy sits between your clients and backend services, forwarding requests to the appropriate service. If you're running multiple applications on your Raspberry Pi — Nextcloud, Jellyfin, Pihole, Home Assistant — a reverse proxy lets you access them all through a single domain with clean subdomains, eliminating the need to remember different ports.
Beyond routing, a reverse proxy also:
- Centralizes HTTPS/TLS termination: Certificates are managed in one place, not on each service
- Provides load balancing: Distribute traffic across multiple backend servers
- Enables clean URLs: Access
nextcloud.home.example.cominstead of192.168.1.10:8080 - Adds security headers: Protect against XSS, clickjacking, and MIME-sniffing
- Simplifies DNS: Single DNS record routing to all services
For a Raspberry Pi homelab, a reverse proxy is essential infrastructure.
Why Caddy over Nginx or Traefik
Three reverse proxies dominate the self-hosting scene: Nginx, Traefik, and Caddy. Here's why Caddy wins for Raspberry Pi:
Automatic HTTPS: Caddy automatically provisions and renews Let's Encrypt certificates with zero configuration. Nginx requires manual cert management and cron jobs. Traefik comes close but adds complexity.
Simple Configuration: Caddy's config format is human-readable. Compare:
# Caddy
example.com {
reverse_proxy localhost:8080
}
To Nginx's equivalent (3x the code with more boilerplate). For a Pi with limited resources, simpler configuration means lower memory footprint.
No Renewal Cron Jobs: Caddy handles certificate renewal automatically in the background. Nginx admins must set up separate automation.
LAN-Only Support: Caddy works equally well for internet-facing and LAN-only setups, with options for self-signed certs and DNS challenges.
Container-Friendly: Caddy's Docker integration with label-based auto-discovery means you can spin up a new service and have it automatically discovered without editing config files.
For a Raspberry Pi, where simplicity and low resource usage matter, Caddy is the ideal choice.
Prerequisites
Before starting, ensure you have:
- Raspberry Pi 3B or later (4GB RAM recommended for comfort)
- Docker and Docker Compose installed on your Pi
- A domain name (for internet-facing setups; LAN-only setups can use local domains)
- A static IP address for your Raspberry Pi on your local network
- Port 80 and 443 accessible (or port forwarding configured on your router)
- Basic understanding of DNS and networking concepts
If Docker isn't installed, run:
curl -fsSL https://get.docker.com -o get-docker.sh
sudo sh get-docker.sh
sudo usermod -aG docker $USER
Verify installation with docker --version and docker-compose --version.
Step 1 — Install Caddy with Docker
We'll use Docker to keep system dependencies minimal. Create a project directory:
mkdir -p ~/caddy/{config,data}
cd ~/caddy
Create docker-compose.yml:
version: '3.8'
services:
caddy:
image: caddy:latest
container_name: caddy-reverse-proxy
restart: unless-stopped
ports:
- "80:80"
- "443:443"
- "443:443/udp"
environment:
# Cloudflare API token for DNS challenges (optional)
# CLOUDFLARE_API_TOKEN: your_token_here
# For Let's Encrypt staging (testing without rate limits)
# CADDY_ENVIRONMENT: STAGE=1
pass
volumes:
- ./config/Caddyfile:/etc/caddy/Caddyfile
- caddy_data:/data
- caddy_config:/config
networks:
- homelab
networks:
homelab:
driver: bridge
ipam:
config:
- subnet: 172.20.0.0/16
volumes:
caddy_data:
caddy_config:
What this does:
- Maps ports 80 (HTTP) and 443 (HTTPS) from the container to your host
- Mounts your
Caddyfile(configuration) into the container - Uses volumes to persist certificate data and configuration across restarts
- Creates a
homelabDocker network so Caddy can reach other containers by service name
Start the service:
docker-compose up -d
Verify it's running:
docker-compose logs -f caddy
Step 2 — Basic Caddyfile (Single Service)
Create config/Caddyfile:
# Global settings
{
# Use HTTP/2 for better performance
order encode gzip
}
# Single service example: reverse proxy to a web app on port 8080
example.com {
# Enable gzip compression
encode gzip
# Reverse proxy configuration
reverse_proxy localhost:8080 {
# Keep-alive for better performance
header_up Connection keep-alive
}
}
Line-by-line explanation:
{}block: Global Caddy options apply to all sitesorder encode gzip: Specify filter order for compressionexample.com: The domain this configuration applies toencode gzip: Compress responses for bandwidth savingsreverse_proxy localhost:8080: Forward requests to this addressheader_up Connection keep-alive: Tell upstream server to use persistent connections
Reload the config (Caddy watches for changes):
docker-compose restart caddy
Test by visiting https://example.com. Caddy automatically obtains a certificate from Let's Encrypt.
Step 3 — Multiple Subdomains
Most homelabs run multiple services. Update config/Caddyfile:
{
order encode gzip
}
# Nextcloud
nextcloud.home.example.com {
encode gzip
reverse_proxy localhost:8080 {
header_up Connection keep-alive
}
}
# Jellyfin (media server)
jellyfin.home.example.com {
encode gzip
reverse_proxy localhost:8096 {
header_up Connection keep-alive
# Jellyfin requires these headers to work behind a reverse proxy
header_up X-Real-IP {remote_host}
header_up X-Forwarded-For {remote_host}
header_up X-Forwarded-Proto {scheme}
}
}
# Pihole (DNS admin)
pihole.home.example.com {
encode gzip
reverse_proxy localhost:80 {
# Pihole already runs on 80 internally, use different port externally
header_up X-Real-IP {remote_host}
header_up X-Forwarded-For {remote_host}
}
}
# Home Assistant
ha.home.example.com {
encode gzip
reverse_proxy localhost:8123 {
header_up X-Real-IP {remote_host}
header_up X-Forwarded-For {remote_host}
header_up X-Forwarded-Proto {scheme}
# Home Assistant needs websocket support
websocket
}
}
# Vaultwarden (password manager)
vault.home.example.com {
encode gzip
reverse_proxy localhost:8000 {
header_up X-Real-IP {remote_host}
header_up X-Forwarded-For {remote_host}
header_up X-Forwarded-Proto {scheme}
}
}
# Default catch-all (redirects unknown domains to HTTPS)
:80 {
redir https://{host}{uri}
}
Key additions:
websocket: Enables WebSocket support for applications like Home Assistantheader_up X-Real-IP: Passes the client's real IP to the upstream serviceheader_up X-Forwarded-Proto: Tells backend services the request came through HTTPS- Each subdomain gets its own block with independent configuration
Now you can access all services through your domain:
https://nextcloud.home.example.comhttps://jellyfin.home.example.comhttps://pihole.home.example.comhttps://ha.home.example.comhttps://vault.home.example.com
Step 4 — Automatic HTTPS with Let's Encrypt
Caddy handles HTTPS automatically, but you need to configure how it validates domain ownership. For internet-facing setups, HTTP validation (the default) works:
{
acme_dns cloudflare {env.CLOUDFLARE_API_TOKEN}
}
example.com, *.example.com {
reverse_proxy localhost:8080
}
For multiple subdomains on the same domain, use a wildcard cert. Update docker-compose.yml to pass your Cloudflare API token:
environment:
CLOUDFLARE_API_TOKEN: your_cloudflare_api_token_here
Caddy uses ACME (Automatic Certificate Management Environment) to:
- Prove domain ownership to Let's Encrypt
- Receive a signed certificate
- Automatically renew certificates 30 days before expiry
DNS Challenge for LAN-Only Setups:
If your domain isn't publicly accessible (LAN-only), Let's Encrypt's HTTP validation fails. Instead, use DNS challenges with Cloudflare:
{
acme_ca https://acme-v02.api.letsencrypt.org/directory
acme_dns cloudflare {env.CLOUDFLARE_API_TOKEN}
}
# Works even on internal network, as long as Cloudflare DNS is configured
internal.home.example.com {
reverse_proxy localhost:8080
}
This tells Let's Encrypt to verify ownership by checking DNS records (which Caddy modifies automatically via Cloudflare's API).
Certificate files are automatically stored in the caddy_data volume and persisted across restarts.
Step 5 — LAN-Only Setup with Self-Signed Certs
If you don't need Let's Encrypt or don't own a domain, use self-signed certificates:
{
# Disable ACME (automatic certificates)
acme_ca internal
}
homelab.local {
reverse_proxy localhost:8080 {
header_up Connection keep-alive
}
}
nextcloud.homelab.local {
reverse_proxy localhost:8080
}
jellyfin.homelab.local {
reverse_proxy localhost:8096
}
With acme_ca internal, Caddy generates self-signed certificates for every domain. Browsers will warn about untrusted certificates, but the connection is still encrypted.
Add to /etc/hosts on your client machine (Linux/Mac):
192.168.1.50 homelab.local nextcloud.homelab.local jellyfin.homelab.local
On Windows, edit C:\Windows\System32\drivers\etc\hosts:
192.168.1.50 homelab.local nextcloud.homelab.local jellyfin.homelab.local
Now access https://nextcloud.homelab.local from your LAN. Accept the self-signed certificate warning, and you're in.
Step 6 — Add Security Headers
Protect your services by adding HTTP security headers:
{
order encode gzip
}
# Helper snippet for common security headers
(security-headers) {
# Strict-Transport-Security: force HTTPS for 1 year
header Strict-Transport-Security "max-age=31536000; includeSubDomains; preload"
# X-Frame-Options: prevent clickjacking
header X-Frame-Options "DENY"
# X-Content-Type-Options: prevent MIME-sniffing
header X-Content-Type-Options "nosniff"
# X-XSS-Protection: legacy XSS filter (modern browsers use CSP)
header X-XSS-Protection "1; mode=block"
# Referrer-Policy: control what referrer data is shared
header Referrer-Policy "strict-origin-when-cross-origin"
# Permissions-Policy: restrict browser features
header Permissions-Policy "geolocation=(), microphone=(), camera=()"
}
nextcloud.home.example.com {
encode gzip
import security-headers
reverse_proxy localhost:8080
}
jellyfin.home.example.com {
encode gzip
import security-headers
reverse_proxy localhost:8096 {
websocket
}
}
The (security-headers) snippet is reusable across all your services. import security-headers applies all headers to that domain.
What these headers do:
- HSTS: Browsers never use HTTP for this domain, preventing downgrade attacks
- X-Frame-Options: Prevents embedding your site in iframes (clickjacking defense)
- X-Content-Type-Options: Prevents browsers from guessing file types
- Referrer-Policy: Hides referrer URLs from upstream services
- Permissions-Policy: Blocks access to sensitive browser APIs
Caddy vs Nginx vs Traefik
| Feature | Caddy | Nginx | Traefik |
|---|---|---|---|
| Auto HTTPS | ✓ Yes | ✗ Manual | ✓ Yes |
| Config Simplicity | ✓ Simple | ✗ Complex | ~ Medium |
| Learning Curve | ✓ Easy | ✗ Steep | ~ Medium |
| Memory Usage | ✓ ~50MB | ✓ ~10MB | ~ 100MB |
| Docker Labels | ✓ Yes | ✗ No | ✓ Yes |
| Wildcard Certs | ✓ Yes | ✓ Yes | ✓ Yes |
| Cron Jobs Needed | ✗ No | ✓ Yes | ✗ No |
| LAN Self-Signed | ✓ Built-in | ✗ Manual | ~ Complex |
| Performance | ✓ Good | ✓ Excellent | ~ Good |
| Ideal For | Homelabs, simplicity | High-traffic, performance | Complex routing, Docker Swarm |
For a Raspberry Pi homelab where simplicity and low resource usage matter, Caddy is the winner.
Advanced: Caddy with Docker Labels
Instead of manually editing the Caddyfile, you can use Docker labels to automatically discover and configure services:
Update docker-compose.yml:
version: '3.8'
services:
caddy:
image: caddy:latest
container_name: caddy-reverse-proxy
restart: unless-stopped
ports:
- "80:80"
- "443:443"
- "443:443/udp"
volumes:
- /var/run/docker.sock:/var/run/docker.sock
- caddy_data:/data
- caddy_config:/config
networks:
- homelab
nextcloud:
image: nextcloud:latest
container_name: nextcloud
restart: unless-stopped
ports:
- "8080:80"
labels:
caddy: "nextcloud.home.example.com"
caddy.reverse_proxy: "{{upstreams}}"
networks:
- homelab
# ... rest of nextcloud config
jellyfin:
image: jellyfin/jellyfin:latest
container_name: jellyfin
restart: unless-stopped
ports:
- "8096:8096"
labels:
caddy: "jellyfin.home.example.com"
caddy.reverse_proxy: "{{upstreams}}"
caddy.header: |
X-Real-IP {remote_host}
X-Forwarded-For {remote_host}
networks:
- homelab
# ... rest of jellyfin config
networks:
homelab:
driver: bridge
volumes:
caddy_data:
caddy_config:
Then use this minimal Caddyfile:
{
order encode gzip
}
:80 {
encode gzip
reverse_proxy localhost:8080
}
Caddy automatically discovers services via Docker labels:
caddy: The domain to usecaddy.reverse_proxy: The upstream servicecaddy.header: Custom headers
When you start a new service with proper labels, Caddy picks it up instantly.
Troubleshooting
Port 80 Already in Use
If something else (web server, Pihole) is using port 80:
# Find what's using port 80
sudo lsof -i :80
# Either stop the conflicting service or change Caddy's port in docker-compose.yml
ports:
- "8080:80" # External port 8080 maps to container port 80
Then adjust your Caddyfile or DNS to point to example.com:8080.
Certificate Errors / Untrusted Certificate
If you see certificate warnings:
- Clear stale certificates:
docker-compose exec caddy rm -rf /data/caddy/certificates - Restart Caddy:
docker-compose restart caddy - Check logs:
docker-compose logs caddyfor ACME errors - DNS issue: Verify your domain resolves to your Pi's IP:
nslookup example.com
For Let's Encrypt rate limits, test with the staging server:
{
acme_ca https://acme-staging-v02.api.letsencrypt.org/directory
}
502 Bad Gateway
Means Caddy can't reach your upstream service. Check:
# Is the service running?
docker-compose ps
# Can Caddy reach it?
docker-compose exec caddy curl http://localhost:8080
# Are firewall rules blocking it?
sudo ufw status
DNS Not Resolving
For LAN-only setups, ensure you added the IP to /etc/hosts:
# Test name resolution
nslookup nextcloud.homelab.local
For internet-facing domains, verify DNS points to your Pi's public IP:
dig example.com
Summary
You now have a professional-grade reverse proxy running on your Raspberry Pi. Caddy automatically handles HTTPS, routes traffic to multiple services, and requires minimal configuration or maintenance.
Key takeaways:
- Caddy simplifies reverse proxy setup compared to Nginx or Traefik
- Automatic HTTPS with Let's Encrypt removes certificate management headaches
- Multiple subdomains let you access all services through clean URLs
- Security headers protect against common web attacks
- LAN-only setups work with self-signed certificates and
/etc/hosts - Docker integration enables automatic service discovery
Your Pi now acts as a professional gateway to your entire homelab. Add more services by creating new subdomains in your Caddyfile — Caddy handles the rest.
Next steps:
- Explore Caddy's full documentation: https://caddyserver.com/docs
- Set up wildcard DNS for dynamic subdomains
- Monitor Caddy with Prometheus metrics
- Implement rate limiting for sensitive services
- Set up automated backups of certificate data
Happy self-hosting!