Set Up Caddy as a Reverse Proxy for Docker Apps with Automatic HTTPS
Put a friendlier open-source front door in front of Docker apps so certificates and proxying are easier to run correctly the first time.
How to run Caddy in Docker Compose, attach it to the right network, proxy one app by domain name, and verify that automatic HTTPS actually worked.
Single VPS stacks where you want a simpler public entry point than managing Nginx server blocks and Certbot separately.
The common failure is blaming Caddy when DNS, port reachability, or backend networking is the real problem.
Before you begin
- A domain or subdomain you control, already pointed at the VPS public IP.
- Ports
80and443reachable from the internet. - One Docker app already working on an internal container port.
- Modern
docker composeinstalled on the host.
Caddy is an open-source web server and reverse proxy with automatic HTTPS built in. Its quick-start docs emphasize an important baseline: if Caddy knows the hostname and the site is reachable, it will serve the proxy over HTTPS by default. That makes it a strong fit for smaller VPS stacks where you want a clean proxy layer without stitching together several moving parts first.
Step 1: Understand where Caddy fits
The safe pattern looks like this:
- Caddy is the only container that publishes ports
80and443. - Your app container does not need to publish its internal port publicly.
- Both containers share a Docker network so Caddy can reach the app by service name.
That architecture keeps the public surface small. The app stays private inside Docker networking, and Caddy becomes the single public web entry point.
Step 2: Build the Compose layout
Create a project directory with a Caddy config folder and persistent storage directories:
mkdir -p ~/apps/caddy-proxy
cd ~/apps/caddy-proxy
mkdir -p caddy_data caddy_configThen create a Compose file like this:
services:
app:
image: nginx:alpine
expose:
- "80"
networks:
- edge
caddy:
image: caddy:2
restart: unless-stopped
ports:
- "80:80"
- "443:443"
volumes:
- ./Caddyfile:/etc/caddy/Caddyfile:ro
- ./caddy_data:/data
- ./caddy_config:/config
depends_on:
- app
networks:
- edge
networks:
edge:Two details matter here:
exposeon the app advertises the internal port to other containers without publishing it to the world.- The Caddy data directory must persist, because that is where certificates and related state are stored.
If your app already lives in another Compose project, you can still attach both services to a shared external network. The principle stays the same: private backend, public proxy.
Step 3: Write a minimal Caddyfile
Create Caddyfile in the same directory:
app.example.com {
reverse_proxy app:80
}Caddy's reverse proxy quick start shows this exact strength: once it knows the hostname and the upstream, the config can stay small. Replace app.example.com with your real domain and app:80 with the service name and port your container listens on.
If your app needs special headers, websockets, or larger uploads, you can extend the config later. For the first deployment, keep it minimal and verify the basics first.
Check the config before starting the stack:
docker run --rm -v \"$PWD/Caddyfile:/etc/caddy/Caddyfile:ro\" caddy:2 caddy validate --config /etc/caddy/CaddyfileStep 4: Start the stack and test in the right order
Bring the services up:
docker compose up -dThen test each layer in order:
- Does the backend app work inside the network?
- Does Caddy start cleanly?
- Does the domain resolve to the VPS?
- Can you reach the site over HTTPS in a browser or with
curl?
docker compose ps
docker compose logs caddy --tail=100
docker compose exec caddy wget -qO- http://app:80 | head
curl -I https://app.example.comIf everything is correct, you should see a working HTTPS response without having to run Certbot separately. Caddy's docs make clear that it serves HTTPS automatically when it knows the hostname and the environment allows certificate issuance.
Once the proxy works, keep the app private. Do not add a published app port out of convenience unless you truly intend that app to be public outside Caddy.
Rollback and recovery notes
The clean rollback is simply to restore the previous public path and remove the new Caddy entry point from the traffic flow. If you are replacing an older proxy, do not destroy that working config until the Caddy path is fully verified.
Useful recovery checks:
docker compose logs caddy --tail=200
docker compose exec caddy caddy adapt --config /etc/caddy/Caddyfile
docker compose downIf HTTPS issuance fails, do not start by deleting certificate data. First confirm DNS points at the right host, ports 80 and 443 are reachable, and the domain is not still routed somewhere else.
Troubleshooting common Caddy proxy problems
The browser shows a certificate or issuance error.
Check DNS, port reachability, and whether another service is already bound to 80 or 443. Automatic HTTPS depends on the hostname resolving to this VPS and being reachable.
Caddy starts, but returns 502.
That usually means the backend app is not reachable at the service name or port you configured. Test connectivity from inside the Caddy container.
The app still appears on a raw public port.
Remove the app's published ports: entry. If Caddy is the public edge, the backend does not need direct public exposure.
The config is valid, but the wrong app loads.
Confirm the domain block and upstream target in the Caddyfile. Short configs are easier to review, which is one of the reasons Caddy is attractive here.
What to do next
Once a public app is working cleanly through Caddy, the next useful hardening move is separating public traffic from private admin access. Continue with Set Up WireGuard for Private VPS Admin Access.
