LFCE 58 🐧 Reverse Proxy with Caddy
Caddy’s reverse proxy is configured with a single directive: reverse_proxy. Where Nginx requires an upstream block, a location block, and a set of proxy_set_header directives, Caddy requires the backend address and, optionally, a block of modifiers. The forwarding headers that Nginx requires explicitly are set by default: Caddy passes the incoming Host header to the backend, sets X-Forwarded-For, X-Forwarded-Proto, and X-Forwarded-Host, and ignores the client’s values for those headers to prevent spoofing . The previous chapter covered the Nginx implementation; this chapter covers the Caddy equivalent, including the handle_path and handle directives for path-based routing, the load-balancing options, and the health-check mechanisms.
Key point: Caddy’s reverse_proxy directive sets the X-Forwarded-* headers automatically, so the backend receives the client’s IP and the original scheme without any header_up directives. The Host header is also passed through by default, which is the opposite of Nginx’s default behavior. When proxying to an HTTPS backend, Caddy automatically overrides the Host header with the upstream address since v2.11.0, and the X-Forwarded-Host header preserves the original value .
Why Caddy’s proxy defaults differ from Nginx
The automatic-forwarding problem.
Nginx sends the backend’s own hostname in the Host header unless proxy_set_header Host $host; is configured, and it does not add X-Forwarded-For or X-Forwarded-Proto at all. Caddy does the opposite: it passes the incoming Host through unchanged and sets the X-Forwarded-* headers automatically . The backend therefore sees the client’s hostname and the original protocol without any explicit configuration. The only exception is when Caddy is behind another proxy or CDN; in that case, the trusted_proxies global option tells Caddy which incoming IPs are allowed to set those headers, preventing spoofing .
The path-routing problem.
Caddy provides two directives for path-based routing: handle_path strips the matched prefix before forwarding, and handle preserves it. The choice depends on whether the backend expects to receive the prefix in the request path. Most applications that are not designed for a subpath require handle_path, because their generated links and redirects do not include the prefix . If the application has a setting for its base path, it can be configured to expect the prefix, and handle is used instead.
The health-check problem.
Caddy supports both active and passive health checks. Active health checks run in the background on a timer and probe a configured path or port. Passive health checks monitor real requests and mark a backend as unhealthy after a threshold of failures. The passive check is configured with fail_duration and max_fails; the active check is configured with a health_uri or health_port . The default load-balancing policy is random selection, not round-robin, which is a difference from Nginx’s default .
a. The basic reverse proxy configuration
The simplest proxy configuration forwards all requests to a single backend. The reverse_proxy directive accepts the backend address as its argument.
# /etc/caddy/Caddyfile
example.com {
reverse_proxy localhost:3000
}
The reverse_proxy localhost:3000 directive forwards every request for example.com to the backend on port 3000. Caddy terminates TLS automatically, sets the X-Forwarded-For, X-Forwarded-Proto, and X-Forwarded-Host headers, and passes the Host header through unchanged . The backend receives the client’s IP in X-Forwarded-For and the original scheme in X-Forwarded-Proto without any additional directives.
When proxying to an HTTPS backend, Caddy overrides the Host header with the upstream’s hostname to match the TLS ServerName. The X-Forwarded-Host header still carries the original value, so the backend can use it if it needs the client’s hostname .
example.com {
reverse_proxy https://backend.internal:8443
}
The https:// scheme in the backend address tells Caddy to use TLS for the upstream connection. The Host header is set to backend.internal:8443 automatically, and the original hostname is available in X-Forwarded-Host.
b. Path-based routing with handle and handle_path
The handle_path directive strips the matched prefix before forwarding, and the handle directive preserves it. Both directives group the handlers that apply to the matched requests.
example.com {
handle_path /api/* {
reverse_proxy localhost:5000
}
handle_path /app/* {
reverse_proxy localhost:5001
}
handle {
root * /var/www
file_server
}
}
The handle_path /api/* block strips /api from the request before forwarding to the backend on port 5000. The handle_path /app/* block does the same for the backend on port 5001. The final handle block catches every request that does not match the earlier prefixes and serves static files from /var/www .
The handle_path directive is the correct choice when the backend does not expect the prefix. The documentation warns that most applications do not work in a subfolder unless they are configured for it, because the links and redirects they generate do not include the prefix. If the application has a base-path setting, it can be configured to expect the prefix, and handle is used instead .
c. Load balancing and health checks
Caddy’s reverse proxy supports multiple upstreams and several load-balancing policies. The default policy is random selection, but round_robin, least_conn, ip_hash, and first are available.
example.com {
reverse_proxy localhost:3000 localhost:3001 localhost:3002 {
lb_policy least_conn
lb_try_duration 5s
lb_retries 2
}
}
The lb_policy least_conn selects the backend with the fewest active connections. The lb_try_duration 5s tells Caddy to keep trying to find an available backend for up to five seconds. The lb_retries 2 limits the number of retries . The lb_try_duration and lb_retries options are useful when a backend goes down; without them, the request fails immediately if the selected backend is unavailable.
Passive health checks are configured with fail_duration and max_fails. The fail_duration is how long a backend is considered unhealthy after a failure, and max_fails is the number of failures that trigger the unhealthy state.
example.com {
reverse_proxy localhost:3000 localhost:3001 {
fail_duration 30s
max_fails 3
}
}
The backend is marked unhealthy after three failures and remains unhealthy for thirty seconds. During that time, it is not selected for new requests. After the duration expires, it is retried .
Active health checks probe a configured endpoint on a timer. The health_uri and health_interval options configure the path and the frequency.
example.com {
reverse_proxy localhost:3000 localhost:3001 {
health_uri /health
health_interval 10s
}
}
The health_uri /health tells Caddy to request /health on each backend every ten seconds. A backend that fails the check is removed from rotation until it passes again . Active health checks are more reliable than passive checks because they detect a failed backend before a client request is routed to it.
Complete Example Session
# ============================================
# PART 1: START A TEST BACKEND
# ============================================
python3 -m http.server 3000 --directory /var/www/backend &
# ============================================
# PART 2: CONFIGURE THE PROXY
# ============================================
sudo tee /etc/caddy/Caddyfile > /dev/null <<'EOF'
example.com {
reverse_proxy localhost:3000
}
EOF
# ============================================
# PART 3: VALIDATE AND RELOAD
# ============================================
sudo caddy validate --config /etc/caddy/Caddyfile
sudo systemctl reload caddy
# ============================================
# PART 4: TEST THE PROXY
# ============================================
curl -H "Host: example.com" http://localhost
# Output: the content from the backend
# ============================================
# PART 5: VERIFY THE FORWARDING HEADERS
# ============================================
# The backend receives:
# Host: example.com (passed through)
# X-Forwarded-For: <client-ip>
# X-Forwarded-Proto: http
# X-Forwarded-Host: example.com
# ============================================
# PART 6: ADD PATH-BASED ROUTING
# ============================================
python3 -m http.server 3001 --directory /var/www/api &
sudo tee /etc/caddy/Caddyfile > /dev/null <<'EOF'
example.com {
handle_path /api/* {
reverse_proxy localhost:3001
}
handle {
reverse_proxy localhost:3000
}
}
EOF
# ============================================
# PART 7: VALIDATE AND RELOAD
# ============================================
sudo caddy validate --config /etc/caddy/Caddyfile
sudo systemctl reload caddy
# ============================================
# PART 8: TEST THE PATH ROUTING
# ============================================
curl -H "Host: example.com" http://localhost/api/
# Output: the content from the API backend
curl -H "Host: example.com" http://localhost/
# Output: the content from the main backend
# ============================================
# PART 9: ADD LOAD BALANCING AND HEALTH CHECKS
# ============================================
python3 -m http.server 3002 --directory /var/www/backend2 &
sudo tee /etc/caddy/Caddyfile > /dev/null <<'EOF'
example.com {
reverse_proxy localhost:3000 localhost:3002 {
lb_policy least_conn
fail_duration 30s
max_fails 3
health_uri /
health_interval 10s
}
}
EOF
sudo caddy validate --config /etc/caddy/Caddyfile
sudo systemctl reload caddy
# ============================================
# PART 10: CLEAN UP
# ============================================
kill %1 %2 %3
The ten parts demonstrate the Caddy reverse proxy configuration: a single backend, validation, header verification, path-based routing with handle_path, and load balancing with health checks. The critical distinction from Nginx is Part 5: the forwarding headers are set automatically, and the Host header is passed through without any header_up directive.
Quick Reference
Reverse Proxy Directives
| Directive | Purpose | Example |
|---|---|---|
reverse_proxy | Forward requests to a backend | reverse_proxy localhost:3000 |
handle_path | Strip prefix before proxying | handle_path /api/* { ... } |
handle | Preserve prefix before proxying | handle /api/* { ... } |
header_up | Modify request headers | header_up Host {upstream_hostport} |
header_down | Modify response headers | header_down +X-Custom "value" |
Load Balancing Options
| Option | Purpose |
|---|---|
lb_policy | Selection policy (random, round_robin, least_conn, ip_hash, first) |
lb_try_duration | How long to try finding an available backend |
lb_retries | Maximum retries |
fail_duration | How long a backend is unhealthy after failure |
max_fails | Failures before marking unhealthy |
health_uri | Path for active health check |
health_interval | Interval for active health check |
Forwarding Headers (Automatic)
| Header | Caddy Behavior |
|---|---|
Host | Passed through unchanged |
X-Forwarded-For | Set automatically |
X-Forwarded-Proto | Set automatically |
X-Forwarded-Host | Set automatically |
Best Practices
✅ Do This:
# Use reverse_proxy with the backend address
reverse_proxy localhost:3000 # ✅
# Use handle_path to strip prefixes
handle_path /api/* {
reverse_proxy localhost:5000
} # ✅
# Configure health checks for multiple backends
reverse_proxy localhost:3000 localhost:3001 {
fail_duration 30s
max_fails 3
} # ✅
# Use lb_policy for load distribution
reverse_proxy localhost:3000 localhost:3001 {
lb_policy least_conn
} # ✅
# Validate before reloading
caddy validate --config /etc/caddy/Caddyfile # ✅
❌ Don’t Do This:
# Set X-Forwarded-* headers manually
header_up X-Forwarded-For {remote_host} # Caddy sets this automatically # ❌
# Use handle when the backend does not expect the prefix
handle /api/* {
reverse_proxy localhost:5000 # Backend receives /api/... # ❌
} # ❌
# Omit health checks for multiple backends
reverse_proxy localhost:3000 localhost:3001 # ❌
# Assume round-robin is the default
# Caddy's default is random selection # ❌
# Forget to validate
systemctl reload caddy # Syntax error prevents reload # ❌
Common Pitfalls
| Pitfall | Why It Happens | Fix |
|---|---|---|
| Backend receives wrong path | Used handle instead of handle_path | Use handle_path to strip the prefix |
| Forwarding headers missing | Manually overrode them | Let Caddy set them; use trusted_proxies if behind a CDN |
| Uneven load distribution | Default random policy | Use lb_policy round_robin or least_conn |
| Failed backend stays in rotation | No health checks configured | Add fail_duration and max_fails |
Host header mismatch on HTTPS backend | Caddy overrides it automatically since v2.11.0 | Use X-Forwarded-Host in the backend |
| Configuration not applied | Validation failed | caddy validate reports the error |
Real-World Examples
1. Basic reverse proxy
example.com { reverse_proxy localhost:3000 }
2. Path-based routing with handle_path
example.com {
handle_path /api/* { reverse_proxy localhost:5000 }
handle { reverse_proxy localhost:3000 }
}
3. Load balancing with least_conn
example.com {
reverse_proxy localhost:3000 localhost:3001 {
lb_policy least_conn
}
}
4. Passive health checks
example.com {
reverse_proxy localhost:3000 localhost:3001 {
fail_duration 30s
max_fails 3
}
}
5. Active health checks
example.com {
reverse_proxy localhost:3000 localhost:3001 {
health_uri /health
health_interval 10s
}
}
6. HTTPS backend
example.com { reverse_proxy https://backend.internal:8443 }
7. Proxy to a Unix socket
example.com { reverse_proxy unix//run/app.sock }
8. Validate configuration
caddy validate --config /etc/caddy/Caddyfile
9. Reload Caddy
systemctl reload caddy
10. Test the proxy
curl -H "Host: example.com" http://localhost
Visual
Caddy Reverse Proxy Flow
┌──────────────────────────────────────────────────────────────┐
│ CLIENT → CADDY → BACKEND │
│ │
│ Client │
│ ┌─────────┐ │
│ │ Browser │ │
│ └────┬────┘ │
│ │ HTTPS │
│ ▼ │
│ ┌────────────────────────────────────────────────────────┐ │
│ │ Caddy │ │
│ │ ├── terminates TLS (automatic HTTPS) │ │
│ │ ├── passes Host through │ │
│ │ ├── sets X-Forwarded-For/Proto/Host │ │
│ │ └── reverse_proxy to the backend │ │
│ └──────────────────────────┬─────────────────────────────┘ │
│ │ HTTP (internal) │
│ ▼ │
│ ┌────────────────────────────────────────────────────────┐ │
│ │ Backend │ │
│ │ ├── Host: example.com (original) │ │
│ │ ├── X-Forwarded-For: <client-ip> │ │
│ │ └── X-Forwarded-Proto: https │ │
│ └────────────────────────────────────────────────────────┘ │
└──────────────────────────────────────────────────────────────┘
handle vs handle_path
┌──────────────────────────────────────────────────────────────┐
│ PREFIX PRESERVATION │
│ │
│ handle_path /api/* { │
│ reverse_proxy localhost:5000 │
│ } │
│ Request: /api/users → backend receives /users │
│ (prefix stripped) │
│ │
│ handle /api/* { │
│ reverse_proxy localhost:5000 │
│ } │
│ Request: /api/users → backend receives /api/users │
│ (prefix preserved) │
└──────────────────────────────────────────────────────────────┘
Default Headers
┌──────────────────────────────────────────────────────────────┐
│ CADDY SETS X-Forwarded-* AUTOMATICALLY │
│ │
│ Incoming request: │
│ ┌────────────────────────────────────────────────────────┐ │
│ │ Host: example.com │ │
│ │ (from 203.0.113.5) │ │
│ └────────────────────────────────────────────────────────┘ │
│ │ │
│ ▼ │
│ reverse_proxy localhost:3000 │
│ │ │
│ ▼ │
│ Backend receives: │
│ ┌────────────────────────────────────────────────────────┐ │
│ │ Host: example.com ← passed through │ │
│ │ X-Forwarded-For: 203.0.113.5 ← set automatically │ │
│ │ X-Forwarded-Proto: https ← set automatically │ │
│ │ X-Forwarded-Host: example.com ← set automatically │ │
│ └────────────────────────────────────────────────────────┘ │
│ │
│ No header_up directives required. │
└──────────────────────────────────────────────────────────────┘
Summary
| Item | Value |
|---|---|
| Core directive | reverse_proxy <backend> |
| Path routing (strip) | handle_path /prefix/* { ... } |
| Path routing (preserve) | handle /prefix/* { ... } |
Host header | Passed through by default |
X-Forwarded-* | Set automatically |
| Default load-balancing policy | Random |
| Alternative policies | round_robin, least_conn, ip_hash, first |
| Passive health checks | fail_duration, max_fails |
| Active health checks | health_uri, health_interval |
| Validation | caddy validate --config /etc/caddy/Caddyfile |
| Reload | systemctl reload caddy |
Key takeaways:
- Caddy sets the forwarding headers automatically. The
Hostheader is passed through, andX-Forwarded-For,X-Forwarded-Proto, andX-Forwarded-Hostare set without anyheader_updirectives. This is the opposite of Nginx’s default behavior . handle_pathstrips the prefix;handlepreserves it. The choice depends on whether the backend expects the prefix in the request path. Most applications that are not designed for a subpath requirehandle_path.- The default load-balancing policy is random, not round-robin. The
lb_policydirective selectsround_robin,least_conn,ip_hash, orfirstwhen a different distribution is needed . - Health checks are configured with
fail_durationandmax_fails. Passive checks monitor real requests; active checks probe ahealth_urion a timer. Both remove failed backends from rotation . - HTTPS backends require the
Hostheader override. Caddy does this automatically since v2.11.0, and the original hostname is available inX-Forwarded-Host. - Validate before reloading.
caddy validatecatches syntax errors before the configuration is applied.
Remember: Caddy’s reverse proxy is configured with a single directive and a set of optional modifiers. The forwarding headers that Nginx requires explicitly are set by default, and the Host header is passed through unchanged. The handle_path directive strips the prefix for backends that do not expect it, and the handle directive preserves it for backends that do. The load-balancing policy defaults to random, and the health checks remove failed backends from rotation. Validate with caddy validate, reload with systemctl reload caddy, and test with curl using the correct Host header.
Stop using slow, ad-bloated tool sites! 🤮
🔎 Search “KandZ Tools” on Google to use many professional utilities for free.
KandZ.me is the ultimate minimalist hub for:
✅ Finance (Mortgage, Interest, Inflation)
✅ Tech (Base64, JSON, Dev Suite, IP)
✅ Health (BMI, BMR, TDEE)
✅ Productivity (Timer, Workspace, QR)
⚡️ Fast & Private
🔒 No data leaves your device
💎 100% Free
🔗 Use it now: https://tools.kandz.me
🔖 Bookmark it—you’ll need it later!