Running Caddy Server: The Zero-Config HTTPS Reverse Proxy
You’re running a dozen Docker containers. Each one listens on a different port. You’re tired of remembering 192.168.1.50:8096 for Jellyfin and :3000 for Gitea. You want jellyfin.yourdomain.com with HTTPS — and you don’t want to wrestle with Nginx config files or manually renew Let’s Encrypt certificates.
Caddy does all of this automatically. Point a domain at it, tell it where your service lives, and it handles HTTPS certificates, renewal, and reverse proxying with almost zero configuration.
Why Caddy Over Nginx or Traefik
Every reverse proxy gets the job done. Here’s why Caddy stands out for self-hosters:
- Automatic HTTPS — Caddy obtains and renews Let’s Encrypt (or ZeroSSL) certificates with zero config. No certbot, no cron jobs, no renewal scripts.
- Dead-simple config — A reverse proxy rule is literally two lines in a Caddyfile.
- HTTP/3 support — Enabled by default. Your services get QUIC support for free.
- No external dependencies — Single binary. No Lua modules, no separate certificate managers.
The trade-off: fewer community plugins than Nginx, and less flexible routing than Traefik for complex microservice setups. For most self-hosters running 5–30 services behind a single domain, Caddy is the sweet spot.
Prerequisites
- A Linux server (Ubuntu/Debian recommended) with Docker and Docker Compose installed
- A domain name with DNS pointed to your server’s public IP
- Ports 80 and 443 open on your firewall/router
- Basic familiarity with Docker Compose files
Setting Up Caddy with Docker Compose
Create a directory for your Caddy setup:
mkdir -p ~/caddy && cd ~/caddy
Create a docker-compose.yml:
services:
caddy:
image: caddy:2-alpine
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
networks:
- caddy
networks:
caddy:
name: caddy
driver: bridge
volumes:
caddy_data:
caddy_config:
Key details:
- Port 443/udp enables HTTP/3 (QUIC). Optional but free performance.
- caddy_data stores your TLS certificates. Back this volume up.
- caddy_config stores Caddy’s runtime configuration.
- The caddy network lets other containers connect to Caddy without exposing their ports to the host.
Writing Your Caddyfile
Create a Caddyfile in the same directory:
That’s it. Each block is a domain name, and inside it you tell Caddy where to forward traffic. No ssl_certificate directives, no proxy_pass with upstream blocks, no location / nesting.
When Caddy starts, it will:
- Check that each domain resolves to your server
- Request a TLS certificate from Let’s Encrypt (or ZeroSSL as fallback)
- Complete the ACME challenge automatically
- Start serving HTTPS with automatic HTTP→HTTPS redirects
- Renew certificates before they expire (no cron needed)
Connecting Docker Services
For Caddy to reach your other containers by name (like jellyfin or gitea), they need to be on the same Docker network. Add the caddy network to each service’s compose file:
# In your Jellyfin docker-compose.yml
services:
jellyfin:
image: jellyfin/jellyfin
container_name: jellyfin
# No need to expose ports to the host!
networks:
- caddy
volumes:
- ./config:/config
- ./media:/media
networks:
caddy:
external: true
The critical change: remove the ports mapping. Your services no longer need to expose ports to the host. Caddy reaches them internally through the Docker network. This is more secure — the only ports open to the internet are 80 and 443 on Caddy.
Start Everything
# Start Caddy
cd ~/caddy && docker compose up -d
# Check logs to verify certificate issuance
docker logs caddy
You should see lines like:
Visit https://jellyfin.yourdomain.com — you’ll get a valid HTTPS connection with no browser warnings.
Common Patterns
Wildcard Certificates with DNS Challenge
If you have many subdomains or use internal-only services, a wildcard certificate avoids per-domain ACME challenges:
For the DNS challenge, you need the Caddy image with a DNS provider plugin. Use the official builder:
services:
caddy:
image: caddy:2-alpine
# Or build with DNS plugin:
# build:
# context: .
# dockerfile: Dockerfile.caddy
environment:
- CF_API_TOKEN=your_cloudflare_api_token
Create Dockerfile.caddy:
FROM caddy:2-builder AS builder
RUN xcaddy build --with github.com/caddy-dns/cloudflare
FROM caddy:2-alpine
COPY --from=builder /usr/bin/caddy /usr/bin/caddy
Basic Authentication
Protect a service with a username and password:
Generate a bcrypt hash:
docker run --rm caddy:2-alpine caddy hash-password --plaintext 'your-secure-password'
Headers and Security
Add security headers to all sites:
WebSocket Support
Caddy handles WebSockets automatically — no special config needed. Services like Nextcloud, Gitea, and code-server work out of the box.
Redirect www to Bare Domain
Local Network with Self-Signed Certs
Running Caddy on a local network without a public domain? Use Caddy’s built-in CA:
Caddy generates a self-signed certificate from its internal CA. You can install Caddy’s root CA on your devices to trust these certificates:
# Export Caddy's root certificate
docker exec caddy caddy trust
# Or copy it manually
docker cp caddy:/data/caddy/pki/authorities/local/root.crt ./caddy-root.crt
Troubleshooting
Certificates not issuing:
- Verify your domain’s DNS A record points to your server’s public IP:
dig +short yourdomain.com - Ensure ports 80 and 443 are forwarded from your router to your server
- Check Caddy logs:
docker logs caddy --tail 50 - ACME challenges require port 80 to be reachable from the internet
502 Bad Gateway:
- The upstream container isn’t running or isn’t on the
caddynetwork - Check:
docker network inspect caddy— your service container should appear in the list - Verify the container name matches what’s in your Caddyfile
Permission denied on volumes:
- The
caddy_datavolume stores certificates. Don’t delete it during upgrades. - If you moved hosts, restore the
caddy_datavolume or Caddy will re-request certificates (rate limits apply)
Let’s Encrypt rate limits:
- 50 certificates per registered domain per week
- Use the staging endpoint for testing: add
acme_ca https://acme-staging-v02.api.letsencrypt.org/directoryto your global options
Caddy not reloading config:
# Reload without downtime
docker exec caddy caddy reload --config /etc/caddy/Caddyfile
Caddy vs Nginx Proxy Manager vs Traefik
| Feature | Caddy | Nginx Proxy Manager | Traefik v3 |
|---|---|---|---|
| HTTPS | Automatic | Automatic (via UI) | Automatic |
| Config style | Caddyfile (text) | Web UI | YAML + Docker labels |
| Learning curve | Low | Lowest | Medium-High |
| HTTP/3 | Yes (default) | No | Yes |
| Docker integration | Network-based | Network-based | Label-based |
| Best for | Simple to medium setups | Beginners who want a GUI | Complex microservices |
Conclusion
Caddy removes the worst parts of running a reverse proxy: certificate management, config syntax, and renewal automation. For most self-hosters, it’s the fastest path from “I have services on random ports” to “everything has HTTPS on clean subdomains.”
Start with the basic Caddyfile, add services as you deploy them, and enjoy never thinking about SSL certificates again.
Next steps:
- Add Authelia or Authentik for single sign-on behind Caddy
- Set up Uptime Kuma to monitor your proxied services
- Configure CrowdSec to protect Caddy from brute-force attacks