Caddy¶
A deep dive into Caddy — how it works, its configuration model, and the full range of directives available for reverse proxying, routing, SSL, and more.
What is Caddy¶
Caddy is a web server and reverse proxy written in Go. It is Sartiq's reverse proxy, replacing Nginx Proxy Manager (NPM).
Why Caddy over NPM¶
| Caddy | NPM | |
|---|---|---|
| Config format | Plain text file (Caddyfile) | SQLite database (web UI) |
| Version control | Committed to git, PR-reviewable | Not versionable |
| SSL certificates | Automatic, zero config | Automatic, via UI |
| WebSocket proxy | Automatic | Requires manual header config |
| Recovery | Redeploy from repo | Restore database backup |
Why Caddy over Nginx¶
| Caddy | Nginx | |
|---|---|---|
| SSL setup | Automatic (Let's Encrypt) | Manual (certbot + cron) |
| Config verbosity | ~3 lines per route | ~10 lines per route |
| WebSocket | Automatic | Manual proxy_set_header |
| Hot reload | Automatic on config change | nginx -s reload |
| Health checks | Built-in | Requires nginx-plus |
How Caddy Works¶
Automatic HTTPS¶
When Caddy sees a domain name in the Caddyfile, it automatically:
- Obtains a TLS certificate from Let's Encrypt (or ZeroSSL)
- Redirects HTTP → HTTPS
- Renews certificates before expiry
No tls, ssl_certificate, or certbot configuration needed. This happens by default for any public domain.
Request Flow¶
Client → DNS → Caddy (:443)
│
├── TLS termination (automatic cert)
├── Match host + path
└── Forward to upstream
Configuration Model¶
Caddy uses a Caddyfile — a human-readable config format. Each block defines a site:
A site address can be:
- A domain: api.sartiq.com
- A domain with port: api.sartiq.com:8443
- Just a port: :8080 (all interfaces)
- localhost (generates self-signed cert)
Directives Reference¶
reverse_proxy¶
The core directive. Forwards requests to a backend.
# Basic — proxy everything
api.sartiq.com {
reverse_proxy localhost:8000
}
# Path-specific
api.sartiq.com {
reverse_proxy /api/* localhost:8000
reverse_proxy /compute/* localhost:9000
}
# Multiple upstreams (load balancing)
api.sartiq.com {
reverse_proxy node1:8000 node2:8000 {
lb_policy round_robin
}
}
# With health checks
api.sartiq.com {
reverse_proxy localhost:8000 {
health_uri /health
health_interval 10s
health_timeout 5s
}
}
# With custom headers to upstream
api.sartiq.com {
reverse_proxy localhost:8000 {
header_up X-Real-IP {remote_host}
header_up X-Forwarded-Proto {scheme}
}
}
handle and handle_path¶
Route requests by path. handle_path strips the matched prefix before forwarding.
api.sartiq.com {
# /app/dashboard → backend receives /app/dashboard
handle /app/* {
reverse_proxy localhost:3000
}
# /api/v2/users → backend receives /users (prefix stripped)
handle_path /api/v2/* {
reverse_proxy localhost:8001
}
# Everything else
handle {
reverse_proxy localhost:8000
}
}
handle blocks are mutually exclusive — only the first matching block executes (like nginx location with break).
rewrite¶
Internally rewrite the URI before processing. The client doesn't see the change.
api.sartiq.com {
rewrite /old-endpoint /new-endpoint
reverse_proxy localhost:8000
}
# Regex rewrite
api.sartiq.com {
@legacy path_regexp legacy ^/v1/(.*)$
rewrite @legacy /v2/{re.legacy.1}
reverse_proxy localhost:8000
}
redir¶
Send an HTTP redirect to the client. Unlike rewrite, the client sees the new URL.
# Permanent redirect (301)
old.sartiq.com {
redir https://api.sartiq.com{uri} permanent
}
# Temporary redirect (302)
api.sartiq.com {
redir /old-page /new-page temporary
}
# Redirect with path matching
api.sartiq.com {
redir /docs /docs/ 301
}
header¶
Modify response headers.
api.sartiq.com {
# Set a header
header Cache-Control "public, max-age=3600"
# Add a header (doesn't replace existing)
header +X-Custom-Header "value"
# Remove a header
header -Server
# Set only if not already present
header ?X-Frame-Options "DENY"
# Multiple headers
header {
Cache-Control "no-store"
X-Content-Type-Options "nosniff"
-Server
}
reverse_proxy localhost:8000
}
encode¶
Enable compression (gzip, zstd).
api.sartiq.com {
encode gzip
reverse_proxy localhost:8000
}
# Both zstd and gzip (zstd preferred when client supports it)
api.sartiq.com {
encode zstd gzip
reverse_proxy localhost:8000
}
basic_auth¶
Protect routes with HTTP basic authentication.
admin.sartiq.com {
basic_auth {
admin $2a$14$Zkx19XLiW... # bcrypt hash
}
reverse_proxy localhost:8000
}
# Only protect a specific path
api.sartiq.com {
basic_auth /admin/* {
admin $2a$14$Zkx19XLiW...
}
reverse_proxy localhost:8000
}
Generate a password hash:
respond¶
Return a static response without proxying. Useful for health checks and blocking.
api.sartiq.com {
# Health check endpoint
respond /health 200
# Block a path
respond /blocked "Access denied" 403
reverse_proxy localhost:8000
}
log¶
Configure access logging.
api.sartiq.com {
log {
output stdout
format json
level INFO
}
reverse_proxy localhost:8000
}
# Log to a file
api.sartiq.com {
log {
output file /var/log/caddy/access.log {
roll_size 100mb
roll_keep 5
}
}
reverse_proxy localhost:8000
}
TLS Configuration¶
Default (automatic)¶
Just use a domain name — Caddy handles everything:
Set ACME email (recommended)¶
Let's Encrypt sends expiry warnings to this email:
Self-signed (local development)¶
Caddy generates a self-signed certificate for localhost automatically.
Internal CA (no public domain)¶
For services without public DNS:
Custom certificate files¶
If you have your own cert (rare — prefer automatic):
Disable HTTPS (HTTP only)¶
For internal services behind another proxy:
Using a port without a domain skips automatic HTTPS.
Matchers¶
Matchers let you apply directives conditionally. They are the equivalent of nginx location blocks and if conditions.
Path matcher (inline)¶
Named matchers¶
@api path /api/*
@static path /static/* /media/*
@websocket header Connection *Upgrade*
reverse_proxy @api localhost:8000
file_server @static
reverse_proxy @websocket localhost:8000
Common matcher types¶
| Matcher | Syntax | Matches |
|---|---|---|
| Path | path /api/* |
Request path |
| Path regex | path_regexp ^/v[0-9]+/ |
Path by regex |
| Header | header Content-Type application/json |
Request header value |
| Method | method GET POST |
HTTP method |
| Remote IP | remote_ip 10.0.0.0/8 |
Client IP range |
| Query | query key=value |
Query string parameter |
| Not | not path /health |
Negate any matcher |
Combining matchers¶
# Must match ALL conditions (AND)
@admin {
path /admin/*
remote_ip 10.0.0.0/8
}
reverse_proxy @admin localhost:8000
Global Options¶
Set at the top of the Caddyfile, outside any site block:
{
# ACME email for Let's Encrypt
email devops@sartiq.com
# Use staging Let's Encrypt (for testing, avoids rate limits)
acme_ca https://acme-staging-v02.api.letsencrypt.org/directory
# Disable automatic HTTPS redirect
auto_https disable_redirects
# Custom HTTP and HTTPS ports
http_port 8080
https_port 8443
# Admin API (disable in production)
admin off
}
Docker Usage¶
docker-compose.yml¶
services:
caddy:
image: caddy:2-alpine
restart: unless-stopped
ports:
- "80:80"
- "443:443"
volumes:
- ./Caddyfile:/etc/caddy/Caddyfile
- caddy-data:/data # certificates
- caddy-config:/config
volumes:
caddy-data:
caddy-config:
Useful commands¶
# Validate config
docker compose exec caddy caddy validate --config /etc/caddy/Caddyfile
# Reload config (no downtime)
docker compose exec caddy caddy reload --config /etc/caddy/Caddyfile
# View loaded config as JSON
docker compose exec caddy caddy adapt --config /etc/caddy/Caddyfile
# View certificates
docker compose exec caddy caddy list-modules | grep tls
Related Documentation¶
- Reverse Proxy — How to add proxy entries at Sartiq
- Infrastructure Overview — Server fleet and architecture
- Caddy official docs