on this page

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:

  1. Is the container even running, or did it quietly die?
  2. What do the logs say? (This is where the answer usually is.)
  3. Is the port published, and to the right place?
  4. Are the environment variables actually inside the container?
  5. 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:

SymptomLikely causeFirst thing to check
Container exits immediatelyApp 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 refusedPort not published, or wrong container port in -pPORTS column in docker ps; confirm it matches app.listen()
App can’t reach the databaseUsing localhost instead of the service nameConnect to db:5432, not localhost:5432; exec in and nc -zv db 5432
Env var is undefinedNot passed in, or misspelled in Composedocker exec -it <name> env and look for it
Code changes not taking effectStale build cache, or a volume mount you forgot aboutdocker 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 Railway

Heads up. You're leaving raindev.fyi

This link heads to railway.app, an external site we don't control. Cool to keep going?

Continue
, Render

Heads up. You're leaving raindev.fyi

This link heads to render.com, an external site we don't control. Cool to keep going?

Continue
, or a Cloudflare deploy and that one breaks, you read the logs, check the ports, confirm the env vars. The platform’s dashboard is just 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.