TL;DR — Quick Summary
Complete guide to Caddy as a reverse proxy with automatic HTTPS. Caddyfile syntax, load balancing policies, health checks, and multi-site production Caddyfile.
Caddy is a modern, open-source web server written in Go whose defining feature is automatic HTTPS — it provisions and renews TLS certificates from Let’s Encrypt or ZeroSSL the moment you point a domain at it, with zero manual Certbot setup. When used as a reverse proxy, Caddy combines that zero-config TLS management with a clean declarative syntax, built-in load balancing, HTTP/2 and HTTP/3 by default, and a single statically-compiled binary with no runtime dependencies. This guide covers everything you need to run Caddy as a production reverse proxy: installation, Caddyfile syntax, all load balancing policies, active and passive health checks, header manipulation, authentication, on-demand TLS, the admin API, and a complete multi-site Caddyfile.
Why Choose Caddy as a Reverse Proxy
Before diving into configuration, here is a direct comparison of the major reverse proxy options available in 2026:
| Feature | Caddy | Nginx | Traefik | HAProxy | Apache |
|---|---|---|---|---|---|
| Automatic HTTPS | Built-in ACME client | Manual (Certbot) | Built-in (via ACME) | Manual | Manual (mod_md) |
| Configuration format | Caddyfile (minimal) | nginx.conf (verbose) | YAML/TOML/Docker labels | haproxy.cfg | httpd.conf |
| HTTP/2 | Default | Explicit config | Default | No (TCP only) | Explicit config |
| HTTP/3 (QUIC) | Default | Experimental | Via plugin | No | No |
| Single binary | Yes (Go, no deps) | No (C, with modules) | Yes (Go) | Yes (C) | No |
| Runtime config API | Full REST API | No | Full REST API | Stats socket only | No |
| Load balancing | 8 policies built-in | Limited built-in | Multiple providers | Excellent | Basic |
| Memory footprint | ~20-50 MB | ~5-15 MB | ~25-60 MB | ~5-10 MB | ~30-80 MB |
| Learning curve | Low | Medium-High | Medium | High | Medium-High |
Choose Caddy when: you want automatic TLS, minimal config, and HTTP/3 without plugin builds or external tools. Ideal for self-hosted apps, multi-tenant reverse proxy, and teams that want zero certificate management overhead.
Choose Nginx when: you need maximum raw throughput, years of battle-tested documentation, or very specific worker/buffer tuning unavailable in Caddy.
Choose Traefik when: you run heavy Docker/Kubernetes workloads and want dynamic service discovery via container labels rather than editing config files.
Prerequisites
- A Linux server running Ubuntu 22.04+, Debian 12+, or a RHEL/CentOS-compatible distribution
- A domain name with DNS A/AAAA records pointing to the server’s public IP address
- Ports 80 and 443 open in your firewall (required for ACME HTTP-01 challenge and HTTPS traffic)
- Root or sudo access
- No other process bound to ports 80 or 443 (stop Nginx, Apache, or any other web server first)
# Open required ports with UFW
sudo ufw allow 80/tcp && sudo ufw allow 443/tcp && sudo ufw status
Installing Caddy
Ubuntu and Debian (APT)
sudo apt update && 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 -y caddy
caddy version
RHEL, CentOS, Fedora (DNF)
dnf install 'dnf-command(copr)'
dnf copr enable @caddy/caddy
dnf install caddy
Docker
docker run -d \
-p 80:80 -p 443:443 -p 443:443/udp \
-v /path/to/Caddyfile:/etc/caddy/Caddyfile \
-v caddy_data:/data \
-v caddy_config:/config \
caddy:latest
xcaddy — Custom Builds with Plugins
When you need third-party modules such as caddy-dns for DNS-01 challenges or caddy-rate-limit:
# Install xcaddy
go install github.com/caddyserver/xcaddy/cmd/xcaddy@latest
# Build Caddy with a DNS provider plugin (Cloudflare example)
xcaddy build \
--with github.com/caddy-dns/cloudflare \
--with github.com/mholt/caddy-ratelimit
sudo mv caddy /usr/bin/caddy
sudo systemctl restart caddy
The APT installation creates the caddy system user, places the default Caddyfile at /etc/caddy/Caddyfile, stores certificates in /var/lib/caddy/.local/share/caddy, and registers a systemd service.
Caddyfile Syntax
The Caddyfile is Caddy’s primary configuration format. It is intentionally human-readable — you describe what you want, and Caddy fills in secure defaults.
Structure
# Global options block (no address)
{
email admin@example.com # ACME account email
admin off # Disable admin API in production
grace_period 10s # Drain connections on shutdown
}
# Site block — address triggers automatic HTTPS
example.com {
directive argument
}
# Multiple addresses share the same config
app.example.com, www.app.example.com {
reverse_proxy localhost:3000
}
Matchers
Matchers filter which requests a directive applies to:
example.com {
# Named matcher (reusable)
@api path /api/*
reverse_proxy @api localhost:8000
# Inline matcher (path only)
reverse_proxy /static/* localhost:9000
# Wildcard matcher (* = all requests)
file_server *
}
Common matcher types: path, host, method, header, query, remote_ip, protocol, not, and expression (CEL expressions for complex logic).
Placeholders
Caddy provides runtime placeholders for dynamic values:
example.com {
reverse_proxy localhost:3000 {
header_up X-Real-IP {remote_host}
header_up X-Forwarded-Port {server_port}
header_up X-Request-ID {uuid}
}
}
Common placeholders: {remote_host}, {remote_port}, {server_port}, {host}, {path}, {method}, {uuid}, {upstream_hostport}.
Reverse Proxy Configuration
Basic Single Backend
app.example.com {
reverse_proxy localhost:3000
}
This single line gives you: automatic HTTPS, HTTP/2, HTTP/3, HTTP-to-HTTPS redirect, X-Forwarded-For header forwarding, and transparent WebSocket support.
Path-Based Routing
example.com {
reverse_proxy /api/* localhost:8000
reverse_proxy /ws/* localhost:4000
reverse_proxy /admin/* localhost:9000
root * /var/www/frontend
file_server
}
Header Manipulation
Use header_up to modify request headers sent to the upstream and header_down to modify response headers returned to the client:
app.example.com {
reverse_proxy localhost:3000 {
# Add/override upstream request headers
header_up Host {upstream_hostport}
header_up X-Real-IP {remote_host}
header_up X-Forwarded-Proto {scheme}
# Remove a header before forwarding
header_up -Authorization
# Modify response headers from upstream
header_down -X-Internal-Server-ID
header_down Strict-Transport-Security "max-age=63072000; includeSubDomains; preload"
}
}
WebSocket and Long-Lived Connections
Caddy proxies WebSocket connections automatically. For long-lived connections, tune the flush interval:
ws.example.com {
reverse_proxy localhost:4000 {
flush_interval -1 # Immediate flush (required for SSE and WebSockets)
}
}
Load Balancing
Multiple Upstream Addresses
app.example.com {
reverse_proxy localhost:3001 localhost:3002 localhost:3003
}
Default policy is round_robin — requests cycle through backends sequentially.
All Load Balancing Policies
app.example.com {
reverse_proxy localhost:3001 localhost:3002 localhost:3003 {
lb_policy least_conn # Backend with fewest active connections
# lb_policy round_robin # Sequential cycling (default)
# lb_policy first # First available backend always
# lb_policy random # Random selection each request
# lb_policy ip_hash # Client IP → consistent backend (session affinity)
# lb_policy cookie caddy_lb # Cookie-based session persistence
# lb_policy header X-Shard # Route by request header value
# lb_policy uri_hash # Request URI → consistent backend
}
}
Policy selection guide:
round_robin— Stateless services where any instance can handle any requestleast_conn— Long-running requests (APIs, streaming) to avoid overloading slow backendsip_hash/cookie— Stateful applications that require session affinityuri_hash— Cache-friendly routing where the same URL should hit the same backendfirst— Primary/standby failover where the first healthy backend absorbs all traffic
Active Health Checks
Active checks probe backends on a schedule and remove unhealthy instances from the pool:
app.example.com {
reverse_proxy localhost:3001 localhost:3002 localhost:3003 {
lb_policy least_conn
health_uri /health # Endpoint to probe
health_interval 15s # Probe every 15 seconds
health_timeout 5s # Fail probe after 5 seconds
health_status 200 # Expected HTTP status code
health_headers Authorization "Bearer healthcheck-token"
}
}
Passive Health Checks
Passive checks detect failures from real traffic without external probing:
app.example.com {
reverse_proxy localhost:3001 localhost:3002 localhost:3003 {
lb_policy round_robin
# Mark a backend unhealthy after 3 failures within 30 seconds
fail_duration 30s
max_fails 3
# Mark unhealthy if latency exceeds 500ms
unhealthy_latency 500ms
# Mark unhealthy if response code is 5xx
unhealthy_status 5xx
}
}
Combining Active and Passive
app.example.com {
reverse_proxy localhost:3001 localhost:3002 localhost:3003 {
lb_policy least_conn
health_uri /health
health_interval 10s
health_timeout 3s
fail_duration 20s
max_fails 2
unhealthy_latency 300ms
}
}
Automatic HTTPS in Depth
How Caddy Obtains Certificates
When Caddy encounters a public domain name, it:
- Registers an ACME account with Let’s Encrypt (or ZeroSSL as fallback) using your email
- Completes one of the ACME domain validation challenges to prove ownership
- Receives and installs the signed certificate and private key
- Automatically redirects port 80 → 443
- Schedules background renewal approximately 30 days before expiration
ACME Challenge Types
{
email admin@example.com
}
# HTTP-01 (default): Caddy serves /.well-known/acme-challenge/ on port 80
example.com {
reverse_proxy localhost:3000
}
# TLS-ALPN-01: Uses port 443, works when port 80 is blocked
tls-only.example.com {
tls {
challenges tls-alpn-01
}
reverse_proxy localhost:3001
}
# DNS-01: No inbound port required — needs xcaddy + DNS plugin
# Used for wildcard certificates and internal services
wildcard.example.com {
tls {
dns cloudflare {env.CF_API_TOKEN}
}
reverse_proxy localhost:3002
}
On-Demand TLS for Dynamic Domains
On-demand TLS issues certificates at connection time rather than startup — useful for SaaS platforms where each customer has their own subdomain:
{
on_demand_tls {
ask http://localhost:9001/check-domain # Validation endpoint
interval 2m
burst 5
}
}
:443 {
tls {
on_demand
}
reverse_proxy localhost:3000
}
Your validation endpoint must return 200 OK for allowed domains and any other status to reject.
Internal CA for Development
{
local_certs # Generate self-signed certs and install root CA locally
}
localhost, 127.0.0.1, ::1 {
reverse_proxy localhost:3000
}
Caddy installs its root CA into the system trust store so browsers accept the certificate without warnings.
Custom TLS Options
secure.example.com {
tls {
protocols tls1.2 tls1.3 # Minimum TLS version
ciphers TLS_ECDHE_ECDSA_WITH_AES_256_GCM_SHA384 TLS_ECDHE_RSA_WITH_AES_256_GCM_SHA384
curves x25519 secp384r1
}
reverse_proxy localhost:3000
}
Authentication
HTTP Basic Auth
admin.example.com {
basicauth /admin/* {
# Generate hashed password: caddy hash-password
alice $2a$14$Zkx19XLiW6VYouLHR5NmfOFU0z2GT.VVKf3KCrXxpHPkAXmn9sQHO
}
reverse_proxy localhost:9000
}
Generate the password hash:
caddy hash-password --plaintext 'yourpassword'
Forward Auth (Authelia, oauth2-proxy)
app.example.com {
forward_auth authelia:9091 {
uri /api/verify?rd=https://auth.example.com
copy_headers Remote-User Remote-Groups Remote-Name Remote-Email
}
reverse_proxy localhost:3000
}
File Server and SPA Support
Static File Server
static.example.com {
root * /var/www/html
encode gzip zstd
file_server {
hide .git .env .htaccess
precompressed gzip br zstd
}
}
Single-Page Application (SPA)
spa.example.com {
root * /var/www/app/dist
encode gzip zstd
# Serve index.html for all non-file routes (client-side routing)
try_files {path} /index.html
file_server
}
Compression and Logging
Compression
example.com {
encode {
zstd # Preferred (better ratio, faster decode)
gzip 6 # Fallback (compression level 1-9, default 6)
minimum_length 1024 # Skip compressing responses under 1KB
}
reverse_proxy localhost:3000
}
Structured JSON Access Logging
example.com {
log {
output file /var/log/caddy/access.log {
roll_size 100MiB
roll_keep 10
roll_keep_for 720h
}
format json
level INFO
}
reverse_proxy localhost:3000
}
Log entries include: timestamp, request method, URI, status code, response size, duration, remote IP, and TLS version.
The Caddy Admin API
Caddy exposes a local REST API on localhost:2019 for runtime configuration without reloading:
# View current running configuration
curl http://localhost:2019/config/
# List active certificates
curl http://localhost:2019/pki/ca/local
# Load a new configuration from a Caddyfile
curl -X POST http://localhost:2019/load \
-H "Content-Type: text/caddyfile" \
--data-binary @/etc/caddy/Caddyfile
# Dynamically add a new route at runtime
curl -X POST http://localhost:2019/config/apps/http/servers/srv0/routes \
-H "Content-Type: application/json" \
-d '{"handle":[{"handler":"reverse_proxy","upstreams":[{"dial":"localhost:5000"}]}]}'
Disable the admin API in production when not needed:
{
admin off
}
Or bind it to a unix socket for secure access:
{
admin unix//run/caddy/admin.sock
}
Production Multi-Site Caddyfile
This is a complete production Caddyfile for multi-site hosting with security headers, compression, structured logging, load balancing, and reusable snippets:
{
email admin@example.com
grace_period 30s
admin unix//run/caddy/admin.sock
}
# Reusable security headers snippet
(security_headers) {
header {
Strict-Transport-Security "max-age=63072000; includeSubDomains; preload"
X-Content-Type-Options "nosniff"
X-Frame-Options "SAMEORIGIN"
Referrer-Policy "strict-origin-when-cross-origin"
Permissions-Policy "camera=(), microphone=(), geolocation=()"
-Server
-X-Powered-By
}
}
# Main application — load balanced with health checks
app.example.com {
import security_headers
encode zstd gzip
log {
output file /var/log/caddy/app-access.log { roll_size 50MiB; roll_keep 7 }
format json
}
reverse_proxy localhost:3001 localhost:3002 localhost:3003 {
lb_policy least_conn
health_uri /health
health_interval 10s
health_timeout 3s
fail_duration 30s
max_fails 3
header_up X-Real-IP {remote_host}
header_up X-Forwarded-Proto {scheme}
header_down -X-Internal-Trace-ID
}
}
# API service — single backend with forward auth
api.example.com {
import security_headers
encode zstd gzip
header {
Access-Control-Allow-Origin "https://app.example.com"
Access-Control-Allow-Methods "GET, POST, PUT, DELETE, OPTIONS"
}
log {
output file /var/log/caddy/api-access.log { roll_size 50MiB; roll_keep 7 }
format json
}
reverse_proxy localhost:8000 {
header_up X-Real-IP {remote_host}
flush_interval -1
}
}
# Admin panel — basic auth protected
admin.example.com {
import security_headers
basicauth * {
admin $2a$14$Zkx19XLiW6VYouLHR5NmfOFU0z2GT.VVKf3KCrXxpHPkAXmn9sQHO
}
reverse_proxy localhost:9000
}
# Static site with SPA fallback
www.example.com {
import security_headers
root * /var/www/frontend/dist
encode zstd gzip
@static path *.css *.js *.png *.jpg *.webp *.gif *.svg *.woff2 *.ico
header @static Cache-Control "public, max-age=31536000, immutable"
try_files {path} /index.html
file_server
}
Gotchas and Edge Cases
Port 80 must be reachable for HTTP-01 challenges. If your cloud provider or firewall blocks port 80, use the tls-alpn-01 challenge or a DNS-01 challenge via xcaddy with a DNS plugin. Caddy will keep retrying certificate issuance but your site will be inaccessible until the first certificate is issued.
Let’s Encrypt rate limits. The production ACME server allows 5 duplicate certificates per domain per week. During testing, set acme_ca https://acme-staging-v02.api.letsencrypt.org/directory to use the staging environment (which has much higher limits) and remove it before going live.
The caddy user needs read access to your web roots. The systemd service runs as the caddy system user. Always run sudo chown -R caddy:caddy /var/www/yoursite after creating or copying files.
Flush interval matters for streaming. Server-Sent Events (SSE) and streaming APIs require flush_interval -1 inside the reverse_proxy block. Without it, Caddy buffers the response and clients never receive streamed data.
xcaddy replaces the system binary. When you build a custom Caddy with xcaddy, copy the resulting binary to /usr/bin/caddy and restart the service. The APT-installed binary will be overwritten on the next apt upgrade caddy — pin the package or manage the binary separately.
Troubleshooting
# Validate Caddyfile syntax before reloading
caddy validate --config /etc/caddy/Caddyfile
# Auto-format the Caddyfile
caddy fmt --overwrite /etc/caddy/Caddyfile
# Watch live Caddy logs (includes ACME events)
sudo journalctl -u caddy -f --no-pager
# Check which ports Caddy is listening on
sudo ss -tlnp | grep caddy
# Test TLS certificate and chain
echo | openssl s_client -connect example.com:443 -servername example.com 2>/dev/null \
| openssl x509 -noout -subject -issuer -dates
# Verify the backend is reachable from the server
curl -I http://localhost:3000
# Check certificate storage
ls -la /var/lib/caddy/.local/share/caddy/certificates/
Common errors and fixes:
dial tcp: connection refusedin logs → backend is not running or not bound to the expected portno such hostduring ACME → DNS A/AAAA record not pointing to this server’s IPcertificate obtainednot appearing → port 80 is blocked or another process owns itpermission deniedserving files → runsudo chown -R caddy:caddy /var/www/yourdir
Summary
- Caddy is a single Go binary with no external dependencies that provides automatic HTTPS via ACME, HTTP/2 and HTTP/3 by default, and a minimal Caddyfile syntax
- The
reverse_proxydirective handles proxying, load balancing, header manipulation, WebSocket support, and health checks in one block - Eight load balancing policies cover every scenario from stateless round-robin to cookie-based session affinity
- Active health checks (
health_uri,health_interval) and passive health checks (fail_duration,max_fails) can be combined for robust failure detection - On-demand TLS enables per-tenant certificate issuance for SaaS platforms without any pre-configuration
- The admin API at
localhost:2019allows runtime configuration changes and certificate inspection without restarting - Use snippets (
(name) {}andimport) to share configuration blocks across multiple site definitions