Nginx and Caddy Reverse Proxy Config Guide
Compare Nginx proxy_pass and Caddy reverse_proxy, then choose safe headers, WebSocket handling, TLS defaults, and validation steps for a new service.
Control Plane Labs Staff
Published September 1, 2026
The Nginx and Caddy config generator is useful for comparing the two outputs before you put a virtual host in production. Treat generated configuration as a starting point: confirm the upstream address, hostname, TLS policy, and application-specific headers on the server that will run it.
What a reverse proxy is responsible for
A reverse proxy accepts a client request, selects an upstream, sends a request to that upstream, and returns the upstream response. The boundary is more than a rewritten URL: preserve the host, represent the original client address, and describe whether the client used HTTP or HTTPS. RFC 9110 defines HTTP semantics, while the Host field reference documents the hostname and optional port that identify the target server.
Nginx makes the boundary explicit with proxy_pass. Its official
ngx_http_proxy_module documentation
shows proxy_pass http://localhost:8000; alongside proxy_set_header Host $host and
proxy_set_header X-Real-IP $remote_addr. The URI on proxy_pass also changes the
mapping rule: with a URI, Nginx replaces the matching location portion; without one,
it passes the request URI in its processed form. That trailing slash is a routing
choice, not cosmetic punctuation.
Caddy uses a site address plus a reverse_proxy directive. Its
reverse_proxy documentation
shows the upstream as a simple handler and documents transport, load-balancing, and
health-check options when the basic form is not enough. Caddy also sets the usual
forwarded headers for the upstream and protects its forwarded-header behavior from
untrusted incoming values unless you configure trusted proxies. Read that section
before putting Caddy behind another proxy or load balancer.
Equivalent starting configurations
For a service listening on 127.0.0.1:3000, these examples express the same basic
intent: terminate HTTPS at the proxy and send application traffic to the
local service.
# Put the map block in the http{} context, not inside server{}.
map $http_upgrade $connection_upgrade {
default upgrade;
'' close;
}
server {
listen 443 ssl;
server_name app.example.com;
ssl_certificate /etc/letsencrypt/live/app.example.com/fullchain.pem;
ssl_certificate_key /etc/letsencrypt/live/app.example.com/privkey.pem;
location / {
proxy_pass http://127.0.0.1:3000;
proxy_set_header Host $host;
proxy_set_header X-Real-IP $remote_addr;
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
proxy_set_header X-Forwarded-Proto $scheme;
proxy_set_header Upgrade $http_upgrade;
proxy_set_header Connection $connection_upgrade;
}
}
app.example.com {
reverse_proxy 127.0.0.1:3000
}
The Nginx example follows the documented WebSocket pattern:
Nginx does not pass hop-by-hop Upgrade and Connection fields to an upstream by
default, so the example uses a map and explicit forwarding. Keep that map in the http scope. If the
application is ordinary HTTP, you can omit the upgrade lines; leaving the scope and
header behavior clear is more valuable than copying a block you do not need.
The Caddy automatic HTTPS guide describes a different default. A public hostname activates certificate management and an HTTP-to-HTTPS redirect without a certificate path. Localhost, IP addresses, and explicitly HTTP-only site addresses are exceptions. Confirm DNS and firewall reachability before expecting an ACME certificate, and use Caddy’s internal issuer for a deliberately local development certificate rather than exposing a private test service to the public CA.
Headers, client identity, and trust boundaries
Forwarded headers are inputs to the application, not proof supplied by the client.
The MDN reference for X-Forwarded-For
notes that the field is not a standardized security credential and can contain a
comma-separated chain of addresses. An application that uses the first or last
address without understanding every proxy in the path can log the wrong client or
make an unsafe access decision.
If Nginx is the only public proxy, $proxy_add_x_forwarded_for appends the address
already in the chain to the address Nginx sees. If a trusted load balancer is in
front, define which hop is authoritative and configure the application or proxy to
trust only that hop. Do not turn an arbitrary client-supplied header into an allowlist
identity. The same rule applies to X-Forwarded-Proto: use it to reconstruct the
original scheme only after the proxy chain is known.
Caddy’s reverse proxy handler manages the common forwarded headers, but its docs still require a trusted-proxy configuration when another proxy supplies them. This is a good default for a one-proxy deployment, not a reason to skip the trust-boundary review in a CDN, ingress, or multi-hop topology.
The HTTP header inspector verifies public response headers, but not how the application interpreted the chain. Pair it with an application log entry and a request through each proxy hop.
TLS and validation differences
Nginx gives you precise control over certificate files, protocol settings, redirects, and renewal hooks, but those choices often span several directives and an ACME client. Check that the worker can read the private key and that renewal reloads Nginx after replacing it; a successful renewal is not the same as a live worker using that certificate.
Caddy bundles certificate acquisition and renewal for qualifying names. That reduces configuration, but it does not remove operational checks. Verify that ports 80 and 443 reach the Caddy instance, that DNS points to the right host, and that persistent storage is available for Caddy’s managed state. For either server, the TLS certificate inspector can check the certificate served after deployment, and the DNS lookup can confirm the records before you investigate an ACME failure.
Run a syntax or configuration check before a reload. With Nginx, a rate-limit zone
such as limit_req_zone is an http-context directive; the
ngx_http_limit_req_module reference
shows the directive’s context and explains that it controls request processing by a
key such as a client address. Putting it inside server {} produces a configuration
error rather than a working limit. With Caddy, run caddy validate against the
Caddyfile and keep optional third-party directives commented until the binary really
contains the module.
A practical three-step rollout
- Generate, then inspect. Put the hostname and upstream into the config generator. Compare the URI path, public listeners, forwarded headers, WebSocket behavior, and TLS assumptions with the service’s runbook.
- Validate on the target host. Run
nginx -torcaddy validate, check the certificate and key permissions, and test the upstream locally. Reload only after the validator succeeds. Keep the last known-good configuration for rollback. - Test from outside. Request the public hostname with a normal HTTP client, a WebSocket client if the service needs one, and a deliberately missing path. Confirm the scheme redirect, host routing, status code, forwarded scheme, and application logs. Use the curl command builder to make the request repeatable.
Choose Nginx for directive-level controls or an existing Nginx fleet. Choose Caddy when automatic HTTPS and a compact configuration reduce mistakes. Neither choice is a substitute for testing the proxy boundary that your application actually trusts.
FAQ
Frequently asked questions
Is Caddy faster than Nginx as a reverse proxy?+
Do I need to configure WebSocket headers in Caddy?+
Why does an Nginx proxy_pass trailing slash matter?+
Does automatic HTTPS work for an IP address in Caddy?+
Can I trust X-Forwarded-For for authorization?+
Where should Nginx limit_req_zone go?+
Read next
- Try the Nginx and Caddy config generator for side-by-side output.
- Verify the deployed response with the HTTP header inspector.
- Check the certificate with the TLS certificate inspector.
- Read the HTTP headers cheatsheet for related fields.
Tags: #nginx, #caddy, #reverse-proxy, #tls, #web-development