| |

LFCE 58 🐧 Reverse Proxy with Caddy

⏱️ Estimated read: 12 min | |

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

DirectivePurposeExample
reverse_proxyForward requests to a backendreverse_proxy localhost:3000
handle_pathStrip prefix before proxyinghandle_path /api/* { ... }
handlePreserve prefix before proxyinghandle /api/* { ... }
header_upModify request headersheader_up Host {upstream_hostport}
header_downModify response headersheader_down +X-Custom "value"

Load Balancing Options

OptionPurpose
lb_policySelection policy (random, round_robin, least_conn, ip_hash, first)
lb_try_durationHow long to try finding an available backend
lb_retriesMaximum retries
fail_durationHow long a backend is unhealthy after failure
max_failsFailures before marking unhealthy
health_uriPath for active health check
health_intervalInterval for active health check

Forwarding Headers (Automatic)

HeaderCaddy Behavior
HostPassed through unchanged
X-Forwarded-ForSet automatically
X-Forwarded-ProtoSet automatically
X-Forwarded-HostSet 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

PitfallWhy It HappensFix
Backend receives wrong pathUsed handle instead of handle_pathUse handle_path to strip the prefix
Forwarding headers missingManually overrode themLet Caddy set them; use trusted_proxies if behind a CDN
Uneven load distributionDefault random policyUse lb_policy round_robin or least_conn
Failed backend stays in rotationNo health checks configuredAdd fail_duration and max_fails
Host header mismatch on HTTPS backendCaddy overrides it automatically since v2.11.0Use X-Forwarded-Host in the backend
Configuration not appliedValidation failedcaddy 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

ItemValue
Core directivereverse_proxy <backend>
Path routing (strip)handle_path /prefix/* { ... }
Path routing (preserve)handle /prefix/* { ... }
Host headerPassed through by default
X-Forwarded-*Set automatically
Default load-balancing policyRandom
Alternative policiesround_robin, least_conn, ip_hash, first
Passive health checksfail_duration, max_fails
Active health checkshealth_uri, health_interval
Validationcaddy validate --config /etc/caddy/Caddyfile
Reloadsystemctl reload caddy

Key takeaways:

  • Caddy sets the forwarding headers automatically. The Host header is passed through, and X-Forwarded-For, X-Forwarded-Proto, and X-Forwarded-Host are set without any header_up directives. This is the opposite of Nginx’s default behavior .
  • handle_path strips the prefix; handle preserves it. The choice depends on whether the backend expects the prefix in the request path. Most applications that are not designed for a subpath require handle_path .
  • The default load-balancing policy is random, not round-robin. The lb_policy directive selects round_robin, least_conn, ip_hash, or first when a different distribution is needed .
  • Health checks are configured with fail_duration and max_fails. Passive checks monitor real requests; active checks probe a health_uri on a timer. Both remove failed backends from rotation .
  • HTTPS backends require the Host header override. Caddy does this automatically since v2.11.0, and the original hostname is available in X-Forwarded-Host .
  • Validate before reloading. caddy validate catches 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.


Was this helpful?


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!