Skip to content
Control Plane Labs

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

  1. 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.
  2. Validate on the target host. Run nginx -t or caddy 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.
  3. 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?+
There is no universal answer. Workload, TLS, buffering, modules, hardware, and configuration all matter. Benchmark the traffic and failure behavior you operate.
Do I need to configure WebSocket headers in Caddy?+
Caddy's `reverse_proxy` supports WebSocket upgrades, so the basic Caddyfile needs no Nginx-style map. Confirm that the application and any proxy in front of Caddy permit upgrades.
Why does an Nginx proxy_pass trailing slash matter?+
A URI in `proxy_pass` replaces the matching normalized location. Without one, Nginx passes the processed request URI. Test a root and nested path.
Does automatic HTTPS work for an IP address in Caddy?+
Automatic HTTPS is for qualifying domain names. Localhost and IP addresses are exceptions; use a local issuer or configure certificates explicitly.
Can I trust X-Forwarded-For for authorization?+
Only after defining and enforcing the trusted proxy chain. Clients can send it, and proxies append values. Configure the authoritative hop and ignore untrusted values for access control.
Where should Nginx limit_req_zone go?+
`limit_req_zone` belongs in `http`. Apply the zone with `limit_req` in `server` or `location`; keeping its definition outside server blocks avoids validation errors.

Tags: #nginx, #caddy, #reverse-proxy, #tls, #web-development