🐞 Debugging Containerized Apps
A container is a sealed box — great for reproducibility, awkward the moment something breaks and you can't just add a console.log and hit refresh. This lesson gives you the three tools that pry the box open: streaming logs, a shell inside the running container, and a real step-through debugger attached across the container boundary.
Week 11 · Day 4 (Thursday: Docker for Development) · Lecture 3
🎯 Learning Objectives
By the end of this lesson, you will be able to:
- Read and filter container output with
docker compose logs -f,--tail, and--since - Drop into a running container with
docker compose execto inspect files, env vars, and processes - Diagnose the layer a bug lives in: host, Docker config, runtime, environment, or your code
- Expose the Node inspector on
--inspect=0.0.0.0:9229and attach VS Code across the container boundary - Debug service-to-service networking with
curl, DNS lookups, anddocker network inspect - Add healthchecks and graceful shutdown so failures surface clearly instead of silently
Estimated Time: 70 minutes
Practice: Set breakpoints in a running containerized Express API from VS Code and trace a request end to end.
In This Lesson
The Sealed-Box Problem
Everything that makes a container reproducible — its own filesystem, its own network namespace, its own process tree — also puts a wall between you and your running code. You can't just open the file on disk and watch it run; the "disk" and the "run" are inside the box. Good news: Docker gives you clean interfaces through that wall, and once you know them, debugging in a container is often more controlled than debugging on bare metal.
🔬 The keyhole-surgery analogy
Debugging on your host machine is open surgery: total access, but messy and hard to reproduce. Debugging in a container is keyhole surgery — you work through small, precise interfaces (logs, exec, the inspector port). It feels more constrained at first, but it's cleaner, more repeatable, and it's the same procedure whether the patient is your laptop or production.
Three interfaces do most of the work, in ascending order of power:
- Logs — what the process printed. Your first look, always.
- A shell — go inside and check the filesystem, env vars, and network from the container's point of view.
- A debugger — pause execution, inspect variables, step line by line.
Which Layer Is Broken?
Before you reach for a tool, locate the bug. Containerized apps stack five layers, and the right technique depends on which one is failing. Asking "which layer?" first saves you from debugging your code when the real problem is a mistyped environment variable.
| Symptom | Likely layer | First tool |
|---|---|---|
| Container exits immediately | Docker config / runtime | docker compose logs, config |
| "Cannot find module" | App environment | exec then ls node_modules |
| Wrong config value at runtime | App environment | exec … printenv |
| Service can't reach another | Docker config (network) | exec … curl, network inspect |
| Logic bug, bad response | Application code | Node inspector + breakpoints |
Reading Logs
Docker captures everything your process writes to stdout and stderr. That's your first and cheapest signal — and it's why containerized apps should log to the console rather than to files inside the container.
# Follow one service's logs live (the everyday command)
docker compose logs -f api
# Last 100 lines only, with timestamps
docker compose logs --tail=100 -t api
# Everything since 10 minutes ago
docker compose logs --since=10m api
# All services interleaved (useful for request tracing)
docker compose logs -f
If a container starts and dies before you can attach, its last words are still in the logs — that stack trace on exit is usually the whole answer:
Terminal output
api-1 | Error: connect ECONNREFUSED db:5432
api-1 | at TCPConnectWrap.afterConnect [as oncomplete]
api-1 exited with code 1
Here the layer is clear: the app couldn't reach db:5432. That's a networking/startup-order problem (revisit healthchecks), not a code bug — and the log said so in one line.
💡 Make logs worth reading
Structured JSON logs are far easier to filter than free-form text. A small logger like pino lets you attach a request id and level to every line, so docker compose logs | grep actually finds what you need.
import pino from 'pino';
const log = pino();
log.info({ reqId: 'a1b2', route: '/users' }, 'request received');
// → {"level":30,"reqId":"a1b2","route":"/users","msg":"request received"}
Getting a Shell with exec
When logs aren't enough, go inside. docker compose exec runs a command in an already-running container — most usefully, a shell. Now you can look at the world exactly as your app sees it.
# Open an interactive shell (alpine images ship sh, not bash)
docker compose exec api sh
# Once inside, investigate from the container's point of view:
printenv | sort # are the env vars actually set?
ls -la node_modules | head # did dependencies install?
cat /app/package.json # is the code what you think it is?
ps aux # what's actually running?
wget -qO- http://localhost:4000/health # does the app respond in here?
You can also run a one-off command without an interactive shell — handy for migrations, quick checks, or CI:
# Run a single command and exit
docker compose exec api printenv DATABASE_URL
docker compose exec api npm run migrate
# If the container is crashed/not running, start a throwaway one instead
docker compose run --rm api sh
⚠️ exec needs a running container
exec attaches to a live container. If yours crashed on boot, there's nothing to attach to — use docker compose run --rm api sh to spin up a fresh one with the same config, or override the command to keep it alive (command: sh -c "sleep infinity") so you can poke around.
The Node Inspector & VS Code
The most powerful move: attach a real debugger and step through your code line by line, inside the container. Node has a built-in inspector protocol. The trick for containers is binding it to 0.0.0.0 so it accepts a connection from outside the box, and publishing its port.
1. Start Node with the inspector bound to 0.0.0.0
// package.json
{
"scripts": {
"dev": "nodemon src/index.js",
"dev:debug": "nodemon --inspect=0.0.0.0:9229 src/index.js"
}
}
The default --inspect binds to 127.0.0.1, reachable only from inside the container. --inspect=0.0.0.0:9229 makes it reachable from your host. Use --inspect-brk instead to pause on the very first line until a debugger attaches.
2. Publish the inspector port in Compose
# compose.override.yaml
services:
api:
ports:
- "4000:4000" # the app
- "9229:9229" # the debugger
command: npm run dev:debug
3. Attach from VS Code
// .vscode/launch.json
{
"version": "0.2.0",
"configurations": [
{
"type": "node",
"request": "attach",
"name": "Docker: Attach to Node",
"port": 9229,
"address": "localhost",
"localRoot": "${workspaceFolder}",
"remoteRoot": "/app",
"restart": true
}
]
}
💡 localRoot and remoteRoot matter
Your files live at /app in the container but at your project folder on the host. These two settings map between them so breakpoints in your editor line up with the code the container is actually running. Get them wrong and breakpoints show as "unbound".
You can also skip VS Code entirely: open chrome://inspect in Chrome, add localhost:9229 as a target, and click "inspect" for full DevTools against your container's Node process.
Debugging Service Networking
Multi-service bugs are usually one service failing to reach another. Compose puts every service on a shared network where they're addressable by service name — so the debugging question is "can container A actually reach container B by name?" Answer it from inside A.
# From inside the API container, can we reach the db and another service?
docker compose exec api sh
# Does the service name resolve?
nslookup db # or: getent hosts db
# Can we actually connect and get a response?
wget -qO- http://web:5173
curl -v http://api:4000/health
# Inspect the network Compose created
docker network inspect $(docker compose ls -q)_default
⚠️ The localhost trap, again
Inside a container, localhost is that container. A connection string of localhost:5432 in your API means "a Postgres running inside the API container" — which there isn't. Use the service name: db:5432. This single mistake accounts for a huge share of "services can't communicate" tickets.
Alpine images are minimal, so curl or nslookup may be missing. Install on the fly inside the container while debugging (never bake these into a production image):
docker compose exec api sh -c "apk add --no-cache curl bind-tools"
Healthchecks & Graceful Shutdown
The best debugging is the kind you don't have to do because the failure announced itself. Two small habits make containerized apps far easier to reason about.
A health endpoint + Compose healthcheck
// Report real dependency status, not just "the process is up"
app.get('/health', async (req, res) => {
const dbOk = await pingDatabase().then(() => true).catch(() => false);
res.status(dbOk ? 200 : 503).json({
status: dbOk ? 'ok' : 'degraded',
db: dbOk ? 'up' : 'down',
uptime: process.uptime(),
});
});
services:
api:
healthcheck:
test: ["CMD", "wget", "-qO-", "http://localhost:4000/health"]
interval: 15s
timeout: 3s
retries: 3
start_period: 20s
Now docker compose ps shows each service as healthy or unhealthy at a glance, turning "is it working?" into a status column instead of a guess.
Graceful shutdown so restarts are clean
Docker sends SIGTERM when stopping a container. If you ignore it, Node is killed mid-request and connections drop. Handle it so in-flight work finishes and resources close cleanly:
process.on('SIGTERM', async () => {
console.log('SIGTERM received, shutting down gracefully');
server.close(async () => { // stop accepting new requests
await pool.end(); // close the DB pool
process.exit(0);
});
// Safety net: force-exit if cleanup hangs
setTimeout(() => process.exit(1), 10_000).unref();
});
✅ Restart policies buy you resilience
Pair graceful shutdown with a restart policy so transient crashes self-heal: restart: unless-stopped in Compose brings a crashed service back automatically, while still letting you stop it deliberately without it fighting you.
Practice & Quiz
🏋️ Exercise 1: Attach a debugger to a running container
Goal: You have a containerized Express API and want to set a breakpoint in a route handler from VS Code. List the three changes needed across package.json, Compose, and launch.json.
💡 Hint
The debugger has to be reachable from outside the container, its port has to be published, and your editor has to map host paths to /app.
✅ Solution
- package.json: a script that runs
node --inspect=0.0.0.0:9229 src/index.js(via nodemon for reload). - Compose: publish the port with
- "9229:9229"and use that command. - launch.json: an
attachconfig on port 9229 withlocalRoot: "${workspaceFolder}"andremoteRoot: "/app".
Then run docker compose up, press F5 on the attach config, and your breakpoints bind.
🏋️ Exercise 2: Trace a "can't connect" failure
Goal: The API logs ECONNREFUSED db:5432 intermittently on startup. Walk through diagnosing it using tools from this lesson.
✅ Solution
docker compose logs -f api— confirm the error and its timing (only at startup?).docker compose exec api sh, thennslookup db— does the name resolve? (If yes, DNS is fine.)wget -qO- http://db:5432orprintenv DATABASE_URL— is the host right (db, notlocalhost)?- Startup-only failures point to ordering: add a Postgres healthcheck and
depends_on: condition: service_healthyso the API waits until the DB is truly ready.
🎯 Quick Quiz
Question 1: Why must you start Node with --inspect=0.0.0.0:9229 rather than the default --inspect in a container?
Question 2: Your container crashes on boot. Which command lets you poke around a fresh instance with the same config?
Question 3: An API connecting to localhost:5432 works locally but fails in Compose. Why?
Best Practices & Pitfalls
✅ Do
- Log to stdout/stderr with structured (JSON) output so
logsis greppable - Name the failing layer before choosing a tool — logs, shell, or debugger
- Bind the inspector to
0.0.0.0and publish port9229for host debugging - Map
localRoot/remoteRootso breakpoints actually bind - Add health endpoints and handle
SIGTERMfor clean, observable restarts - Address other services by name (
db,api), neverlocalhost
❌ Don't
- Expose the debug port (9229) in production images — it's a remote-code-execution risk
- Bake debug tools like
straceorgdbinto production images — install them ad hoc while debugging - Assume
execworks on a crashed container — userun --rm - Write logs to files inside the container — they vanish when it's recreated
- Ignore
SIGTERMand let Docker hard-kill in-flight requests
⚠️ Security: the inspector is a back door
An open --inspect port lets anyone who can reach it execute arbitrary code in your process. Keep it strictly in compose.override.yaml so it never travels to production, and never publish 9229 on a public interface.
Summary
🎉 Key Takeaways
- Three interfaces open the sealed box: logs, an
execshell, and an attached debugger - Locate the bug's layer first — host, Docker config, runtime, environment, or code
docker compose logs -fis your first look;--tailand--sincenarrow it down- Attach a real debugger by binding the inspector to
0.0.0.0:9229, publishing the port, and mappinglocalRoot/remoteRoot - Most "can't communicate" bugs are the
localhosttrap — use service names - Healthchecks and
SIGTERMhandling make failures loud and restarts clean
📚 Additional Resources
- Docker Docs — docker compose logs
- Docker Docs — docker compose exec
- Node.js — Debugging & the inspector protocol
- VS Code — Debugging apps in containers
🚀 What's Next?
You can now build, hot-reload, and debug a multi-container app on one machine. The next step is running many containers across many machines reliably — Introduction to Kubernetes — where these same ideas (health, logs, service discovery) scale up into pods, deployments, and services.
🎉 Box opened!
Containers no longer hide your bugs — you can read, enter, and step through them at will.