Back to tutorials
Tutorial

Set Up Caddy Reverse Proxy for Docker on a VPS

Set up Caddy reverse proxy Docker on your VPS: route multiple domains to containers with automatic HTTPS, clean configs, and safe defaults.

By Anurag Singh
Updated on Oct 25, 2026
Category: Tutorial
Share article
Set Up Caddy Reverse Proxy for Docker on a VPS

Running three or four Docker apps on one VPS usually ends the same way. You get a mess of ports. You get a reverse proxy config nobody wants to touch. Certificates renew only when you remember. This tutorial fixes that with Caddy reverse proxy Docker on a single host: one readable Caddyfile, automatic HTTPS per domain, and predictable routing.

You’ll run Caddy as a container. You’ll connect your app containers to one shared network.

You’ll route multiple domains (and paths) to different services. You’ll also confirm TLS and proxy headers behave the way your apps expect.

The steps work on Ubuntu 24.04/26.04 LTS, Debian 12/13, AlmaLinux 9/10, Rocky Linux 9/10, and CentOS Stream 9/10.

What you’ll build (and what you need)

  • One VPS (1 vCPU / 1–2 GB RAM works for light apps; scale up for databases and traffic).
  • Docker + Docker Compose running on the VPS.
  • Caddy as the reverse proxy container, handling TLS automatically.
  • Two example apps (a simple “whoami” service and an app container) so you can test routing quickly.
  • Domains pointing to the VPS public IP (A/AAAA records).

If you don’t already have a server, this is a solid use case for a HostMyCode VPS. One box gives you control over networking, firewall rules, and container deployments.

If you’d rather not manage OS updates and security tuning yourself, managed VPS hosting is the simpler route.

Step 1: Point your DNS to the VPS

Create DNS records for each site you plan to serve. At minimum:

  • app1.example.com → A record → your VPS IPv4
  • app2.example.com → A record → your VPS IPv4

If your VPS has IPv6, add AAAA records too. Use a TTL of 300 seconds while you’re setting things up.

Verify DNS from your laptop:

dig +short app1.example.com A
dig +short app2.example.com A

Common mistake: Debugging through a “proxy DNS” feature (for example, an “orange cloud” mode). Start with direct DNS so you can see the origin clearly.

Add a CDN or proxy later once everything works.

Step 2: Install Docker and Compose (Ubuntu/Debian + Alma/Rocky)

If Docker is already installed, skip to Step 3.

Otherwise, install from supported packages for your distro.

Ubuntu / Debian

sudo apt update
sudo apt -y install ca-certificates curl gnupg

# Docker Engine + Compose plugin
sudo apt -y install docker.io docker-compose-plugin

sudo systemctl enable --now docker
sudo usermod -aG docker $USER

Log out and back in. Your shell needs to pick up the docker group membership.

AlmaLinux / Rocky / CentOS Stream

sudo dnf -y install dnf-plugins-core
sudo dnf config-manager --add-repo https://download.docker.com/linux/centos/docker-ce.repo
sudo dnf -y install docker-ce docker-ce-cli containerd.io docker-compose-plugin

sudo systemctl enable --now docker
sudo usermod -aG docker $USER

Verify:

docker version
docker compose version

If you’re new to Docker on a VPS, the workflow matches other HostMyCode guides like self-hosting Forgejo with Docker Compose.

The difference here is the “front door.” Caddy becomes the single entry point for every app.

Step 3: Open firewall ports 80/443 (and keep SSH safe)

Caddy must be reachable on ports 80 and 443.

It needs port 80 for HTTP-01 validation. It needs 443 for normal HTTPS traffic.

UFW (Ubuntu/Debian typically)

sudo ufw allow OpenSSH
sudo ufw allow 80/tcp
sudo ufw allow 443/tcp
sudo ufw status

firewalld (Alma/Rocky commonly)

sudo firewall-cmd --permanent --add-service=http
sudo firewall-cmd --permanent --add-service=https
sudo firewall-cmd --reload
sudo firewall-cmd --list-services

If you want to tighten things later (rate limits, explicit rules, logging), keep this open: HostMyCode’s iptables/nftables production firewall tutorial.

Step 4: Create a shared Docker network for the proxy

A dedicated network keeps service discovery simple.

It also helps you avoid publishing random container ports to the internet “just to make it work.”

docker network create caddy

Verify:

docker network ls | grep caddy

Step 5: Prepare directories for Caddy data and config

Caddy stores certificates and account keys in its data directory. Persist it.

If you don’t, you’ll re-issue certificates on every rebuild.

sudo mkdir -p /opt/caddy/{data,config,logs}
sudo chown -R $USER:$USER /opt/caddy

Create a Caddyfile:

nano /opt/caddy/Caddyfile

Paste this starter config (you’ll swap domains and upstreams as you go):

{
	# Your email is used for Let's Encrypt notifications
	email admin@example.com

	# Optional: keep logs readable while you're setting things up
	# debug
}

# App 1: whoami test service
app1.example.com {
	encode zstd gzip
	reverse_proxy whoami:80

	log {
		output file /var/log/caddy/app1.access.log {
			roll_size 50MiB
			roll_keep 10
		}
	}
}

# App 2: example app
app2.example.com {
	encode zstd gzip
	reverse_proxy app2:3000

	log {
		output file /var/log/caddy/app2.access.log {
			roll_size 50MiB
			roll_keep 10
		}
	}
}

Why this works: On a Docker network, Caddy reaches whoami and app2 by container name. Docker’s internal DNS handles it.

That means no IPs to track. It also means no “it changed after a restart” surprises.

Step 6: Deploy Caddy + example apps with Docker Compose

Create a project directory:

mkdir -p ~/proxy-stack
cd ~/proxy-stack
nano docker-compose.yml

Use this Compose file:

services:
  caddy:
    image: caddy:2.9
    container_name: caddy
    restart: unless-stopped
    ports:
      - "80:80"
      - "443:443"
    networks:
      - caddy
    volumes:
      - /opt/caddy/Caddyfile:/etc/caddy/Caddyfile:ro
      - /opt/caddy/data:/data
      - /opt/caddy/config:/config
      - /opt/caddy/logs:/var/log/caddy

  whoami:
    image: traefik/whoami:latest
    container_name: whoami
    restart: unless-stopped
    networks:
      - caddy

  app2:
    image: ghcr.io/umami-software/umami:postgresql-latest
    container_name: app2
    restart: unless-stopped
    environment:
      DATABASE_URL: postgresql://umami:umami_password@db:5432/umami
      APP_SECRET: "change_me_to_a_long_random_string"
    networks:
      - caddy
    depends_on:
      - db

  db:
    image: postgres:16
    container_name: app2_db
    restart: unless-stopped
    environment:
      POSTGRES_DB: umami
      POSTGRES_USER: umami
      POSTGRES_PASSWORD: umami_password
    volumes:
      - umami_db:/var/lib/postgresql/data
    networks:
      - caddy

networks:
  caddy:
    external: true

volumes:
  umami_db:

Bring it up:

docker compose up -d
docker compose ps

Watch Caddy logs. Certificate issuance shows up here:

docker logs -f caddy

Verification checklist:

  • docker compose ps shows all services as Up.
  • curl -I http://YOUR_VPS_IP returns something from Caddy (often a redirect once domains are configured).
  • Opening https://app1.example.com returns the whoami response.

If you want a side-by-side comparison for Umami behind a proxy with TLS, see the Umami + Nginx setup.

You’ll notice Caddy gets you to the same place with less configuration overhead.

Step 7: Fix the two most common HTTPS failures

If Caddy can’t issue certificates, it’s usually one of these two problems.

Problem A: DNS points to the wrong IP

From the VPS itself, check what the world sees:

curl -4 ifconfig.me; echo
getent ahosts app1.example.com

If the A record doesn’t match the VPS IPv4, fix DNS first.

Don’t keep retrying certificate issuance. Let’s Encrypt has rate limits.

Problem B: Port 80/443 blocked upstream

Confirm Caddy is listening:

sudo ss -lntp | egrep ':80|:443'

If Docker is bound to 80/443 but you still can’t connect from outside, something upstream is blocking traffic.

That’s usually your firewall or a provider security group.

Open 80/443 and test again.

For deeper TLS debugging (mixed content, wrong chain, renewals), keep this nearby: configure HTTPS with Let’s Encrypt and fix common SSL errors.

Step 8: Add a third app (pattern you’ll reuse)

Once the proxy is in place, adding apps becomes routine.

Connect the service to the caddy network, then add a site block in the Caddyfile.

Add this to docker-compose.yml under services: as an example:

  app3:
    image: nginx:1.27-alpine
    container_name: app3
    restart: unless-stopped
    networks:
      - caddy

Then add a matching site to /opt/caddy/Caddyfile:

app3.example.com {
	encode zstd gzip
	reverse_proxy app3:80
}

Apply changes safely:

docker compose up -d
docker exec caddy caddy validate --config /etc/caddy/Caddyfile
docker exec caddy caddy reload --config /etc/caddy/Caddyfile

Common mistake: Restarting the container before validating the config.

One typo can take every site down. Run caddy validate first.

Step 9: Route by path (one domain, multiple services)

Sometimes you want one domain for the main app and a subpath for an admin tool.

Caddy’s routing makes this straightforward.

Example: route /status to an internal status page, while everything else goes to the main app:

example.com {
	encode zstd gzip

	@status path /status* 
	reverse_proxy @status statuspage:80

	reverse_proxy mainapp:8080
}

Gotcha: Some apps need to know they’re running under a subpath.

If you see broken links or missing assets, check the app’s base URL setting.

If that’s not workable, move the tool to a subdomain instead.

Step 10: Make proxy headers correct (so apps see real client IP)

Most web apps rely on these headers:

  • X-Forwarded-For (client IP)
  • X-Forwarded-Proto (http/https)
  • Host

Caddy sets sensible defaults automatically.

The usual work is on the app side.

Tell the app to trust the proxy. If you don’t, it may treat every request as “coming from localhost.”

Quick verification from a backend that echoes headers (the whoami service does):

  • Open https://app1.example.com
  • Confirm it shows X-Forwarded-Proto: https
  • Confirm Host matches the domain you requested

If you see the VPS IP instead of the real client IP, check for another proxy/CDN in front of the VPS.

In that case, your app may need to trust CF-Connecting-IP or similar headers.

Step 11: Add basic hardening: headers, TLS settings, and request limits

Caddy’s defaults are fine for many setups.

Still, it helps to make your expectations explicit. This matters more once real traffic shows up.

Add a shared snippet at the top of the Caddyfile:

(security_headers) {
	header {
		Strict-Transport-Security "max-age=31536000; includeSubDomains; preload"
		X-Content-Type-Options "nosniff"
		X-Frame-Options "SAMEORIGIN"
		Referrer-Policy "strict-origin-when-cross-origin"
		Permissions-Policy "geolocation=(), microphone=(), camera=()"
	}
}

Then import it inside each site:

app1.example.com {
	import security_headers
	encode zstd gzip
	reverse_proxy whoami:80
}

For bot pressure or brute-force attempts, rate limiting is the next step.

Caddy can do this with plugins, but you may prefer a predictable built-in approach.

Nginx still has strong native controls. HostMyCode’s Nginx rate limiting tutorial covers patterns you can adapt to any reverse-proxy layer.

Step 12: Backups that you can actually restore

For a Caddy + Docker host, the minimum backup set is:

  • /opt/caddy (Caddyfile + certificate storage)
  • Your app volumes (Postgres/MySQL data volumes, uploads, etc.)
  • Your Compose files (Git repo or filesystem copy)

A practical routine: archive your Compose directory, dump databases on a schedule, and push the backup off-server.

If it only lives on the VPS, it’s not a backup.

Example: Postgres dump for the Umami DB

mkdir -p ~/backups/umami

docker exec -t app2_db pg_dump -U umami umami \
  | gzip > ~/backups/umami/umami-$(date +%F).sql.gz

Test the restore on the same VPS (to a new database name) before you trust the process:

docker exec -i app2_db psql -U umami -d postgres -c "CREATE DATABASE umami_restore_test;"

gunzip -c ~/backups/umami/umami-$(date +%F).sql.gz \
  | docker exec -i app2_db psql -U umami -d umami_restore_test

If you want a more complete runbook (what to back up, how to rehearse restores, DNS cutover planning), use HostMyCode’s disaster recovery tutorial as a companion.

Step 13: Update workflow (no surprise downtime)

Keep the proxy stable.

Update apps on purpose, not accidentally.

Update images and restart:

cd ~/proxy-stack
docker compose pull
docker compose up -d

Clean unused images occasionally:

docker image prune -f

Common mistake: Running docker system prune -a on a production host without reviewing what it will remove.

It can delete images you depend on and make recovery slower.

Step 14: Monitoring and logs you’ll actually look at

You’re already writing access logs to /opt/caddy/logs.

That’s enough for quick checks and incident timelines.

Tail logs:

sudo tail -f /opt/caddy/logs/app1.access.log

Container-level health:

docker stats

If you want a single dashboard for CPU/RAM/disk plus alerting, Netdata is still a good fit for a one-host VPS. HostMyCode walks through it here: run Netdata for live server monitoring.

Troubleshooting quick hits (the stuff that burns time)

  • 502 Bad Gateway: Caddy can’t reach the upstream. Check container name, port, and that both containers share the caddy network: docker inspect caddy | jq '.[0].NetworkSettings.Networks'.
  • Redirect loops: Your app forces HTTP→HTTPS but doesn’t trust proxy headers. Configure “trust proxy” or “external URL” in the app.
  • Certificate not issuing: DNS wrong, ports blocked, or you’re trying to issue for a hostname that isn’t public (like .local).
  • WebSockets failing: Usually an app-side setting or an upstream timeout. Caddy supports WebSockets by default; verify the upstream is correct.
  • Too many open files under load: That’s a host limit. Raise nofile for Docker/systemd only after you confirm the need using logs and lsof.

If you’re consolidating multiple Docker apps onto one host, size the VPS for the whole stack (database + cache + proxy), not just the web containers. A HostMyCode VPS gives you full control over Docker networking and firewall rules, and managed VPS hosting is a better fit if you’d rather not handle updates and security yourself.

FAQ

Do I need to expose app ports to the internet?

No. In this setup, only Caddy binds to 80 and 443. Your apps stay on an internal Docker network, reachable only through the proxy.

Can Caddy handle both HTTP and TCP (like database proxying)?

Caddy can proxy TCP with the right configuration and modules, but it’s usually a bad idea to expose databases publicly. Keep databases private on the VPS network or use a VPN.

Where are the TLS certificates stored?

Inside the Caddy data directory you mounted: /opt/caddy/data. Back it up so you don’t churn certificates after a rebuild.

Should I run Caddy on the host instead of Docker?

Either works. Running Caddy in Docker keeps the proxy deployment consistent with your app stack and makes rollbacks simpler. Host installs can integrate more tightly with systemd if that’s your preference.

What if I want to migrate existing sites with minimal downtime?

Plan the cutover: lower DNS TTL, run both origins briefly, then switch records once HTTPS and routing are verified. This tutorial helps: DNS cutover with low TTL and parallel hosting.

Summary: your repeatable Caddy + Docker pattern

You now have a repeatable setup: one Caddy container on ports 80/443, one shared Docker network, and one Caddyfile that maps domains to container names.

Adding a new app is two small edits, not a weekend of proxy surgery.

If you want this to stay boring in production, run it on infrastructure with stable networking and predictable disk performance. That’s what you get with a HostMyCode VPS (or move to dedicated hardware if you’re running heavier stacks).