Guide

Reverse Proxy an App with Caddy

Part 3 of 5By Amith Kumar8 min read
Part 3 of 5Caddy: the Auto-HTTPS Web Server, A Hands-On Guide
  1. 1Install Caddy and the Caddyfile Model
  2. 2Serve a Static Site with Caddy's Automatic HTTPS
  3. 3Reverse Proxy an App with Caddy
  4. 4Serve PHP with Caddy and php-fpm
  5. 5Production Caddy: Headers, Logging and Rate Limiting
Verified 24 Jul 2026 · Ubuntu 24.04 LTS

Put a Caddy reverse proxy in front of an app

Most real sites are not files on disk; they are an application listening on a local port. A Caddy reverse proxy is the front door for that app: it terminates TLS, forwards the request, sets the headers the app needs to know who called, and can spread traffic across more than one copy. This chapter proxies a small tide-reading API on Ubuntu 24.04, adds the forwarded headers, passes a websocket straight through, and load balances two backends with a health check that drops a bad one.

Key takeaways
  • The reverse_proxy directive names one or more backends and does the rest, keeping HTTPS in front while the app speaks plain HTTP behind.
  • Caddy sets the X-Forwarded-For, X-Forwarded-Proto and X-Forwarded-Host headers so the backend sees the real client and scheme.
  • A websocket upgrade is proxied with no extra config, because Caddy carries the connection upgrade through by default.
  • Listing two backends turns on load balancing, and an active health check takes an unhealthy one out of rotation on its own.
Set the stage
  • The running Caddy service and its site block from the earlier chapters, still serving over HTTPS.
  • An application, or in this walkthrough two small instances, listening on local ports the proxy can reach.
  • The backends bound to loopback only, so nothing but Caddy can talk to them and the public path always crosses the proxy.
  • A second terminal is handy for watching a backend stop and rejoin while requests keep flowing.

Configure a Caddy reverse proxy

Behind the proxy in this walkthrough are two identical instances of a tide API, one calling itself blue and the other green, each answering on a loopback port. Putting Caddy in front of them is a single directive with the upstream address.

api.tidepool.example.com {
    reverse_proxy 127.0.0.1:9001
}

That is the whole thing for one backend: HTTPS on the front from the automatic certificate, plain HTTP to the app behind, and the request passed through. Caddy also rewrites the hop-by-hop details and adds the forwarding headers an app relies on, so the backend can tell the difference between a direct hit and one that came through a proxy. Reload and read what the app receives.

curl -s https://api.tidepool.example.com:8443/tides/today
The backend echoing the forwarding headers Caddy set on the proxied request: X-Forwarded-For carrying the client address, X-Forwarded-Proto reading https even though the app was reached over plain HTTP, and X-Forwarded-Host holding the public hostname

The backend echoes the headers Caddy set. X-Forwarded-For carries the client's address, X-Forwarded-Proto reads https even though the app itself was reached over plain HTTP, and X-Forwarded-Host holds the public hostname. An app that builds absolute URLs or logs client addresses needs these to be correct, and Caddy sets them without being asked.

Checkpoint

The proxied response should show proto as https and a client address in the forwarded field. If the app instead sees http or the proxy's own loopback as the client, it is reading the wrong header, so point its trusted-proxy setting at Caddy so it honours X-Forwarded-For and X-Forwarded-Proto.

Proxy a websocket without special config

Real-time features ride on websockets, and a proxy that mishandles the upgrade breaks them. Caddy carries the upgrade through by default, so a websocket needs no directive of its own. The tide API accepts an upgrade on /live; ask for one and read the response.

curl -si --http1.1 -H "Connection: Upgrade" -H "Upgrade: websocket" \
  -H "Sec-WebSocket-Key: x3JJHMbDL1EzLkh9GBhXDw==" \
  -H "Sec-WebSocket-Version: 13" \
  https://api.tidepool.example.com:8443/live
A websocket upgrade proxied straight through Caddy with no special config, the reply reading 101 Switching Protocols with the Upgrade header and the computed Sec-WebSocket-Accept handed back from the backend, so the persistent connection is open both ways

The reply is 101 Switching Protocols, with Upgrade: websocket and the computed Sec-WebSocket-Accept handed back from the backend through Caddy untouched. That 101 is the handshake completing across the proxy, which means the persistent connection is open and frames flow both ways. The request goes over HTTP/1.1 here because the classic upgrade header belongs to that version; a browser's websocket client does the same negotiation automatically.

Load balance across two backends

One line scales from a single backend to several. List more than one address and Caddy balances across them, which lets you run two copies of the app and take one down for a deploy without an outage. Add a policy so the spread is predictable, and a health check so a sick backend is skipped.

api.tidepool.example.com {
    reverse_proxy 127.0.0.1:9001 127.0.0.1:9002 {
        lb_policy round_robin
        health_uri /healthz
        health_interval 5s
    }
}

With round_robin the requests alternate evenly between the two. Reload and send a handful to watch the backend name flip each time.

for i in $(seq 1 6); do curl -s https://api.tidepool.example.com:8443/tides; done
Six requests through the Caddy reverse proxy alternating evenly between the blue and green backends under the round_robin policy, every response still marked https, the load balancer sharing traffic across two instances behind one certificate and hostname

The responses come back blue, green, blue, green, in step, each still marked https. That even split is the load balancer at work: two instances sharing the traffic, both fronted by the same certificate and hostname. Change round_robin to least_conn and Caddy instead sends each request to the backend with the fewest active connections, which suits uneven request times.

Checkpoint

Six requests should name both backends in a steady alternation. If every response names the same one, the second address is unreachable or failing its health check, so confirm the instance is listening on its loopback port and that /healthz returns a 2xx before expecting it to take traffic.

Watch a health check pull a bad backend

The health check earns its keep when a backend dies. Every few seconds Caddy asks each upstream for /healthz; a backend that stops answering is dropped from rotation until it recovers. Stop one instance and watch the traffic move.

sudo systemctl stop tide-blue
Stopping the blue backend so it fails its health check, after which every request names green and no visitor sees an error, then blue rejoining the pool on restart and the responses returning to a steady alternation, a rolling deploy in miniature

Within one health interval the blue backend fails its check and Caddy stops routing to it, so every request now names green and no visitor sees an error. Start blue again and the next check passes, the instance rejoins the pool, and the responses return to alternating. That is a rolling deploy in miniature: take one copy down, let it recover, and the proxy shifts load without you touching it.

Going further: buffering, retries and streaming

The proxy has knobs for the harder cases. By default Caddy streams the request and response rather than buffering them, which is right for large uploads and downloads and for server-sent events, though a slow backend can be shielded with a buffer if needed. A lb_try_duration lets a request wait briefly for a free backend instead of failing at once during a spike.

Header manipulation with header_up and header_down rewrites what the backend sees and what the client gets, useful when an app expects a particular host header or you want to strip a backend's own server banner. Each is one line inside the proxy block, added only when a real need appears.

Frequently asked questions

Does my app need to know it is behind a proxy?

Only enough to trust the forwarded headers. Caddy sets X-Forwarded-For, X-Forwarded-Proto and X-Forwarded-Host correctly, but many frameworks ignore them until you mark the proxy as trusted, otherwise they would let any client spoof those values. Point the app's trusted-proxy setting at the proxy address and it will read the real client and scheme. Beyond that the app serves plain HTTP on its port and needs no other change.

Which load balancing policy should I start with?

Round robin is the sensible default: it spreads requests evenly and is easy to reason about. Move to least connections when request times vary a lot, so a backend stuck on a slow request stops receiving new ones. There are also hashing policies that pin a client to a backend when you need sticky sessions. Begin with round robin, measure, and switch only if the traffic pattern asks for it.

What is the difference between active and passive health checks?

An active check, the one used here, has Caddy poll a health path on a timer regardless of traffic, so a backend is marked down before a real request hits it. A passive check instead watches live requests and pulls a backend after a set number of failures. They combine well: the active check catches an idle backend that died quietly, and the passive one reacts fast to errors under load. Start with the active check on a cheap health route.

Can Caddy proxy to a backend over HTTPS or a unix socket?

Yes to both. Give the upstream an https:// scheme and Caddy speaks TLS to the backend, with options to verify or skip its certificate for an internal service. For a backend on the same box, a unix socket address avoids the network stack entirely and keeps the app off any port, which is exactly how the next chapter connects Caddy to php-fpm. The proxy directive treats a socket upstream the same as a host and port.

What you have, and what comes next

You put Caddy in front of an application, saw the forwarded headers it sets so the backend knows the real client and scheme, passed a websocket through with no special config, balanced two backends round robin, and watched a health check drop a dead one and welcome it back. HTTPS stayed on the front the entire time.

The backends here were a stand-in app. The next chapter connects Caddy to a real language runtime, wiring php-fpm behind the proxy with a single directive so a dynamic PHP page executes and returns over HTTPS.


Run Caddy on your own slice

Run a site with auto-HTTPS on a slice

Automatic HTTPS needs a server with a public IP and a domain. Spin up a slice, install Caddy from this guide, and serve your site over a certificate that renews itself.

Get early access

This guide is provided for educational purposes and offered as is, without warranty of any kind. The commands change the configuration of a server you control, and some can lock you out if run out of order. Test on a non-production machine, keep a second session open, and take a snapshot or backup before you begin. You are responsible for changes made to your own systems; ServerCake accepts no liability for any loss, downtime, lockout, or damage arising from following this guide or running the accompanying script.

Was this guide helpful?
Share

Spotted something out of date or have a question?

Built by the ServerCake team

Cloud that speaks India.

ServerCake is India's KYC-verified cloud: VMs and managed databases priced in ₹, billed with GST, on India-resident infrastructure. Reserve your spot for early access.

Reserve your spot