Caddy is a modern, open-source web server written in Go. It stands out from nginx and Apache in one critical way: it handles TLS automatically, without plugins, certbot, cron jobs, or manual renewal. For self-hosters and developers, this alone makes Caddy the default choice. The configuration language is also dramatically simpler — what takes 30 lines in nginx takes 3 in a Caddyfile.
Automatic HTTPS
Caddy is the only web server that provisions and renews Let's Encrypt TLS certificates automatically. Zero configuration needed. Supports ACME HTTP-01, DNS-01, and TLS-ALPN challenges. Point a domain at your server, write a Caddyfile, and HTTPS just works.
- Certificates stored in
~/.local/share/caddy(Linux) or$HOME/Library/Application Support/Caddy(macOS) - Auto-renews certificates before expiry — no cron job needed
- On-demand TLS: provision certificates the first time a domain is requested
- Local HTTPS for development via a built-in local CA (
caddy trust) - Supports ZeroSSL and custom ACME endpoints in addition to Let's Encrypt
Caddyfile Syntax
Caddy's config language is drastically simpler than nginx or Apache. A full reverse proxy with HTTPS is just 3 lines. Caddy also exposes a JSON API for programmatic configuration and supports hot-reload without downtime via caddy reload.
- Intuitive block-based syntax — no semicolons, no
locationblocks - Directives are ordered by priority automatically; no
try_fileshacks - JSON API:
POST /loadto push a new config atomically caddy reloadapplies changes without dropping connections- Named matchers for reusable request matching logic
HTTP/2 and HTTP/3
HTTP/2 is enabled by default for all HTTPS sites in Caddy. HTTP/3 (QUIC) is available via the experimental protocol config. Both multiplexing and header compression are built in — no extra modules to install.
- HTTP/2 enabled automatically for all TLS connections
- HTTP/3 via
servers { protocol { experimental_http3 } }in global config - QUIC requires UDP 443 to be open at the firewall
- Server push available (HTTP/2) for preloading critical assets
- Graceful fallback to HTTP/1.1 for older clients
Security Defaults
Caddy sets secure TLS defaults out of the box: TLS 1.2 minimum, modern cipher suites, OCSP stapling, and automatic HSTS for HTTPS sites. Security headers (CSP, X-Frame-Options, etc.) must be added manually via the header directive — but the TLS foundation is already hardened.
- TLS 1.2 minimum enforced globally; TLS 1.3 preferred
- Modern cipher suites only: ECDHE with AES-GCM and ChaCha20-Poly1305
- OCSP stapling enabled by default for faster TLS handshakes
- Automatic HSTS with
includeSubDomainswhen HTTPS is active - No server version disclosed in response headers by default
Caddy distributes official packages for macOS (Homebrew), Debian/Ubuntu, RHEL/Fedora, and Docker. The binary is a single self-contained executable with no runtime dependencies. Choose the method that matches your environment below.
Install via Homebrew
The easiest way on macOS. Homebrew keeps the formula up to date with each Caddy release.
brew install caddy
This installs the caddy binary to /usr/local/bin/caddy (Intel Macs) or /opt/homebrew/bin/caddy (Apple Silicon). No PATH changes needed.
Verify the installation
caddy version
Should print something like v2.8.4 h1:.... If the command is not found, ensure your Homebrew bin directory is in $PATH.
Start Caddy as a Homebrew service (runs on login)
brew services start caddy
Uses a launchd plist at ~/Library/LaunchAgents/homebrew.mxcl.caddy.plist. Caddy will start automatically on login. The Caddyfile is expected at /opt/homebrew/etc/Caddyfile (Apple Silicon) or /usr/local/etc/Caddyfile (Intel). Check or create it there.
Stop the service: brew services stop caddy | Restart: brew services restart caddy
Or run Caddy manually in the foreground
caddy run --config /path/to/Caddyfile
Useful for development. Ctrl+C to stop. Without --config, Caddy looks for a Caddyfile in the current directory.
Test with a minimal Caddyfile
Create a file named Caddyfile with:
localhost respond "Hello from Caddy!"
Then run caddy run in the same directory. Visit https://localhost — Caddy auto-creates a local CA and issues a trusted certificate.
Local HTTPS trust
For the local certificate to be trusted by your browser, run this once: sudo caddy trust. This installs Caddy's local CA into the macOS system keychain. You'll see the green padlock on localhost.
Add the official Caddy apt repository (Debian / Ubuntu)
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
This installs the caddy binary, creates a caddy system user, and registers a systemd service. The Caddyfile is placed at /etc/caddy/Caddyfile.
RHEL / Fedora / CentOS — use the Caddy COPR
dnf install 'dnf-command(copr)' dnf copr enable @caddy/caddy dnf install caddy
The COPR repository mirrors official Caddy releases. Same result as the Debian path: systemd service, caddy user, config at /etc/caddy/Caddyfile.
Or download the static binary directly (any Linux distro)
curl -OL "https://caddyserver.com/api/download?os=linux&arch=amd64" -o caddy chmod +x caddy sudo mv caddy /usr/local/bin/
Replace amd64 with arm64 for Raspberry Pi / ARM servers. Verify with caddy version.
Verify the installation
caddy version
Enable and start the systemd service (apt/dnf installs only)
The package installer pre-creates the service and the caddy user automatically:
sudo systemctl enable --now caddy sudo systemctl status caddy
Caddy starts immediately and will restart on reboot. Logs: journalctl -u caddy --follow
Manual binary install — create the systemd service
Download the official unit file from the Caddy distribution repo and install it:
sudo curl -o /etc/systemd/system/caddy.service \ https://raw.githubusercontent.com/caddyserver/dist/master/init/caddy.service sudo useradd --system --home /var/lib/caddy --shell /usr/sbin/nologin caddy sudo mkdir -p /etc/caddy /var/log/caddy sudo chown caddy:caddy /var/log/caddy sudo systemctl daemon-reload sudo systemctl enable --now caddy
Edit the Caddyfile and reload without downtime
sudo nano /etc/caddy/Caddyfile sudo systemctl reload caddy
systemctl reload sends SIGUSR1 to Caddy, which is equivalent to caddy reload. Zero connections are dropped. Caddy also validates the config before applying it — a syntax error will leave the old config running.
Key paths on Linux
Config: /etc/caddy/Caddyfile | TLS data: /var/lib/caddy/.local/share/caddy/ | Logs: journalctl -u caddy or /var/log/caddy/
Pull the official image
docker pull caddy:latest
The official image is on Docker Hub at hub.docker.com/_/caddy. Use caddy:alpine for a smaller image (~15 MB vs ~50 MB). Both are maintained by the Caddy project.
Run a quick test — serve the current directory
docker run -p 80:80 -p 443:443 \ -v $PWD/Caddyfile:/etc/caddy/Caddyfile \ -v caddy_data:/data \ -v caddy_config:/config \ caddy
The caddy_data named volume persists TLS certificates between container restarts. The caddy_config volume stores runtime JSON state.
Create a Caddyfile alongside docker-compose.yml
example.com {
reverse_proxy app:3000
}
Docker service names (like app) resolve automatically within a Docker network — no IP addresses needed.
Recommended docker-compose.yml
services:
caddy:
image: caddy:latest
restart: unless-stopped
ports:
- "80:80"
- "443:443"
- "443:443/udp" # HTTP/3 QUIC
volumes:
- ./Caddyfile:/etc/caddy/Caddyfile:ro
- caddy_data:/data # TLS certs and ACME state
- caddy_config:/config # Runtime config
networks:
- web
app:
image: your-app:latest
networks:
- web
networks:
web:
volumes:
caddy_data:
caddy_config:
Mount the Caddyfile as :ro (read-only) — Caddy only needs to read it at startup or reload. Never mount /data as a bind mount to a local directory unless you have correct permissions; named volumes are safer for TLS state.
Start the stack
docker compose up -d
Reload config after editing the Caddyfile
docker exec caddy caddy reload --config /etc/caddy/Caddyfile
This hot-reloads the config without restarting the container. Zero dropped connections.
View logs
docker logs caddy -f
HTTPS in Docker — what you need to know
Port access required for automatic HTTPS
For Let's Encrypt to issue a certificate, Caddy needs ports 80 and 443 reachable from the internet (HTTP-01 challenge). For local development using localhost or an internal IP, Caddy uses its built-in local CA instead. If you're behind a NAT or Cloudflare Tunnel, you can use DNS-01 challenge with a DNS provider plugin baked into a custom Caddy image.
The Caddyfile is Caddy's human-friendly configuration format. Each block starts with an address (domain or wildcard), and directives inside the block configure how Caddy handles requests for that site. These are the patterns you will use constantly.
Static file server
Serve files from a directory. encode gzip zstd enables transparent compression for text-based assets (HTML, CSS, JS, JSON). Caddy sets correct MIME types automatically.
example.com {
root * /var/www/html
file_server
encode gzip zstd
}
Reverse proxy — single upstream
Forward all requests to a backend service. Caddy handles automatic HTTPS termination — your backend only needs to listen on HTTP.
example.com {
reverse_proxy localhost:3000
}
Reverse proxy — multiple apps by subdomain
Each site block is independent. Caddy gets a separate Let's Encrypt certificate for each domain automatically.
app1.example.com {
reverse_proxy localhost:3001
}
app2.example.com {
reverse_proxy localhost:3002
}
Reverse proxy — multiple apps by path
Route different URL paths to different backends on the same domain. handle blocks are evaluated in order; the final handle is the catch-all.
example.com {
handle /api/* {
reverse_proxy localhost:8080
}
handle {
reverse_proxy localhost:3000
}
}
Basic authentication
Protect a site or path with HTTP Basic Auth. Passwords are stored as bcrypt hashes — never plaintext. Generate a hash with caddy hash-password (interactive) or docker exec -it caddy caddy hash-password.
private.example.com {
basicauth * {
alice $2a$14$...bcrypt-hash-here...
}
reverse_proxy localhost:4000
}
The * matcher applies basic auth to all paths. Use /admin/* to restrict only a subpath.
Security headers
Caddy's header directive sets, modifies, or removes response headers. The -Server syntax removes the header entirely. Adjust CSP to match your app's requirements.
example.com {
header {
Strict-Transport-Security "max-age=31536000; includeSubDomains; preload"
X-Content-Type-Options "nosniff"
X-Frame-Options "DENY"
Referrer-Policy "strict-origin-when-cross-origin"
Content-Security-Policy "default-src 'self'"
-Server
}
reverse_proxy localhost:3000
}
Access logging
Structured JSON logs with automatic log rotation. roll_size triggers rotation at 100 MB, roll_keep 5 retains 5 compressed archives, and roll_keep_for 720h deletes logs older than 30 days.
example.com {
log {
output file /var/log/caddy/access.log {
roll_size 100mb
roll_keep 5
roll_keep_for 720h
}
format json
}
reverse_proxy localhost:3000
}
Patterns that come up repeatedly in real Caddy deployments. Copy-paste and adapt to your Caddyfile.
Strip path prefix when proxying
Use handle_path instead of handle to strip the matched prefix before forwarding the request. Useful when your upstream app is designed to run at / but you're exposing it at /app/.
example.com {
handle_path /app/* {
reverse_proxy localhost:8080
}
}
A request to /app/login arrives at the backend as /login.
Health checks on upstream
Caddy can actively probe upstreams and automatically remove unhealthy backends from the rotation. If the health check fails, Caddy returns a 502 rather than forwarding to a dead backend.
example.com {
reverse_proxy localhost:3000 {
health_uri /health
health_interval 10s
health_timeout 5s
}
}
WebSocket proxying
WebSocket connections work automatically in Caddy — no special configuration needed. Caddy detects Upgrade: websocket headers and performs the connection upgrade transparently, passing it through to the upstream.
# Nothing extra needed — just proxy as usual:
example.com {
reverse_proxy localhost:3000
}
This works for Socket.IO, native WebSockets, and SSE (Server-Sent Events).
Redirect HTTP to HTTPS (manual)
For named domains, Caddy redirects HTTP to HTTPS automatically. If you need a manual redirect (e.g., you turned off auto_https):
http://example.com {
redir https://{host}{uri} permanent
}
https://example.com {
reverse_proxy localhost:3000
}
Custom TLS certificate
Use your own certificate instead of having Caddy provision one from Let's Encrypt. Useful when certificates are managed externally (e.g., by Vault, AWS ACM, or an internal CA).
example.com {
tls /path/to/cert.pem /path/to/key.pem
reverse_proxy localhost:3000
}
Caddy will still watch for cert file changes and reload them hot.
Load balancing across multiple upstreams
Caddy supports round-robin, random, least-connections, and IP-hash load balancing. Combine with health checks for automatic failover.
example.com {
reverse_proxy {
to localhost:3001 localhost:3002 localhost:3003
lb_policy least_conn
health_uri /health
health_interval 15s
}
}
Rate limiting (with plugin)
Basic rate limiting requires the caddy-ratelimit plugin baked into a custom Caddy build. For simpler setups, put Cloudflare or a WAF in front. Custom Caddy builds with plugins: caddyserver.com/download.
# With caddy-ratelimit plugin installed:
example.com {
rate_limit {remote_host} 100r/m
reverse_proxy localhost:3000
}
Global options block
The { } block at the top of a Caddyfile (before any site blocks) sets global options. Common settings: email for Let's Encrypt, disabling automatic HTTPS (useful behind Cloudflare Tunnel), and enabling HTTP/3.
{
email [email protected]
auto_https off # if TLS is handled upstream
servers {
protocol {
experimental_http3
}
}
}
example.com {
reverse_proxy localhost:3000
}
Caddy provides excellent built-in tools for validating config and diagnosing problems. Use these before applying any change in production.
Validate Caddyfile syntax
Check your Caddyfile for syntax errors without applying it. Returns a non-zero exit code on failure — safe to use in CI pipelines.
caddy validate --config /etc/caddy/Caddyfile
In Docker: docker exec caddy caddy validate --config /etc/caddy/Caddyfile
Inspect the compiled JSON config
See the internal JSON representation that Caddy actually runs. Useful for debugging directive ordering and understanding how Caddy interprets your Caddyfile.
caddy adapt --config /etc/caddy/Caddyfile \ --adapter caddyfile | jq .
Auto-reload on file change (dev mode)
Run Caddy in watch mode during development. Any change to the Caddyfile triggers an automatic reload — no manual caddy reload needed.
caddy run --watch
Caddy watches the Caddyfile in the current directory by default. Combine with --config to specify a path.
ACME challenge troubleshooting
If Caddy fails to obtain a Let's Encrypt certificate, check these common causes:
- Port 80 must be publicly reachable for HTTP-01 challenge
- DNS must point to your server's IP before Caddy starts
- If behind a reverse proxy or CDN, disable their caching for
/.well-known/acme-challenge/ - Let's Encrypt rate limits: 5 failures per hour per domain
- For DNS-01 challenge (behind NAT): use a custom Caddy build with a DNS provider plugin
Locate TLS certificate files
If you need to inspect or copy the certificates Caddy obtained:
# Linux (systemd package install) ls /var/lib/caddy/.local/share/caddy/certificates/ # macOS (Homebrew) ls "$HOME/Library/Application Support/Caddy/certificates/" # Docker (named volume) docker run --rm -v caddy_data:/data alpine ls \ /data/caddy/certificates/
View logs
Caddy logs are structured JSON by default (Zap logger). Filter and pretty-print them with jq.
# Linux systemd journalctl -u caddy --since "10 minutes ago" # Docker docker logs caddy --since 10m -f # Pretty-print JSON log line journalctl -u caddy -n 50 -o cat | jq .msg
Test reverse proxy headers
Verify that Caddy is forwarding the correct headers to your backend. curl -v shows request and response headers.
# Check response headers from Caddy curl -I https://example.com # Check what headers your backend receives # (add a /debug/headers route in your app, or use httpbin) curl https://example.com/debug/headers
Caddy automatically sets X-Forwarded-For, X-Forwarded-Proto, and X-Real-IP when reverse proxying.
Caddy API — live config inspection
Caddy's admin API (localhost:2019 by default) lets you inspect and update the running config programmatically without a reload.
# View running config curl http://localhost:2019/config/ # Reload config via API curl -X POST "http://localhost:2019/load" \ -H "Content-Type: text/caddyfile" \ --data-binary @/etc/caddy/Caddyfile
Disable the admin API in production if not needed: { admin off } in the global options block.
Permissions pitfall on Linux
Caddy runs as the caddy user (when installed via apt/dnf). Binding to ports 80 and 443 requires elevated privileges. The package installer grants this via CAP_NET_BIND_SERVICE on the binary automatically. If you installed the binary manually, run: sudo setcap cap_net_bind_service=+ep $(which caddy). Without this, Caddy will fail to start on ports below 1024.