Fix Caddy 502 Bad Gateway With Docker Compose (Step-by-Step Diagnosis)
You followed our guide on hosting multiple apps on one VPS with Docker and Caddy, everything looked right, and your browser still shows this:
502 Bad Gateway
A 502 from Caddy means something very specific: Caddy itself is working fine, it received your request, and it tried to hand it off to your app container — but that handoff failed. The problem isn't Caddy; it's somewhere between Caddy and your app. Here's how to find exactly where, in the order that actually matches how often each cause turns out to be the real one.
Step 1: Read the actual error, not just the 502
Caddy's logs tell you specifically what went wrong. Check them first, always:
docker compose logs caddy --tail=50
You're looking for a line containing dial tcp — it tells you exactly what Caddy tried to connect to and why it failed. The three most common messages, and what each one actually means:
| Log message contains | What it means |
|---|---|
no such host | Caddy can't resolve your app's container name at all — almost always a Docker networking issue (Step 2) |
connection refused | Caddy found the host, but nothing is listening on that port — almost always the app binding to the wrong address (Step 3) |
i/o timeout | The connection hangs without ever completing — usually a firewall rule or the app not actually running yet |
Step 2: Confirm Caddy and your app are on the same Docker network
Docker's internal DNS only resolves container names for containers on the same network. If Caddy is proxying to myapp:3000 but Caddy and myapp aren't attached to a shared network, that hostname simply doesn't exist as far as Caddy's container is concerned — which is exactly what produces a no such host error.
docker network inspect your-network-name
Look at the Containers section of the output — both your Caddy container and your app container need to appear in it. If your app is missing, add it to the same network in your docker-compose.yml:
services:
myapp:
networks:
- caddy_net
networks:
caddy_net:
external: true
This is the single most common cause when you're running each app in its own docker-compose.yml file (exactly the pattern from our multi-app guide) rather than one giant compose file — it's easy to add a new app's compose file and forget the network line entirely.
Step 3: Check what address the app is actually listening on
This is the cause that trips up even experienced developers, because it looks identical to a networking problem from the outside. Get a shell inside the app's container and check:
docker compose exec myapp sh
netstat -tlnp
If you see your app's port listed against 127.0.0.1 rather than 0.0.0.0, you've found it. A service bound to 127.0.0.1 only accepts connections from inside its own container — Caddy, running in a separate container, can reach the container over the network but gets flatly refused at the app's own door, producing exactly the connection refused error from the table above.
The fix depends on the app, but the pattern is the same everywhere — change the bind address from loopback to all interfaces:
- Node.js:
app.listen(3000, '0.0.0.0')instead ofapp.listen(3000)(which defaults to localhost in some frameworks) - Laravel's built-in server (development only — see the note below):
php artisan serve --host=0.0.0.0 - PHP-FPM: confirm
listen = 0.0.0.0:9000(or the Unix socket path, if you're using one) inwww.conf, notlisten = 127.0.0.1:9000
For a real Laravel deployment, don't route production traffic through artisan serve at all — that's a development convenience, not a production app server. Use PHP-FPM behind Caddy, or if you're also running the Supervisor-managed queue worker setup from our other guide, keep the web-serving process and the queue worker as clearly separate concerns even though they're deployed together.
Step 4: Confirm you're proxying to the container port, not the host port
If your app's compose file publishes a port like 8080:3000, that maps host port 8080 to container port 3000. Inside the Docker network, other containers — including Caddy — talk to each other using the container's internal port, not the host-published one. Your Caddyfile should reference the app's internal port:
myapp.yourdomain.com {
reverse_proxy myapp:3000
}
Pointing Caddy at myapp:8080 instead — the host-side port — is a very easy copy-paste mistake, and it produces the same connection refused error as Step 3, so it's worth ruling out even if you've already fixed a binding issue.
Step 5: Make sure the app is actually up before Caddy tries to reach it
If you're seeing this only right after docker compose up — and it clears up a few seconds later on its own — this is a startup race, not a real misconfiguration. Add a healthcheck and make Caddy (or anything depending on the app) wait for it:
services:
myapp:
healthcheck:
test: ["CMD", "wget", "--spider", "-q", "http://localhost:3000"]
interval: 5s
timeout: 3s
retries: 5
caddy:
depends_on:
myapp:
condition: service_healthy
Without this, depends_on alone only waits for the container to start, not for the application inside it to actually be ready to accept connections — a slow-booting app (a Laravel app running migrations on boot, for instance) can still cause a handful of 502s in that gap.
Quick reference: which step matches your symptom
- 502 immediately, every time, no exceptions → Step 2 (networking) or Step 4 (wrong port)
- 502 only right after deploying or restarting → Step 5 (startup race)
- 502 that a plain
curlfrom inside the app's own container doesn't reproduce, but from Caddy's container does → Step 3 (bind address)
Diagnostic steps and Caddy syntax in this guide reflect current Caddy 2.x and Docker Compose behavior as of August 2026.
Comments 0
Be the first to comment.
Leave a comment