
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 IPv4app2.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 psshows all services asUp.curl -I http://YOUR_VPS_IPreturns something from Caddy (often a redirect once domains are configured).- Opening
https://app1.example.comreturns 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
Hostmatches 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
caddynetwork: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
nofilefor Docker/systemd only after you confirm the need using logs andlsof.
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).