How to use Caddy as a reverse proxy for multiple sites and apps
One Caddyfile that serves several domains and apps on one server, each with its own automatic HTTPS certificate, path routing, headers, logs and basic auth.
Tested on Ubuntu 24.04 LTS, Debian 12 Oldstable, Debian 13 Stable
Before you start
- A server running Ubuntu 24.04 or Debian 12 or 13
- Root or a user with sudo
- A domain with DNS records you can edit
- Apps already listening on local ports (for example 3000 and 8000)
To put several sites and apps behind one Caddy server, give each domain its own block in /etc/caddy/Caddyfile with a reverse_proxy line pointing at the app's local port, then run systemctl reload caddy. Caddy gets and renews a separate HTTPS certificate for every domain in the file by itself, as long as each name points at the server and ports 80 and 443 are open.
This tutorial builds one Caddyfile for a typical small server: a static site with an API under /api, a redirect from www, a web app on its own subdomain, and a private status page behind a password. Each app keeps listening on localhost only, so the internet reaches it through Caddy and nowhere else. If you only need one app behind one domain, the Caddy reverse proxy setup script does it on a new server's first boot.
| What | In this tutorial |
|---|---|
| The server's public IPv4 address | 203.0.113.10 |
Static site, with an API under /api |
example.com, API on localhost:8000 |
| Web app | app.example.com, on localhost:3000 |
| Private status page | status.example.com, on localhost:3001 |
Point the domains at the server
Create an A record for each name (example.com, www.example.com, app.example.com, status.example.com) pointing at 203.0.113.10, and an AAAA record for each if the server has IPv6. Do this first: Caddy asks Let's Encrypt for the certificates as soon as it loads the config, and Let's Encrypt checks that the name leads to your server.
Check it worked. From your computer, dig +short app.example.com prints 203.0.113.10 for every name.
Install Caddy
Install Caddy from its official repository, which is newer than the distributions' own packages. These are the commands from Caddy's install page:
sudo apt install -y 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 /etc/apt/sources.list.d/caddy-stable.list
sudo apt update
sudo apt install -y caddyThe package adds a caddy user, a systemd service that starts at boot, the config file /etc/caddy/Caddyfile and a log folder /var/log/caddy owned by caddy.
Check it worked. caddy version prints the version (v2.11.7 when we wrote this), and systemctl status caddy shows active (running).
Open ports 80 and 443. With ufw: sudo ufw allow 80/tcp && sudo ufw allow 443/tcp. If your provider has a firewall in front of the server, open them there too (on VPSNine, the panel firewall). Caddy needs port 80 for the certificate challenge and the HTTP-to-HTTPS redirect, even though all real traffic ends up on 443.
Write the Caddyfile
Replace /etc/caddy/Caddyfile with this:
{
email admin@example.com
}
(common) {
encode zstd gzip
header {
Strict-Transport-Security "max-age=31536000"
X-Content-Type-Options "nosniff"
Referrer-Policy "strict-origin-when-cross-origin"
-Server
}
log {
output file /var/log/caddy/{args[0]}.log
}
}
example.com {
import common example.com
handle_path /api/* {
reverse_proxy localhost:8000
}
handle {
root * /var/www/example.com
file_server
}
}
www.example.com {
redir https://example.com{uri} permanent
}
app.example.com {
import common app.example.com
reverse_proxy localhost:3000
}
status.example.com {
import common status.example.com
basic_auth {
maya PASTE_THE_HASH_HERE
}
reverse_proxy localhost:3001
}How it fits together:
- The global block (the first
{ }) sets the email address Let's Encrypt writes to if a certificate is about to expire and renewal keeps failing. (common)is a snippet. Each site pulls it in withimport common <name>, and{args[0]}becomes that name, so each site writes its own log file. The snippet compresses responses, adds three security headers and removes theServerheader.- Each site block starts with its domain. That's all Caddy needs to get a certificate for it, redirect HTTP to HTTPS and serve HTTP/2 and HTTP/3.
handle_path /api/*strips the prefix, so a request for/api/healthreaches the API as/health. Usehandle /api/*instead if your API expects to see/apiin the path. Within a site,handleblocks are tried in order of how specific they are, and the barehandlecatches everything else.redir ... permanentsendswww.example.com/anything?x=1tohttps://example.com/anything?x=1with a 301.reverse_proxypasses WebSocket connections through without extra settings, and setsX-Forwarded-For,X-Forwarded-ProtoandX-Forwarded-Hostso the app sees the visitor's address and knows the request came in over HTTPS.
Create the password hash for the status page and paste the output in place of PASTE_THE_HASH_HERE:
caddy hash-passwordIt asks for the password twice and prints a bcrypt hash starting with $2a$14$. The plain password never goes in the file. On Caddy versions before 2.8 the directive was called basicauth; basic_auth is the current name.
If the static site's folder doesn't exist yet, create it: sudo mkdir -p /var/www/example.com.
Check and load the config
Tidy the file's indentation, check it, then apply it:
sudo caddy fmt --overwrite /etc/caddy/Caddyfile
caddy validate --config /etc/caddy/Caddyfile
sudo systemctl reload caddycaddy validate ends with Valid configuration or names the line it can't parse. A reload swaps the config without dropping open connections, and if the new config fails to load, Caddy keeps running the old one.
Check it worked. Give Caddy a minute to get the certificates, then from your computer:
curl -sI https://app.example.com | head -1
curl -sI https://www.example.com/pricing | grep -i location
curl -s -o /dev/null -w '%{http_code}\n' https://status.example.comYou should see HTTP/2 200, then location: https://example.com/pricing, then 401 (the status page wants the password). With curl -u maya https://status.example.com it asks for the password and returns the page.
journalctl -u caddy --since "10 min ago" shows the certificate requests. Each name should end with certificate obtained successfully.
Add another app later
Add a block, check, reload:
grafana.example.com {
import common grafana.example.com
reverse_proxy localhost:3002
}Then caddy validate --config /etc/caddy/Caddyfile && sudo systemctl reload caddy. Point the new name's DNS at the server first.
Apps in Docker work the same way: publish the container's port on localhost only (127.0.0.1:3002:3000 in compose.yaml) and proxy to localhost:3002. Docker Compose behind a reverse proxy shows the whole setup.
Read the logs
Each site writes JSON access logs to its own file in /var/log/caddy/, and Caddy rotates them by itself (at 100 MB, keeping 10 files, by default). To watch requests arrive with the status code and path:
sudo tail -f /var/log/caddy/app.example.com.log | jq -c '{status, method: .request.method, uri: .request.uri}'Install jq first (sudo apt install -y jq) if you don't have it. Caddy's own messages (certificates, errors reaching an app) go to the journal: journalctl -u caddy -f.
Fix the common problems
502 Bad Gateway for one app. Caddy can't reach the app. The journal says dial tcp 127.0.0.1:3000: connect: connection refused. Check the app is running and on that port: ss -tlnp | grep 3000. An app bound to 127.0.0.1 but proxied as localhost can also fail if localhost resolves to ::1 first and the app only listens on IPv4; use reverse_proxy 127.0.0.1:3000 to rule that out.
No certificate: DNS problem: NXDOMAIN looking up A for app.example.com. The DNS record doesn't exist yet, or hasn't spread. Fix it, wait, and Caddy retries by itself.
No certificate: Timeout during connect (likely firewall problem). Let's Encrypt couldn't reach port 80 or 443. Open both in ufw and in your provider's firewall.
listen tcp :443: bind: address already in use. Another web server (often nginx or Apache) holds the port. Stop and disable it, sudo systemctl disable --now nginx, or move it to another port behind Caddy.
Redirect loop in the browser. The app redirects to HTTPS itself because it doesn't see X-Forwarded-Proto. Tell the app it's behind a proxy (Express: app.set('trust proxy', 1); Django: SECURE_PROXY_SSL_HEADER; many apps have a "behind a reverse proxy" setting).
Next steps
- Get a wildcard certificate with the DNS challenge if you have many subdomains, or a server that can't be reached on port 80.
- Run a Docker Compose app behind a reverse proxy.
- Prefer nginx for static files? Host a static website with nginx.