TL;DR — Quick Summary
Complete guide to Caddy reverse proxy with automatic HTTPS. Configure SSL with zero config, load balancing, health checks, and production multi-site hosting.
Caddy is a Go-based web server that makes automatic HTTPS the default — not an afterthought. Unlike Nginx or Apache where TLS means installing certbot, writing renewal scripts, and debugging cron jobs, Caddy negotiates certificates from Let’s Encrypt or ZeroSSL the moment you add a domain to your config. This guide covers everything from installation to production multi-site reverse proxy setups.
Prerequisites
- A Linux server (Ubuntu 22.04/24.04, Debian, RHEL, or Docker).
- A domain name with DNS A records pointing to your server’s public IP.
- Ports 80 and 443 open in your firewall — Caddy needs both for ACME HTTP-01 challenge.
- Backend services running on localhost ports (Node.js, Python, PHP, etc.).
Caddy Architecture
Caddy is a single static binary written in Go with no runtime dependencies. Key architectural decisions:
Automatic HTTPS via ACME. Caddy implements the ACME protocol natively. When you configure a domain, Caddy:
- Detects the domain requires a certificate.
- Runs an HTTP-01 or TLS-ALPN-01 ACME challenge.
- Stores the certificate in
~/.local/share/caddy/(or/var/lib/caddy/for system installs). - Schedules renewal 30 days before expiry — automatically, in the background.
- Staples OCSP responses to TLS handshakes for faster revocation checks.
Certificate storage. Certificates live in ~/.local/share/caddy/certificates/ organized by ACME server hostname. Caddy supports multiple ACME providers simultaneously — Let’s Encrypt and ZeroSSL by default, with automatic failover.
Admin API. Caddy exposes a REST API at localhost:2019 for live config changes — no restart needed. The /config/ endpoint accepts the full JSON config, /load replaces the entire config atomically, and /reverse_proxy/upstreams shows live upstream health.
Installation
apt (Ubuntu/Debian — recommended)
sudo apt install -y debian-keyring debian-archive-keyring apt-transport-https curl
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
Caddy starts immediately as a systemd service. Your Caddyfile is at /etc/caddy/Caddyfile.
Docker
docker run -d \
--name caddy \
-p 80:80 -p 443:443 -p 443:443/udp \
-v $PWD/Caddyfile:/etc/caddy/Caddyfile \
-v caddy_data:/data \
-v caddy_config:/config \
caddy:latest
The /data volume persists certificates across container restarts.
xcaddy — Custom Builds with Plugins
Caddy’s plugin ecosystem extends it with rate limiting, DNS challenges, and more. Build a custom binary:
go install github.com/caddyserver/xcaddy/cmd/xcaddy@latest
xcaddy build \
--with github.com/caddy-dns/cloudflare \
--with github.com/mholt/caddy-ratelimit
This produces a caddy binary with Cloudflare DNS challenge support (for wildcard certificates) and rate limiting.
Caddyfile Syntax
The Caddyfile is Caddy’s human-friendly configuration format. Core concepts:
Site blocks define which hostname Caddy serves:
example.com {
reverse_proxy localhost:3000
}
Global options apply server-wide settings:
{
email admin@example.com
acme_ca https://acme-v02.api.letsencrypt.org/directory
}
Snippets let you reuse config blocks:
(common_headers) {
header {
X-Content-Type-Options nosniff
X-Frame-Options DENY
-Server
}
}
example.com {
import common_headers
reverse_proxy localhost:3000
}
Matchers apply directives to specific request paths:
example.com {
handle /api/* {
reverse_proxy localhost:8080
}
handle /* {
reverse_proxy localhost:3000
}
}
Reverse Proxy Configuration
Basic Reverse Proxy
example.com {
reverse_proxy localhost:3000
}
That’s it. Caddy handles HTTP→HTTPS redirect, certificate issuance, renewal, and OCSP stapling automatically.
Load Balancing
example.com {
reverse_proxy app1:3000 app2:3000 app3:3000 {
lb_policy round_robin
}
}
Available lb_policy values:
| Policy | Description |
|---|---|
round_robin | Distributes requests evenly across upstreams |
least_conn | Routes to the upstream with fewest active connections |
first | Always tries upstreams in order; uses first available |
random | Picks a random upstream each request |
ip_hash | Hashes client IP for sticky sessions |
cookie | Uses a cookie for session persistence |
header | Hashes a request header value |
uri_hash | Hashes the request URI |
Active and Passive Health Checks
example.com {
reverse_proxy app1:3000 app2:3000 {
lb_policy least_conn
# Active health checks — Caddy polls upstreams
health_uri /health
health_interval 10s
health_timeout 5s
health_status 200
# Passive health checks — mark unhealthy on failures
fail_duration 30s
max_fails 3
unhealthy_status 500 502 503
unhealthy_latency 5s
}
}
Active checks poll /health every 10 seconds. Passive checks automatically remove an upstream after 3 consecutive failures and re-add it after 30 seconds.
Header Manipulation
example.com {
reverse_proxy localhost:3000 {
header_up Host {upstream_hostport}
header_up X-Real-IP {remote_host}
header_up X-Forwarded-For {remote_host}
header_up X-Forwarded-Proto {scheme}
header_down -Server
header_down -X-Powered-By
}
}
header_up modifies request headers sent to the upstream. header_down modifies response headers sent to the client. Prefix a header name with - to remove it.
Automatic HTTPS Deep Dive
How Caddy Obtains Certificates
- A request arrives for
example.com. - Caddy checks its certificate store — no cert found.
- Caddy triggers an ACME HTTP-01 challenge: creates a temporary file at
/.well-known/acme-challenge/<token>. - The ACME server (Let’s Encrypt) fetches the token over HTTP.
- Caddy receives the signed certificate and stores it.
- All future connections use HTTPS; HTTP requests redirect to HTTPS automatically.
- Caddy renews the certificate 30 days before expiry in the background.
On-Demand TLS
For dynamic domains (e.g., a SaaS platform where users add custom domains), Caddy supports on-demand TLS — it obtains certificates the first time a domain is requested:
{
on_demand_tls {
ask http://localhost:8080/check-domain
interval 2m
burst 5
}
}
:443 {
tls {
on_demand
}
reverse_proxy localhost:3000
}
The ask endpoint is your app’s API — Caddy calls it before issuing a cert, letting you allow or deny domains.
Internal CA for Local Development
For local development with real HTTPS (no browser warnings), use Caddy’s internal CA:
localhost {
tls internal
reverse_proxy localhost:3000
}
Run caddy trust to install Caddy’s local CA into your system/browser trust store.
Custom ACME Endpoint (Smallstep, BuyPass, etc.)
{
acme_ca https://ca.example.com/acme/acme/directory
acme_ca_root /path/to/root-ca.pem
}
This works with Smallstep CA, BuyPass Go SSL, or any RFC 8555 compliant ACME server.
Common Caddyfile Patterns
SPA with Client-Side Routing
app.example.com {
root * /var/www/app
try_files {path} /index.html
file_server
}
PHP with PHP-FPM
php.example.com {
root * /var/www/php-app
php_fastcgi unix//run/php/php8.3-fpm.sock
file_server
}
Static File Server with Compression
files.example.com {
root * /var/www/files
encode gzip zstd
file_server browse
}
Basic Authentication
private.example.com {
basicauth {
alice JDJhJDE0JHBMbUhBRzVJQ... # bcrypt hash from caddy hash-password
}
reverse_proxy localhost:8080
}
JSON Config API
Caddy’s admin API at localhost:2019 enables live configuration without restarts:
# View current config
curl http://localhost:2019/config/
# Replace entire config
curl -X POST http://localhost:2019/load \
-H "Content-Type: application/json" \
-d @caddy.json
# Check upstream health
curl http://localhost:2019/reverse_proxy/upstreams
# Add a route without reloading
curl -X POST http://localhost:2019/config/apps/http/servers/srv0/routes \
-H "Content-Type: application/json" \
-d '{"match":[{"host":["newsite.com"]}],...}'
Logging and Metrics
Structured JSON Logging
example.com {
log {
output file /var/log/caddy/access.log {
roll_size 100mb
roll_keep 10
roll_keep_for 720h
}
format json
level INFO
}
reverse_proxy localhost:3000
}
Prometheus Metrics
{
servers {
metrics
}
}
Caddy exposes Prometheus metrics at http://localhost:2019/metrics. Scrape with Prometheus and visualize in Grafana.
Caddy vs Nginx vs Traefik vs HAProxy
| Feature | Caddy | Nginx | Traefik | HAProxy |
|---|---|---|---|---|
| Automatic HTTPS | Built-in, default | Manual (certbot) | Built-in | Manual |
| Config syntax | Simple Caddyfile or JSON | Complex nginx.conf | YAML/TOML labels | Complex |
| Runtime reload | Yes (API + signal) | Yes (signal) | Yes | Yes (socket) |
| Load balancing | 8 policies | Round-robin, IP hash | Multiple | Extensive |
| Admin API | REST JSON | No | REST JSON | Stats socket |
| HTTP/3 (QUIC) | Yes | Yes (v1.25+) | Yes | No |
| Plugin ecosystem | xcaddy | Dynamic modules | Provider plugins | No |
| Resource usage | Low (Go) | Very low (C) | Medium (Go) | Very low (C) |
| Best for | Auto-HTTPS, simplicity | High-perf static | Docker/K8s | TCP load balancing |
Production Caddyfile: Multi-Site Hosting
{
email ops@example.com
admin localhost:2019
}
(security_headers) {
header {
X-Content-Type-Options nosniff
X-Frame-Options SAMEORIGIN
Referrer-Policy strict-origin-when-cross-origin
-Server
-X-Powered-By
}
}
(proxy_headers) {
header_up Host {upstream_hostport}
header_up X-Real-IP {remote_host}
header_up X-Forwarded-For {remote_host}
header_up X-Forwarded-Proto {scheme}
}
# Main app — load balanced
app.example.com {
import security_headers
encode gzip zstd
log {
output file /var/log/caddy/app.log
format json
}
reverse_proxy app1:3000 app2:3000 {
import proxy_headers
lb_policy least_conn
health_uri /health
health_interval 15s
fail_duration 30s
}
}
# API backend
api.example.com {
import security_headers
@cors_preflight method OPTIONS
handle @cors_preflight {
header Access-Control-Allow-Origin "https://app.example.com"
header Access-Control-Allow-Methods "GET, POST, PUT, DELETE, OPTIONS"
header Access-Control-Max-Age 3600
respond "" 204
}
reverse_proxy localhost:8080 {
import proxy_headers
}
}
# Static marketing site
www.example.com {
import security_headers
encode gzip zstd
root * /var/www/marketing
try_files {path} /index.html
file_server
}
# Redirect bare domain
example.com {
redir https://www.example.com{uri} 301
}
Gotchas and Edge Cases
- Port 80 must be reachable. Caddy uses HTTP-01 ACME challenge. If port 80 is firewalled, use the DNS-01 challenge with a DNS plugin via xcaddy.
- Rate limits on Let’s Encrypt. You can request 50 certificates per domain per week. Use the Let’s Encrypt staging CA (
acme_ca https://acme-staging-v02.api.letsencrypt.org/directory) during testing. header_up Hostbreaks some upstreams. Some backends requireHost: localhostnot the external hostname. Test withoutheader_up Hostfirst.- On-demand TLS needs an
askendpoint. Without theaskURL, anyone can trigger certificate issuance for any domain — a Let’s Encrypt rate limit attack vector. - Admin API is localhost-only by default. Never expose port 2019 publicly — it allows live config replacement with no authentication.
- Caddy reloads vs restarts.
systemctl reload caddy(graceful, zero-downtime) vssystemctl restart caddy(kills all connections). Always use reload in production.
Summary
- Automatic HTTPS — Caddy obtains and renews Let’s Encrypt/ZeroSSL certificates with zero configuration.
- Simple syntax — A five-line Caddyfile replaces hundreds of lines of Nginx config.
- 8 load balancing policies — including sticky sessions via cookie and IP hash.
- Active and passive health checks — automatically remove and re-add failed upstreams.
- On-demand TLS — issue certificates dynamically for user-provided domains.
- REST admin API — live config changes at
localhost:2019without restarts. - Internal CA — real HTTPS for local development without browser warnings.