Websites & apps

Use Caddy as a reverse proxy for your apps

Put Caddy in front of Node.js, Python or Docker apps with the reverse_proxy directive, give each app its own subdomain with automatic HTTPS, and add load balancing and health checks.

BeginnerLinux server5 min read

Most apps you run on a VPS, such as a Node.js API, a Python service or a Docker container, listen on a local port like 3000 or 8080. Caddy can sit in front of them, take the public traffic on ports 80 and 443, get a free certificate for each domain and pass requests to the right app. This guide shows the Caddyfile patterns you need, from one app to several apps with load balancing.

Before you start

  • A Linux VPS with Caddy installed and running as a systemd service. If you have not done that yet, follow Install Caddy web server with automatic HTTPS.
  • A sudo user and SSH access.
  • One DNS A record (and AAAA if you use IPv6) per hostname, pointing at your server's IP, for example app.example.com → 203.0.113.10.
  • Ports 80 and 443 open in your firewall.
  • Your app running and reachable on the server itself, for example on 127.0.0.1:3000.

Check the app answers locally before you involve Caddy:

Bash
curl -I http://127.0.0.1:3000

Any HTTP status line means the app is up. Connection refused means you need to fix the app first.

Proxy one app

Open the Caddyfile:

Bash
sudo nano /etc/caddy/Caddyfile

Replace the default site with a block for your hostname:

app.example.com {
	reverse_proxy 127.0.0.1:3000
}

That is a complete config. Because the site address is a real hostname, Caddy requests a certificate for it, redirects HTTP to HTTPS and forwards every request to port 3000.

Check the syntax, then reload without dropping connections:

Bash
sudo caddy validate --config /etc/caddy/Caddyfile
sudo systemctl reload caddy

Tip: Bind your app to 127.0.0.1 rather than 0.0.0.0 where it allows. Then the only way to reach it from outside is through Caddy and HTTPS.

Several apps on subdomains

Add one site block per hostname. Each gets its own certificate:

app.example.com {
	reverse_proxy 127.0.0.1:3000
}

api.example.com {
	reverse_proxy 127.0.0.1:8000
}

grafana.example.com {
	reverse_proxy 127.0.0.1:3001
}

You can also split one domain by path. Use handle_path when the app expects requests at its own root, because it strips the prefix before proxying:

example.com {
	handle_path /api/* {
		reverse_proxy 127.0.0.1:8000
	}
	handle {
		reverse_proxy 127.0.0.1:3000
	}
}

A request for /api/users reaches the backend as /users. Use handle /api/* instead if the backend expects to see the /api prefix.

Headers and the real client IP

Caddy already does the common work for you:

  • It passes the original Host header through unchanged.
  • It sets X-Forwarded-For, X-Forwarded-Proto and X-Forwarded-Host, so your app can see the visitor's IP and that the request came in over HTTPS.

Configure your framework to trust the proxy at 127.0.0.1 so it reads those headers. In Express, for example, that is app.set('trust proxy', 'loopback').

If a backend needs a different Host value, such as an upstream that is itself a virtual host, set it with header_up:

legacy.example.com {
	reverse_proxy 10.0.0.5:80 {
		header_up Host {upstream_hostport}
	}
}

To remove a header the backend sends to browsers, use header_down:

reverse_proxy 127.0.0.1:3000 {
	header_down -X-Powered-By
}

WebSockets

There is nothing to add. reverse_proxy detects the WebSocket upgrade request and keeps the connection open, so chat apps, live dashboards and tools like Uptime Kuma work with the plain one-line config.

If long-lived connections drop when you reload Caddy, you can let them stay open for a while after a config change:

reverse_proxy 127.0.0.1:3000 {
	stream_close_delay 5m
}

Load balancing and health checks

List more than one upstream to spread traffic across copies of your app:

app.example.com {
	reverse_proxy 127.0.0.1:3000 127.0.0.1:3002 127.0.0.1:3003 {
		lb_policy round_robin
		lb_retries 2

		health_uri /health
		health_interval 10s
		health_timeout 3s
		health_status 2xx
	}
}
  • lb_policy chooses how requests are shared. Other options include least_conn, ip_hash and cookie for sticky sessions.
  • lb_retries tries another upstream if the first one fails to connect.
  • The health_* lines are active health checks. Caddy calls /health on each upstream every 10 seconds and stops sending traffic to any that fail.

Your app must answer /health with a 2xx status for this to work. If it has no such endpoint, use any cheap page that always returns 200.

Proxy to Docker containers

If a container publishes its port to the host, proxy to 127.0.0.1:<port> as above. Publish it on loopback only, for example -p 127.0.0.1:8080:80, so Docker does not expose it to the internet past your firewall. If Caddy itself runs in Docker, see Caddy reverse proxy for Docker apps.

Check it works

From your own computer:

Bash
curl -I https://app.example.com

You should see HTTP/2 200 (or your app's normal status) and no certificate error. Check that plain HTTP redirects:

Bash
curl -I http://app.example.com

Expect 308 Permanent Redirect with a Location: https://... header. Watch Caddy's log while you test:

Bash
sudo journalctl -u caddy -f

Troubleshooting

  • 502 Bad Gateway: Caddy cannot reach the upstream. The app is stopped, listening on a different port, or bound to another address. Run ss -tlnp | grep 3000 to see what is listening.
  • Redirect loop or wrong links to http://: the app does not trust the forwarded headers and thinks the request is plain HTTP. Enable its trusted proxy setting.
  • Certificate never issues: DNS does not point at the server yet, or ports 80 and 443 are blocked. See Fix Caddy certificate errors and ACME failures.
  • Reload fails with an "adapting config" error: a brace or directive is wrong. Run caddy validate and fix the line it reports. The old config stays live until a reload succeeds.

Next steps

Was this guide helpful?

Still stuck?

Ask MdmHost AI for a quick answer, or open a ticket and our team will take a look. We are here 24/7.