on this page
- Debugging is a process, not a panic
- docker ps: is it actually running?
- docker logs: where the answer usually is
- docker exec: walk around inside the container
- docker inspect: the full wiring diagram
- Checking published ports: the classic -p mistake
- Checking env vars and health
- Rebuilding without cache: when a stale layer lies to you
- Reasoning through “it built but it doesn’t work”
- Common symptoms and likely causes
- Tying it back to shipping
The build finished. No errors scrolled by. You ran docker compose up, the terminal said something hopeful, and then you opened http://localhost:3000 and got nothing. A blank tab. Or “connection refused.” Or a spinner that spins until the heat death of the universe.
Welcome to the part of Docker nobody puts in the tutorial. The container is “up,” the dashboard says green, and your app is somehow dead inside it. This guide is the thing I’d tell you after watching you fight your terminal for three hours: a calm, repeatable way to find out what actually went wrong, instead of restarting it eleven times and hoping.
If you’re still fuzzy on what a container even is, read Docker without the buzzwords first. This guide assumes you know that an image is the recipe and a container is the thing running from it. We’re past the what. We’re on the why is it broken.
Debugging is a process, not a panic
The single most useful mindset shift: when something breaks, you’re not unlucky, you just don’t have the information yet. The container knows exactly what happened. It wrote it down somewhere. Your whole job is to go read it, in order, instead of guessing.
Here’s the order I actually use, every time, no exceptions:
- Is the container even running, or did it quietly die?
- What do the logs say? (This is where the answer usually is.)
- Is the port published, and to the right place?
- Are the environment variables actually inside the container?
- Can this container reach the other one (the database, the cache) by name?
That’s it. Most Docker problems are hiding in step 1 or step 2, and people skip straight to rebuilding everything because it feels like progress. It isn’t. Let’s walk the list.
docker ps: is it actually running?
docker ps lists your running containers. The mental model: this is “who’s currently alive.”
# List containers that are running right now
docker ps
You’ll get something like this:
CONTAINER ID IMAGE COMMAND STATUS PORTS NAMES
a1b2c3d4e5f6 my-app "node dist/server.js" Up 2 minutes 0.0.0.0:3000->3000/tcp my-app-1
Read that STATUS column. “Up 2 minutes” means it’s alive. Good. But here’s the trap that eats beginners alive: if your container crashed on startup, it won’t show up here at all. docker ps only lists the living. A dead container is invisible to it, and you’ll stare at an empty list convinced Docker is broken.
That’s what -a is for:
# List ALL containers, including stopped and crashed ones
docker ps -a
Now you see the corpses too:
CONTAINER ID IMAGE COMMAND STATUS NAMES
a1b2c3d4e5f6 my-app "node dist/server.js" Exited (1) 30 seconds ago my-app-1
Exited (1). That number in the parentheses is the exit code, and it’s the first real clue you’ve gotten. 0 means the program finished cleanly. Anything else means it died, and the number is a hint about how. 1 is a generic “the app threw an error and gave up.” 137 means it was killed, usually because it ran out of memory (that’s the one that gets people running heavy builds on a small machine). The exit code tells you that it died. The logs tell you why.
docker logs: where the answer usually is
If I could make you internalize one command, it’s this one. docker logs shows you everything your app printed to standard output and standard error: startup messages, that stack trace, the line where it actually fell over.
# Show the logs for a container (by name or ID)
docker logs my-app-1
Nine times out of ten, the answer is sitting right there in plain text. The app couldn’t find an environment variable. It tried to connect to Postgres and got refused. It threw on line 42 of server.js. Docker faithfully captured all of it. You just have to look.
For a container that’s still running and misbehaving in real time, follow the logs live with -f (for “follow”):
# Stream logs as they happen, like tail -f
docker logs -f my-app-1
# Only the last 50 lines, then follow (handy when there's a wall of history)
docker logs --tail 50 -f my-app-1
Leave that running in one terminal, hit your app from another, and watch what it does the moment the request lands. With Docker Compose it’s the same idea, just scoped to a service:
# Follow logs for one service defined in docker-compose.yml
docker compose logs -f app
Here’s the discipline part, and I mean this kindly: read the error before you paste it anywhere. The instinct to copy a red stack trace straight into a search bar (or an AI chat) without reading it is how you waste an afternoon fixing the wrong thing. The actual cause is often the first line, not the scary forty lines of framework internals below it. Read the top. Read the exit code. Then go ask for help if you still need it, and you’ll ask a much better question.
docker exec: walk around inside the container
Sometimes the logs aren’t enough and you need to go in there yourself. docker exec runs a command inside a container that’s already running, and the magic incantation opens an interactive shell:
# Open a shell inside a running container
docker exec -it my-app-1 sh
Quick decoder, because these flags look like keyboard mashing: -i keeps the input stream open so you can type, -t gives you a proper terminal so it behaves like a real shell, and sh is the shell program to launch. On slim images (anything alpine) you usually get sh. On bigger ones you can try bash. If sh doesn’t exist either, the image is so stripped down it has no shell, which is rare but real for distroless builds.
Once you’re inside, you’re standing in the container’s own filesystem, seeing exactly what your app sees. This is the difference between guessing and knowing. A few things worth checking from in there:
# Are the files you expected actually here?
ls -la /app
# What environment variables does the app actually have?
env
# Can this container even reach the database container by name?
ping db
# Try the actual service the app talks to (here, Postgres on port 5432)
nc -zv db 5432
# Hit another service over HTTP to confirm it answers
curl http://api:4000/health
That env check has saved me more times than I’ll admit. You think you passed DATABASE_URL in. Running env inside the container tells you whether it’s really there or whether you fat-fingered the variable name in your Compose file and it’s silently undefined.
docker inspect: the full wiring diagram
When something is wired wrong and the logs don’t explain it, docker inspect dumps the container’s entire configuration as JSON: its networks, mounted volumes, environment variables, port mappings, restart policy, all of it. It’s verbose and a little overwhelming, so the trick is to ask for the one piece you want with --format.
# Dump the whole config (a lot of JSON)
docker inspect my-app-1
# Just the environment variables
docker inspect --format '{{ .Config.Env }}' my-app-1
# Just the port mappings
docker inspect --format '{{ .NetworkSettings.Ports }}' my-app-1
# Which networks is this container attached to?
docker inspect --format '{{ .NetworkSettings.Networks }}' my-app-1
Reach for inspect when reality and your expectations have split: the app swears the volume is mounted but the file isn’t there, or two containers can’t talk and you need to confirm they’re even on the same network. inspect shows you the truth of how the container was actually started, which sometimes differs from what you thought you told it to do.
Checking published ports: the classic -p mistake
“Connection refused” on localhost is, more often than not, a port problem. Look back at the PORTS column from docker ps:
PORTS
0.0.0.0:3000->3000/tcp
Read that left to right: traffic to port 3000 on your machine gets forwarded to port 3000 inside the container. That arrow is the bridge between your laptop and the app. If that column is empty, there is no bridge. The app is running, perfectly happy, completely unreachable, because nothing is published to the outside world.
The fix is the -p host:container flag, and the direction matters:
# Map port 8080 on your machine to port 3000 inside the container
docker run -p 8080:3000 my-app
# Now http://localhost:8080 reaches the app listening on 3000
Two ways beginners get burned here. First, getting the order backwards (it’s host:container, your side first). Second, and sneakier: the number on the container side has to match the port your app actually listens on. If your Express server calls app.listen(3000) but you published -p 8080:5000, you mapped to a port nothing is listening on, and you’ll get refused every time. The published container port and the app’s real port have to agree.
Checking env vars and health
We’ve touched both of these, so let’s make them explicit, because they’re the quiet killers.
Environment variables. A missing or misspelled env var rarely crashes loudly. More often the app starts, then falls over the first time it needs that value, or worse, runs with undefined and behaves strangely. Two ways to check what’s really there:
# From inside the container
docker exec -it my-app-1 env
# From outside, via inspect
docker inspect --format '{{ .Config.Env }}' my-app-1
If the variable isn’t in that output, your app doesn’t have it, full stop. Doesn’t matter what’s in your .env file on disk if Compose never loaded it.
Health. Say it with me one more time: a running container is not a healthy app. Your container can sit at “Up 5 minutes” while the app inside crashed three seconds after boot and the process limped on, or while it’s throwing 500s on every request. The status word lies by omission. The logs and the exit code don’t.
Rebuilding without cache: when a stale layer lies to you
Docker caches build layers to save time, which is great until it’s the thing ruining your day. Sometimes you change a file, rebuild, and your change just… doesn’t show up. The container runs the old code like nothing happened, and you start questioning your sanity.
Often that’s a stale cached layer. Docker decided a step hadn’t changed and reused an old result, including some code it shouldn’t have. The blunt fix is to rebuild from scratch and trust nothing:
# Rebuild the image, ignoring all cached layers
docker build --no-cache -t my-app .
# Same idea with Docker Compose
docker compose build --no-cache
--no-cache tells Docker to redo every single step from the top. It’s slower, sometimes a lot slower, and that’s the tradeoff: you trade build time for certainty that nothing stale survived. Don’t reach for it on every build out of paranoia, you’ll waste minutes you didn’t need to. Reach for it when a change refuses to take effect and you’ve ruled out the obvious stuff.
Reasoning through “it built but it doesn’t work”
Put it together. When the build succeeded and the app still won’t behave, walk the chain out loud, in this order, and stop the moment one answer is “no”:
Is the container even running, or did it exit? (docker ps -a, read the exit code.) If it exited, what do the logs say? (docker logs, read the top line.) If it’s running but unreachable, is the port published and pointing at the right container port? (docker ps, read the PORTS column.) If the app starts but misbehaves, are the env vars actually present inside the container? (docker exec ... env.) And if it can’t talk to the database, can it reach that service by its name? (docker exec ... nc -zv db 5432.)
Five questions, five commands. Most of the time you find the answer before you reach the bottom. The skill isn’t memorizing flags, it’s refusing to skip steps.
Common symptoms and likely causes
Tape this to your monitor. When Docker starts acting haunted, start here:
| Symptom | Likely cause | First thing to check |
|---|---|---|
| Container exits immediately | App crashed on startup (bad config, missing env, throw on boot) | docker ps -a for the exit code, then docker logs for the trace |
localhost says connection refused | Port not published, or wrong container port in -p | PORTS column in docker ps; confirm it matches app.listen() |
| App can’t reach the database | Using localhost instead of the service name | Connect to db:5432, not localhost:5432; exec in and nc -zv db 5432 |
| Env var is undefined | Not passed in, or misspelled in Compose | docker exec -it <name> env and look for it |
| Code changes not taking effect | Stale build cache, or a volume mount you forgot about | docker compose build --no-cache, or check your -v mounts |
Tying it back to shipping
The reason any of this matters: the same five questions debug your laptop and the box in production. When your Compose stack misbehaves locally, you read the logs, check the ports, confirm the env vars. When the same image lands on docker logs and docker inspect wearing a nicer outfit.
That’s the whole payoff. Docker stops being a black box that either works or doesn’t, and becomes a thing you can interrogate. The container always knows what went wrong. Once you know how to ask, shipping a real project stops being a leap of faith and turns into something closer to a checklist. Which, honestly, is the most you can ask of any tool.