Skip to main content

🐞 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 exec to 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:9229 and attach VS Code across the container boundary
  • Debug service-to-service networking with curl, DNS lookups, and docker 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.

Five debugging layers stacked from host system at the bottom to application code at the top Application Code your logic, frameworks, libraries Application Environment env vars, dependencies, config Container Runtime lifecycle, resources, processes Docker Configuration Dockerfile, Compose, volumes, networks Host System Docker install, OS, resources
Work top-down for code bugs, bottom-up for "it won't even start." Naming the layer first tells you which tool to grab.
SymptomLikely layerFirst tool
Container exits immediatelyDocker config / runtimedocker compose logs, config
"Cannot find module"App environmentexec then ls node_modules
Wrong config value at runtimeApp environmentexec … printenv
Service can't reach anotherDocker config (network)exec … curl, network inspect
Logic bug, bad responseApplication codeNode 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.

sequenceDiagram participant IDE as VS Code on host participant Port as Published port 9229 participant Node as Node inspector in container IDE->>Port: attach to localhost 9229 Port->>Node: forward to inspector on 0.0.0.0 Node-->>IDE: paused at breakpoint, send scope IDE->>Node: step over and inspect variables Node-->>IDE: updated stack and values

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
  1. package.json: a script that runs node --inspect=0.0.0.0:9229 src/index.js (via nodemon for reload).
  2. Compose: publish the port with - "9229:9229" and use that command.
  3. launch.json: an attach config on port 9229 with localRoot: "${workspaceFolder}" and remoteRoot: "/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
  1. docker compose logs -f api — confirm the error and its timing (only at startup?).
  2. docker compose exec api sh, then nslookup db — does the name resolve? (If yes, DNS is fine.)
  3. wget -qO- http://db:5432 or printenv DATABASE_URL — is the host right (db, not localhost)?
  4. Startup-only failures point to ordering: add a Postgres healthcheck and depends_on: condition: service_healthy so 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 logs is greppable
  • Name the failing layer before choosing a tool — logs, shell, or debugger
  • Bind the inspector to 0.0.0.0 and publish port 9229 for host debugging
  • Map localRoot/remoteRoot so breakpoints actually bind
  • Add health endpoints and handle SIGTERM for clean, observable restarts
  • Address other services by name (db, api), never localhost

❌ Don't

  • Expose the debug port (9229) in production images — it's a remote-code-execution risk
  • Bake debug tools like strace or gdb into production images — install them ad hoc while debugging
  • Assume exec works on a crashed container — use run --rm
  • Write logs to files inside the container — they vanish when it's recreated
  • Ignore SIGTERM and 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 exec shell, and an attached debugger
  • Locate the bug's layer first — host, Docker config, runtime, environment, or code
  • docker compose logs -f is your first look; --tail and --since narrow it down
  • Attach a real debugger by binding the inspector to 0.0.0.0:9229, publishing the port, and mapping localRoot/remoteRoot
  • Most "can't communicate" bugs are the localhost trap — use service names
  • Healthchecks and SIGTERM handling make failures loud and restarts clean

📚 Additional Resources

🚀 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.