Set up Caddy as a reverse proxy on a VPS with Docker
Run Caddy as a Dockerized reverse proxy on a fresh VPS: create a shared network, write the Caddyfile and docker-compose file, get HTTPS automatically, then add new domains with a single zero-downtime reload.
On this page
- Why Caddy
- 1. Install Caddy for the first time
- Create the Caddy config folder
- Create a shared Docker network
- Create the Caddyfile
- Create docker-compose.yml for Caddy
- Start Caddy
- 2. Add a new app / domain
- Step 1: Attach the new container to Caddy's network
- Step 2: Declare the domain in the Caddyfile
- Step 3: Reload Caddy (zero downtime)
- Important rule: never use localhost in reverse_proxy
- Troubleshooting
Caddy is a reverse proxy whose standout feature is that it issues and renews SSL (HTTPS) certificates automatically with almost no configuration (zero-config SSL). This guide runs Caddy with Docker on a fresh VPS, then gives you a 3-step workflow for adding a new app/domain without downtime.
Throughout, replace example.com, app.example.com, api.example.com with your own
domains, and the container names (my-new-app, my-nextjs-container,
my-backend-api) with your real container names.
Why Caddy
- Gets SSL certificates from Let's Encrypt / ZeroSSL automatically.
- The config file is extremely short and easy to read.
- Reloads configuration without interrupting traffic (zero-downtime).
1. Install Caddy for the first time
Create the Caddy config folder
Keep the config files together in one folder so they are easy to manage, edit and back up later.
mkdir -p ~/services/caddy
cd ~/services/caddyCreate a shared Docker network
For Caddy to "see" other containers and proxy traffic to them, they must all sit on
the same network. Create a network named caddy-net (or keep the name nginx-proxy
if you already use it and want backward compatibility):
docker network create caddy-netCreate the Caddyfile
The Caddyfile is where you declare domains and the services behind them.
cat > ~/services/caddy/Caddyfile << 'EOF'
# =============================================
# Caddy Reverse Proxy Configuration
# =============================================
# Just declare the domain, Caddy takes care of SSL!
# Example 1: proxy to a Next.js app
# app.example.com {
# reverse_proxy my-nextjs-container:3000
# }
# Example 2: proxy to an API backend
# api.example.com {
# reverse_proxy my-backend-api:8080
# }
EOFWhen you use it for real, swap example.com and the container names for your own
domain and app.
Create docker-compose.yml for Caddy
This file defines the Caddy container, the ports to open, and the folders to persist (volumes).
The caddy_data volume matters: it keeps your SSL certificates across restarts. Lose
it and Caddy must request them again, which can hit the Let's Encrypt rate limit.
cat > ~/services/caddy/docker-compose.yml << 'EOF'
services:
caddy:
image: caddy:2-alpine
container_name: caddy-proxy
restart: unless-stopped
ports:
- "80:80"
- "443:443"
- "443:443/udp" # UDP port 443 must be open if you want HTTP/3
volumes:
- ./Caddyfile:/etc/caddy/Caddyfile
- caddy_data:/data
- caddy_config:/config
networks:
- caddy-net
volumes:
caddy_data:
caddy_config:
networks:
caddy-net:
external: true
EOFStart Caddy
Before starting Caddy, make sure nothing else on the VPS is holding ports 80 and
443 (for example Nginx, Apache, or an old proxy).
If Nginx Proxy Manager is running, stop it to free the ports:
docker stop nginx-proxy-manager-app-1Start Caddy:
cd ~/services/caddy
docker compose up -dTo check that Caddy is healthy and has obtained its SSL certificate, follow the logs:
docker compose logs -fOnce you see certificate obtained successfully in the log, press Ctrl+C to exit.
2. Add a new app / domain
Whenever you have a new app (say a Docker service listening on port 3000), follow these 3 steps.
Step 1: Attach the new container to Caddy's network
Because both Caddy and your app run in Docker, the best practice is to have the app
join Caddy's network (caddy-net) automatically, straight from its
docker-compose.yml.
Option 1 (recommended): declare it in docker-compose
Add this networks configuration to the new app's docker-compose.yml:
services:
my-new-app:
# ... (image, ports and other settings...)
networks:
- caddy-net
networks:
caddy-net:
external: trueThe external: true flag tells Docker that the caddy-net network already exists
outside this project (you created it in part 1). When you run docker compose up -d,
the container joins it automatically. That way the app works correctly on any VPS you
move it to, with no manual commands.
Option 2 (temporary): connect the network by hand
If the app is already running and you cannot edit its docker-compose right now, run:
docker network connect caddy-net my-new-appThis is only a stopgap: the container loses the network connection as soon as it is recreated or rebuilt.
Step 2: Declare the domain in the Caddyfile
Open the Caddyfile:
nano ~/services/caddy/CaddyfileAdd a new block at the bottom of the file:
app.example.com {
reverse_proxy my-new-app:3000
}Step 3: Reload Caddy (zero downtime)
Caddy can reload its config file without restarting the container. Visitors using other sites served by the same Caddy will not notice anything and will not be disconnected.
docker exec caddy-proxy caddy reload --config /etc/caddy/CaddyfileDone. With this command Caddy picks up the new domain app.example.com, requests an
SSL certificate from Let's Encrypt on its own, and starts forwarding requests right
away.
Important rule: never use localhost in reverse_proxy
Caddy runs in its own Docker container, so localhost in the Caddyfile is the Caddy
container itself, NOT your VPS.
- Do NOT use
reverse_proxy localhost:3000. Herelocalhostis not the VPS, it is the container running Caddy. Caddy looks for a service on port 3000 inside the Caddy container itself, which produces a502 Bad Gateway. - The right way:
reverse_proxy <container-name>:3000. Once Caddy and the app share thecaddy-netnetwork, Docker provides internal DNS. Just use the exactcontainer_namedeclared in the app'sdocker-compose.ymlas the hostname, for examplereverse_proxy my-new-app:3000.
Troubleshooting
| Symptom | Fix |
|---|---|
502 Bad Gateway even though the app is running | The Caddyfile points to localhost, or the app has not joined caddy-net - use reverse_proxy <container-name>:<port> and check the network |
| Caddy will not start because port 80/443 is taken | Stop Nginx, Apache or the old proxy (e.g. docker stop nginx-proxy-manager-app-1), then run docker compose up -d again |
| The app loses its connection to Caddy after a rebuild | The network was attached with docker network connect - declare networks in the app's docker-compose.yml instead |
| Rate limited when requesting SSL certificates | Do not delete the caddy_data volume; it keeps certificates across restarts |
Want visitors to see a proper maintenance page instead of a 502 on every deploy? Read Automatic maintenance page with Caddy next.