How to Self-Host Caddy with Docker
What Is Caddy?
Caddy is a modern web server and reverse proxy with automatic HTTPS built in. Point it at a domain name and it obtains and renews TLS certificates from Let’s Encrypt without any configuration. It replaces managed hosting, Nginx, and manual SSL workflows with a single binary and a readable config file called the Caddyfile.
Prerequisites
- A Linux server (Ubuntu 22.04+ recommended)
- Docker and Docker Compose installed (guide)
- 256 MB of free RAM
- Ports 80 and 443 available (not used by another web server)
- A domain name with DNS A record pointing to your server (required for automatic HTTPS)
Docker Compose Configuration
Create a directory for Caddy:
mkdir -p ~/caddy && cd ~/caddy
Create a Caddyfile in that directory. This example proxies three services and serves a static site:
# Global options
{
email [email protected]
# admin off # Uncomment to disable the admin API in production
}
# Reverse proxy for Immich
photos.yourdomain.com {
reverse_proxy immich-server:2283
}
# Reverse proxy for Vaultwarden
vault.yourdomain.com {
reverse_proxy vaultwarden:80
}
# Reverse proxy for Jellyfin
media.yourdomain.com {
reverse_proxy jellyfin:8096
}
# Static file server example
yourdomain.com {
root * /srv/site
file_server
encode gzip zstd
}
Create a docker-compose.yml:
services:
caddy:
image: caddy:2.10.2-alpine
container_name: caddy
restart: unless-stopped
ports:
- "80:80" # HTTP — required for HTTPS redirects and ACME challenges
- "443:443" # HTTPS
- "443:443/udp" # HTTP/3 (QUIC)
volumes:
- ./Caddyfile:/etc/caddy/Caddyfile:ro # Your config file
- caddy-data:/data # TLS certificates, OCSP staples, private keys
- caddy-config:/config # Auto-saved JSON config
- ./site:/srv/site:ro # Static site files (optional — remove if not serving files)
environment:
TZ: "America/New_York"
cap_add:
- NET_BIND_SERVICE # Allows binding to ports 80/443 as non-root
networks:
- proxy
networks:
proxy:
name: proxy
external: true
volumes:
caddy-data:
caddy-config:
Create the shared Docker network that other services will join:
docker network create proxy
Start Caddy:
docker compose up -d
Caddy obtains TLS certificates automatically for every domain in the Caddyfile within seconds of starting. No additional steps required.
Connecting Other Services
Services that Caddy proxies to must be on the same Docker network. Add the proxy network to each service’s Docker Compose:
services:
your-app:
image: your-app:latest
# ... other config
networks:
- proxy
networks:
proxy:
external: true
Use the container name as the hostname in your Caddyfile (e.g., reverse_proxy immich-server:2283).
Initial Setup
- Edit the
Caddyfileto replaceyourdomain.comwith your actual domain - Replace
[email protected]with your email (used for Let’s Encrypt account registration and certificate expiration notices) - Update
reverse_proxytargets to match your running services - Run
docker compose up -dand check logs:
docker compose logs -f caddy
You should see Caddy obtaining certificates for each configured domain. Once you see certificate obtained successfully, your sites are live with HTTPS.
Configuration
Caddyfile Syntax
The Caddyfile uses a simple, human-readable format. Each site block starts with an address and contains directives:
address {
directive arguments
}
Reverse Proxy
The most common use case for self-hosting. Caddy forwards requests to your backend services:
app.yourdomain.com {
reverse_proxy backend:8080
}
Add health checks and load balancing across multiple backends:
app.yourdomain.com {
reverse_proxy backend-1:8080 backend-2:8080 {
health_uri /health
health_interval 30s
}
}
Strip or add path prefixes:
yourdomain.com {
handle_path /api/* {
reverse_proxy api-server:3000
}
handle {
reverse_proxy frontend:80
}
}
WebSocket Proxying
Caddy proxies WebSocket connections automatically. No extra configuration needed. This works out of the box for Home Assistant, Vaultwarden, and other apps that use WebSockets.
File Server
Serve static files with directory listings and compression:
files.yourdomain.com {
root * /srv/files
file_server browse
encode gzip zstd
}
Headers
Add security headers across all proxied sites:
(security-headers) {
header {
X-Content-Type-Options "nosniff"
X-Frame-Options "DENY"
Referrer-Policy "strict-origin-when-cross-origin"
Strict-Transport-Security "max-age=31536000; includeSubDomains; preload"
-Server
}
}
app.yourdomain.com {
import security-headers
reverse_proxy backend:8080
}
Snippets
Reuse config blocks with named snippets using parentheses:
(common) {
encode gzip zstd
log {
output file /data/logs/access.log
}
}
app1.yourdomain.com {
import common
reverse_proxy app1:8080
}
app2.yourdomain.com {
import common
reverse_proxy app2:3000
}
Reloading Configuration
Apply Caddyfile changes without downtime:
docker compose exec caddy caddy reload --config /etc/caddy/Caddyfile
Caddy validates the config before applying it. If there is a syntax error, the reload fails and the running config remains unchanged.
Advanced Configuration
On-Demand TLS
Issue certificates at request time instead of at startup. Useful when you do not know all domain names in advance (multi-tenant setups):
{
on_demand_tls {
ask http://your-auth-service:8080/check
interval 1m
burst 5
}
}
https:// {
tls {
on_demand
}
reverse_proxy backend:8080
}
The ask endpoint must return 200 for domains that should get certificates. This prevents abuse.
DNS Challenge for Wildcard Certificates
For wildcard certificates or servers behind a firewall, use the DNS-01 ACME challenge. This requires a custom Caddy build with your DNS provider’s plugin. Using the builder image:
docker run --rm -v $(pwd):/output caddy:2.10.2-builder caddy-builder \
xcaddy build \
--with github.com/caddy-dns/cloudflare \
--output /output/caddy
Then use a custom Dockerfile:
FROM caddy:2.10.2-alpine
COPY caddy /usr/bin/caddy
Caddyfile for wildcard:
*.yourdomain.com {
tls {
dns cloudflare {env.CLOUDFLARE_API_TOKEN}
}
@photos host photos.yourdomain.com
handle @photos {
reverse_proxy immich-server:2283
}
@vault host vault.yourdomain.com
handle @vault {
reverse_proxy vaultwarden:80
}
}
Add CLOUDFLARE_API_TOKEN to your Docker Compose environment.
Load Balancing
Distribute traffic across multiple backends with configurable policies:
app.yourdomain.com {
reverse_proxy backend-1:8080 backend-2:8080 backend-3:8080 {
lb_policy round_robin
health_uri /health
health_interval 10s
fail_duration 30s
}
}
Available policies: round_robin, least_conn, random, first, ip_hash, uri_hash, header, cookie.
Rate Limiting with Custom Modules
Caddy supports modules for functionality like rate limiting, caching, and authentication. Modules require a custom build using xcaddy:
xcaddy build --with github.com/mholt/caddy-ratelimit
Admin API
Caddy exposes a JSON API on port 2019 (localhost only by default) for dynamic configuration:
# View current config
curl http://localhost:2019/config/
# Add a new route dynamically
curl -X POST http://localhost:2019/config/apps/http/servers/srv0/routes \
-H "Content-Type: application/json" \
-d '{"handle":[{"handler":"reverse_proxy","upstreams":[{"dial":"backend:8080"}]}],"match":[{"host":["new.yourdomain.com"]}]}'
The admin API is disabled in the Docker container unless you expose port 2019. Keep it off in production unless you need it.
Backup
Three things to back up:
caddy-datavolume — Contains TLS certificates, private keys, and OCSP staples. Losing this forces re-issuance of all certificates (subject to Let’s Encrypt rate limits).caddy-configvolume — Contains the auto-saved JSON representation of your config. Useful but not critical if you have your Caddyfile.- Your
Caddyfile— The source of truth for your configuration. Keep this in version control.
# Back up the critical data volume
docker compose stop
docker run --rm -v caddy-data:/data -v $(pwd):/backup alpine tar czf /backup/caddy-data-backup.tar.gz /data
docker run --rm -v caddy-config:/config -v $(pwd):/backup alpine tar czf /backup/caddy-config-backup.tar.gz /config
docker compose start
# The Caddyfile is already on the host — include it in your regular file backups
cp ~/caddy/Caddyfile ~/backups/Caddyfile.bak
See Backup Strategy for a comprehensive approach.
Troubleshooting
Certificate not provisioning
Symptom: Caddy logs show ACME challenge failed or obtaining certificate errors.
Fix: Verify DNS and firewall configuration:
# Check DNS resolves to your server
dig +short yourdomain.com
# Ensure ports 80 and 443 are open
sudo ufw allow 80/tcp
sudo ufw allow 443/tcp
Let’s Encrypt requires port 80 to be publicly reachable for HTTP-01 challenges. If your server is behind NAT, configure port forwarding. If behind Cloudflare proxy, temporarily set DNS to DNS-only (grey cloud) during initial certificate provisioning.
502 Bad Gateway or “dial tcp: connection refused”
Symptom: Browser shows a Caddy error page with “502” or logs show connection refused.
Fix: The upstream service is unreachable. Verify:
# Check if the target container is running
docker ps | grep your-service
# Check if it's on the same Docker network
docker network inspect proxy
The container name in your Caddyfile must exactly match the container_name or service name, and both containers must be on the same Docker network.
ERR_TOO_MANY_REDIRECTS
Symptom: Browser shows a redirect loop when accessing a proxied service.
Fix: This happens when Caddy and the upstream service both try to redirect HTTP to HTTPS. Configure the upstream to not enforce HTTPS (it is behind Caddy, which already handles TLS). For Nextcloud, set overwriteprotocol to https in config.php. For other apps, disable their built-in TLS/HTTPS redirect.
Changes to Caddyfile not taking effect
Symptom: You edited the Caddyfile but the old config is still active.
Fix: Caddy does not auto-reload the Caddyfile. You must explicitly reload:
docker compose exec caddy caddy reload --config /etc/caddy/Caddyfile
If the reload fails with a syntax error, Caddy keeps the old config running. Check the error message, fix the Caddyfile, and reload again.
Container fails to bind to port 80 or 443
Symptom: Error starting userland proxy: listen tcp4 0.0.0.0:80: bind: address already in use
Fix: Another process is using the port:
sudo lsof -i :80
sudo lsof -i :443
Stop the conflicting service (commonly Apache or Nginx):
sudo systemctl stop apache2 && sudo systemctl disable apache2
sudo systemctl stop nginx && sudo systemctl disable nginx
Resource Requirements
- RAM: ~20 MB idle, ~50 MB under moderate load (significantly lighter than Nginx Proxy Manager)
- CPU: Minimal — Caddy is written in Go with efficient concurrency
- Disk: ~50 MB for the binary and config, plus TLS certificate storage (negligible)
Verdict
Caddy is the best reverse proxy for most self-hosters. Its automatic HTTPS with zero configuration is unmatched — where Nginx Proxy Manager needs you to click through a UI and Traefik requires Docker labels on every service, Caddy just works when you give it a domain name. The Caddyfile syntax is cleaner and more readable than Nginx configs or Traefik’s YAML/TOML files. It uses less RAM than Nginx Proxy Manager and has a simpler mental model than Traefik.
Choose Nginx Proxy Manager if you want a web UI and never want to touch config files. Choose Traefik if you need deep Docker integration with automatic service discovery via labels. Choose Caddy for everything else — it is the right default for self-hosting in 2026.
Frequently Asked Questions
Does Caddy automatically handle HTTPS?
Yes. If your domain’s DNS points to your server and ports 80/443 are accessible, Caddy obtains and renews Let’s Encrypt certificates automatically. No configuration needed beyond specifying the domain name in your Caddyfile.
Can Caddy replace Nginx?
Yes. Caddy handles reverse proxying, static file serving, load balancing, and TLS termination. For most self-hosting use cases, Caddy is a drop-in replacement for Nginx with far less configuration. Caddy lacks some of Nginx’s niche modules for advanced caching and stream processing, but these are irrelevant for typical self-hosting setups.
Is Caddy fast enough for production?
Yes. Caddy is written in Go, supports HTTP/3 out of the box, and handles thousands of concurrent connections efficiently. For self-hosting workloads (tens to hundreds of concurrent users), Caddy’s performance is indistinguishable from Nginx or Traefik.
How do I use Caddy with Cloudflare proxy?
Set Cloudflare SSL/TLS mode to “Full (Strict).” Caddy manages the origin certificate, and Cloudflare handles the edge. If you want to use Cloudflare proxy mode (orange cloud) and still get valid origin certificates, build Caddy with the Cloudflare DNS plugin and use the DNS-01 challenge as shown in the Advanced Configuration section.
Can I run Caddy on a Raspberry Pi?
Yes. The caddy:2.10.2-alpine image supports arm64. Caddy’s low resource usage (20 MB RAM idle) makes it an excellent choice for Raspberry Pi and other single-board computers.
Related
Get self-hosting tips in your inbox
New guides, comparisons, and setup tutorials — delivered weekly. No spam.