# 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.

Source: https://vpsnine.com/tutorials/caddy-reverse-proxy-multiple-apps · Updated: 2026-10-21

Difficulty: intermediate · Time: about 30 minutes · Tested on: Ubuntu 24.04 LTS, Debian 12 Oldstable, Debian 13 Stable

Prerequisites:

- 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](https://vpsnine.com/help/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](https://caddyserver.com/docs/install#debian-ubuntu-raspbian):

```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](https://vpsnine.com/help/firewall-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](https://vpsnine.com/help/server-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](https://vpsnine.com/tutorials/docker-compose-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

- [Get a wildcard certificate with the DNS challenge](https://vpsnine.com/tutorials/letsencrypt-wildcard-certificate-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](https://vpsnine.com/tutorials/docker-compose-reverse-proxy).
- Prefer nginx for static files? [Host a static website with nginx](https://vpsnine.com/tutorials/host-static-website-nginx).
