Caddy Reverse Proxy on a VPS, Pinned to 2.11.7
Put two apps behind Caddy 2.11.7 with automatic HTTPS and a version hold, then fix the 431 errors and dropped headers that recent releases bring.
What a Caddy reverse proxy on a VPS gives you
A Caddy reverse proxy on a VPS puts every app on the box behind one public front door. Caddy gets a certificate for each hostname and renews it for you. The apps stay on 127.0.0.1 ports where the internet cannot reach them. Caddy listens on ports 80 and 443 and forwards each request by hostname.
This guide sets that up for two apps on one Ubuntu 24.04 VPS, pinned to Caddy 2.11.7, which was tagged on 2026-10-03. Pinning matters right now. The 2.11.4 and 2.11.6 releases changed how Caddy treats request headers and slow connections, and the final section shows what each change looks like from the app's side. If you are still choosing a proxy, read the comparison of Nginx, Caddy and Traefik as a reverse proxy first. This guide assumes you picked Caddy.
The example uses two hostnames:
app1.example.com, an app listening on127.0.0.1:3000app2.example.com, an app listening on127.0.0.1:8080
Replace them with your own names and ports everywhere below.
Install Caddy from the official apt repository
Caddy publishes its Debian and Ubuntu packages in a Cloudsmith repository. These are the commands from Caddy's install page. They add the signing key and the repository, then refresh the package index.
sudo apt install --yes debian-keyring debian-archive-keyring apt-transport-https curl
curl -1sLf 'https://dl.cloudsmith.io/public/caddy/stable/gpg.key' | sudo gpg --dearmor -o /usr/share/keyrings/caddy-stable-archive-keyring.gpg
curl -1sLf 'https://dl.cloudsmith.io/public/caddy/stable/debian.deb.txt' | sudo tee /etc/apt/sources.list.d/caddy-stable.list
sudo chmod o+r /usr/share/keyrings/caddy-stable-archive-keyring.gpg
sudo chmod o+r /etc/apt/sources.list.d/caddy-stable.list
sudo apt updateThe key lets apt reject a tampered package, so it is worth knowing what that check proves and what it does not. Verifying downloads with checksums and signatures covers the same idea for files you fetch by hand.
Does Ubuntu 24.04 ship its own caddy package?
Yes. Some guides say Caddy is not in the default 24.04 repositories, but the Ubuntu archive lists a caddy package for 24.04 (noble) in the universe component. As of October 2026 its version is 2.6.2-6ubuntu0.24.04.3 on amd64. That is a 2.6 release, older than every change this guide discusses, so it is not the package you want here. The next step asks apt for an exact version, which removes any doubt about which repository the package comes from.
Pin Caddy to 2.11.7 and hold it there
First list the versions apt can see, and the repository each one comes from:
apt-cache policy caddyYou should see a 2.11.7 line from dl.cloudsmith.io. If the universe component is enabled, you also see the older 2.6.2 build from the Ubuntu archive. The install command below uses caddy=2.11.7. If apt-cache policy prints a different string for 2.11.7, use that exact string instead.
sudo apt install caddy=2.11.7
sudo apt-mark hold caddy
apt-mark showhold
caddy versionapt-mark showhold should print caddy. caddy version prints the installed version and exits, and it should report 2.11.7. If it does not, apt installed a different build. Run apt-cache policy caddy again and check the Installed: line.
A hold stops apt from upgrading or removing the package automatically. That covers unattended-upgrades as well, because unattended-upgrades skips any package whose selected state is hold. If you prefer a config file to a hold, unattended-upgrades also has a Package-Blacklist option that takes regular expressions. The hold is simpler, and apt-mark showhold makes it visible.
When you decide to move to a newer release, read its release notes first. Then run these commands, replacing NEW_VERSION with the exact version string that apt-cache policy caddy prints:
sudo apt-mark unhold caddy
sudo apt install caddy=NEW_VERSION
sudo apt-mark hold caddyThe package starts Caddy as a systemd service named caddy, running as the caddy user. Check it:
systemctl status caddy --no-pagerWhat automatic HTTPS needs before it works
Caddy gets a certificate for every hostname in the Caddyfile. Before it can do that, four things must be true:
- Each hostname has a DNS A record (and an AAAA record, if you use IPv6) that points at this VPS.
- Ports 80 and 443 are open from the internet to this VPS.
- Caddy can bind to ports 80 and 443 on the box itself.
- Caddy's data directory is writable and survives restarts. With the package, that is
/var/lib/caddy/.local/share/caddy, where certificates are stored.
Check DNS from your laptop, not from the VPS, because the VPS may have its own resolver cache:
dig +short app1.example.com
dig +short app2.example.comBoth should print the VPS's public IP address. If one prints nothing, or an old IP, the certificate request for that name fails, because the certificate authority connects to whatever address DNS returns.
Open the two ports in the host firewall. With ufw:
sudo ufw allow 80/tcp
sudo ufw allow 443/tcp
sudo ufw statusPort 80 must stay open even though you serve HTTPS. Caddy uses it to redirect plain HTTP to HTTPS, and the ACME (automatic certificate management environment) HTTP challenge also arrives on port 80. The ufw firewall basics for a VPS covers default policies and how to avoid locking yourself out of SSH. Many providers also have a separate network firewall in their control panel. It must allow the same two ports.
If your apps run in Docker, publish their ports on loopback only, for example 127.0.0.1:3000:3000 in Compose. A bare 3000:3000 publishes the app on every interface, and Docker's own firewall rules let that traffic in whatever ufw says. Why Docker ports bypass ufw explains the mechanism and the fix.
If you cannot open port 80 at all, the HTTP challenge cannot work. The DNS challenge is the alternative, but in Caddy each DNS provider is a plugin, and the official packages include only the standard modules. The DNS-01 challenge with certbot shows how that challenge works.
A Caddyfile that proxies two hostnames
The package reads /etc/caddy/Caddyfile. Replace its contents with one site block per hostname:
app1.example.com {
reverse_proxy 127.0.0.1:3000
}
app2.example.com {
reverse_proxy 127.0.0.1:8080
}That is the whole proxy. Each block names a hostname, so automatic HTTPS turns on for it. Caddy requests a certificate for each name and redirects HTTP to HTTPS. Every request for app1.example.com goes to port 3000, and every request for app2.example.com goes to port 8080. Caddy proxies only the hostnames you list.
The file must be readable by the caddy user, because the service runs as that user. If you edit the existing file with sudo nano, its permissions stay as they were.
Before you go further, confirm each app answers on its own port. Run this on the VPS:
curl -sI http://127.0.0.1:3000/
curl -sI http://127.0.0.1:8080/Each should print an HTTP status line. A Connection refused here means the app is not listening on that port, and Caddy would return 502 Bad Gateway for it.
Forwarding the real client IP to the app
Behind a proxy, every request reaches the app from 127.0.0.1. The app needs the visitor's address for rate limits and logs. Caddy's reverse_proxy handles this with no extra config:
- It sets or adds to the
X-Forwarded-Forheader, so the visitor's address is the last entry. - It sets
X-Forwarded-ProtoandX-Forwarded-Host, so the app knows the original scheme and hostname. The originalHostheader passes through unchanged.
Caddy also ignores any X-Forwarded-* values that arrive from the internet, because by default it trusts no proxies. That matters. A visitor who sends a fake X-Forwarded-For: 10.0.0.5 cannot make your app log the wrong address.
The other half of the job is in the app. Most frameworks only read X-Forwarded-For when you tell them the proxy at 127.0.0.1 is trusted. If your app logs 127.0.0.1 for every visitor, that setting is missing in the app, not in Caddy.
Change Caddy's side only if Caddy itself sits behind a CDN or a load balancer. Then add the CDN's address ranges with the trusted_proxies server option, so Caddy accepts the forwarded address from them. On a single VPS that faces the internet directly, leave it unset.
Validate the Caddyfile, then reload
Check the file before Caddy loads it. Run the check as the caddy user, so it sees files with the same permissions the service has:
sudo -u caddy caddy validate --config /etc/caddy/Caddyfile --adapter caddyfile && echo OKcaddy validate loads and provisions every module without starting a server, so it catches more than a syntax check. If it fails, it prints the error and OK does not appear. If OK appears at the end, apply the file:
sudo systemctl reload caddy
systemctl status caddy --no-pagerDo not stop the service to change config. A stop takes both sites offline, and a reload swaps the config in place. If the new config fails to load, caddy reload keeps the running config, so a bad edit does not take the sites down. Run systemctl cat caddy to see the ExecReload= line the unit uses.
Now test from your laptop:
curl -sI https://app1.example.com/
curl -sI http://app2.example.com/The first should return your app's status over HTTPS. The second should return a redirect to the https:// URL. If the first request fails with a certificate error, read Caddy's log, described in the next section. Certificate errors are logged there.
Where Caddy logs go, and creating the log directory first
Caddy's own log, which covers startup and certificate requests, goes to the journal:
journalctl -u caddy --no-pager | less +GRequest logs (one line per HTTP request) are separate. The log directive turns them on for a site. Without an output, they go to stderr, which also lands in the journal. To write each site to its own file, create the directory first and give it to the caddy user:
sudo install -d -m 750 -o caddy -g caddy /var/log/caddy
ls -ld /var/log/caddyThe listing should show caddy caddy as owner and group. This step matters because the service runs as caddy, and /var/log belongs to root. Run ls -ld /var/log to see that. Caddy's file writer tries to create a missing directory, but it does so as the user it runs as, so the safe order is to create the directory yourself first.
Then add a log block to each site:
app1.example.com {
log {
output file /var/log/caddy/app1.log
}
reverse_proxy 127.0.0.1:3000
}
app2.example.com {
log {
output file /var/log/caddy/app2.log
}
reverse_proxy 127.0.0.1:8080
}Caddy rotates these files itself. By default it starts a new file at 100 MiB and keeps 10 old files. Files older than 90 days are deleted. You can change this inside the output file block (roll_size, roll_keep, roll_keep_for). Log files are created with mode 600.
One trap with reload: a new log filename takes effect on reload, but a changed setting for a file Caddy already writes does not. After you change an existing output file block, run sudo systemctl restart caddy instead.
Caddy 2.11.4 and 2.11.6: the changes your app will notice
Three recent releases changed default behaviour that sits between the visitor and your app. 2.11.4 (2026-06-03) and 2.11.6 (2026-10-01) tightened it. 2.11.7 fixed regressions that 2.11.6 introduced. If you upgrade from an older Caddy, these are the reports to expect. Each change has an override in the global servers option, shown below.
Large headers now get 431 Request Header Fields Too Large
The 2.11.6 release notes say:
Request headers are limited to 16 KiB by default.
Requests with larger headers (giant cookies, oversized tokens, etc.) will now get 431 Request Header Fields Too Large.
The limit was 1 MB before. The symptom is one visitor who gets a 431 page while everyone else is fine. It is usually a user with many cookies, or an app that sends a large JWT (JSON web token) in a header. Caddy rejects the request before it proxies it, so your app's log has no entry for it. Look in the Caddy request log instead.
You can reproduce it from your laptop with one oversized header:
curl -s -o /dev/null -w '%{http_code}\n' -H "X-Big: $(head -c 20000 /dev/zero | tr '\0' a)" https://app1.example.com/This prints 431, because 20,000 bytes is more than 16 KiB. To raise the limit, set max_header_size in the global options block at the top of the Caddyfile:
{
servers {
max_header_size 32KiB
}
}Raise it only as far as your app needs. The new default exists because huge headers cost memory on every request.
Header names with underscores or dots never reach the app
2.11.4 started ignoring request headers whose names contain an underscore. Its notes say:
caddyhttp: Ignore header fields with underscores to prevent collisions
2.11.6 did the same for dots:
Request header fields containing . are now dropped, the same way underscores were in 2.11.4.
Some backends, PHP among them, can map such a name onto a legitimate header. That collision is what the change prevents. The symptom is quiet. No error appears anywhere, because the request still succeeds. The app just never sees the header. A client that sends X_Api_Key gets 401 from an app that checks that key, and a client that sends X.Feature.Flag loses the flag. To confirm it, send the same header straight to the app on the VPS with curl -H 'X_Api_Key: test' http://127.0.0.1:3000/. If it works there and fails through Caddy, Caddy is dropping the header.
The best fix is to rename the header to use hyphens. If you cannot change the client, 2.11.6 added options that keep named headers:
{
servers {
expected_underscore_headers X_Api_Key
expected_dot_headers X.Feature.Flag
}
}List only the headers you need. By default, no underscore or dot headers are kept.
Stalled request bodies are cut off after one minute
The 2.11.6 notes add:
New 1-minute idle read/write timeouts by default.
The timer measures a read or write that makes no progress. It does not measure the total length of a request. A large upload that keeps sending data is fine. An upload from a client that stops sending for a full minute is aborted. The app sees an aborted or cut-off request body, and the visitor sees a failed upload.
The first version of this change had bugs. The 2.11.7 notes say Caddy could panic when proxying over HTTP/2. They also say that over HTTP/1.1, streams in the style of SSE (server-sent events) "were cut off exactly 60 seconds after the body was read". The notes then state: "As documented, only a write that stalls should count." 2.11.7 makes Caddy behave that way, which is the main reason to pin 2.11.7 and not 2.11.6. For anyone still on 2.11.6, the notes say: "If you're on 2.11.6, we recommend upgrading."
To give slow clients more time, raise the timeouts in the global options:
{
servers {
timeouts {
read_body_idle 5m
write_idle 5m
}
}
}A negative value such as -1s turns a timeout off. Leave it on if you can, because an idle timeout is what stops a stalled client from holding a connection open forever. Caddy also has a per-route timeouts directive. Its docs explain that the server-wide idle timeouts apply after it and take precedence, so read that page before you rely on it.
All these overrides live in the same global block. If you use more than one, put them inside a single servers { } block at the top of the Caddyfile. Then validate and reload as before.
Where to go from here
You now have two apps behind Caddy with certificates, real client addresses, per-site logs and a pinned version. Adding another app is one more site block and one more DNS record. If you later need the same setup on Nginx, the Nginx reverse proxy config explained maps each piece to its Nginx directive.
FAQ
Why does Caddy fail to get a certificate for my domain?
The certificate authority must reach your VPS on the hostname you configured. Check that dig +short from another machine returns your VPS's public IP. Then check that ports 80 and 443 are open in ufw and in your provider's network firewall. Read journalctl -u caddy for the certificate error Caddy received.
Why does my app log 127.0.0.1 for every visitor behind Caddy?
Caddy sends the visitor's address in the X-Forwarded-For header by default. The app logs 127.0.0.1 because it reads the address of the TCP connection, and that connection comes from Caddy. Tell the app or framework to trust the proxy at 127.0.0.1 so it reads X-Forwarded-For.
Why do some users get 431 Request Header Fields Too Large after a Caddy upgrade?
Caddy 2.11.6 limits request headers to 16 KiB by default, and larger requests get 431. Large cookies and big tokens are the usual cause. Raise the limit with max_header_size inside the global servers option, or make the cookies smaller.
How do I stop apt or unattended-upgrades from upgrading Caddy?
Install the exact version with sudo apt install caddy=2.11.7, then run sudo apt-mark hold caddy. A held package is not upgraded automatically, and unattended-upgrades skips it. apt-mark showhold lists held packages. Run sudo apt-mark unhold caddy when you want to upgrade on purpose.