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:

bash
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 caddy

The 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:

text
{
	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 with import 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 the Server header.
  • 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/health reaches the API as /health. Use handle /api/* instead if your API expects to see /api in the path. Within a site, handle blocks are tried in order of how specific they are, and the bare handle catches everything else.
  • redir ... permanent sends www.example.com/anything?x=1 to https://example.com/anything?x=1 with a 301.
  • reverse_proxy passes WebSocket connections through without extra settings, and sets X-Forwarded-For, X-Forwarded-Proto and X-Forwarded-Host so 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:

bash
caddy hash-password

It 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:

bash
sudo caddy fmt --overwrite /etc/caddy/Caddyfile
caddy validate --config /etc/caddy/Caddyfile
sudo systemctl reload caddy

caddy 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:

bash
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.com

You 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:

text
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:

bash
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

Be first when orders open

Join the waitlist and we'll send you one email when VPSNine launches. Pick a plan if you already know which one you want, and we'll size the first servers around it.

  • One email, on launch day
  • Monthly or yearly, no lock-in
  • 14-day money-back guarantee