Debugging Containers: Tools and Techniques
Systematic guide to debugging Docker containers covering log analysis, interactive exec sessions, network troubleshooting, Node.js debugging, and crash diagnosis.
Debugging Containers: Tools and Techniques
Overview
Debugging a containerized application is fundamentally different from debugging a process running directly on your machine. The isolation that makes containers powerful also makes them opaque when things go wrong. This article walks through the full debugging toolkit, from reading logs to attaching Node.js debuggers to diagnosing OOM kills, with the systematic approach you need to go from "my container keeps crashing" to a root cause and a fix.
Prerequisites
- Docker installed locally (Docker Desktop or Docker Engine 20.10+)
- Basic familiarity with Docker commands (
docker run,docker build,docker ps) - Node.js 18+ installed on the host machine
- A working understanding of Express.js and basic networking concepts
- Comfort with a terminal and shell commands
Docker Logs and Log Drivers
The first place you look when a container misbehaves is the logs. Docker captures everything written to stdout and stderr from PID 1 inside the container and makes it available through the docker logs command.
Basic Log Commands
# View all logs from a container
docker logs my-app
# Follow logs in real time (like tail -f)
docker logs -f my-app
# Show the last 100 lines
docker logs --tail 100 my-app
# Show logs since a specific time
docker logs --since 2026-02-08T10:00:00 my-app
# Show logs with timestamps
docker logs -t my-app
# Combine flags: last 50 lines with timestamps, then follow
docker logs -t --tail 50 -f my-app
Understanding Log Drivers
Docker supports multiple log drivers that determine where log output goes. The default is json-file, which stores logs as JSON on disk. The critical thing to understand is that docker logs only works with the json-file and journald drivers. If your production setup uses syslog, fluentd, or awslogs, the docker logs command returns nothing.
# Check which log driver a container is using
docker inspect --format='{{.HostConfig.LogConfig.Type}}' my-app
Output:
json-file
You can configure log rotation to prevent logs from eating all your disk space:
docker run -d \
--log-opt max-size=10m \
--log-opt max-file=3 \
--name my-app \
my-app:latest
Or set it globally in /etc/docker/daemon.json:
{
"log-driver": "json-file",
"log-opts": {
"max-size": "10m",
"max-file": "5"
}
}
Structured Logging Matters
If your Node.js app uses console.log("User logged in"), you are going to have a bad time debugging in production. Use structured logging from day one:
var pino = require("pino");
var logger = pino({ level: process.env.LOG_LEVEL || "info" });
app.get("/api/users/:id", function(req, res) {
logger.info({ userId: req.params.id, method: req.method }, "user lookup");
// ...
});
Structured JSON logs let you pipe docker logs output through jq for filtering:
# Find all error-level log entries
docker logs my-app 2>&1 | jq 'select(.level >= 50)'
# Find logs related to a specific user
docker logs my-app 2>&1 | jq 'select(.userId == "abc123")'
Interactive Debugging with Docker Exec
When logs are not enough, you need to get inside the running container. docker exec lets you run commands in a container that is already running.
# Open an interactive shell
docker exec -it my-app /bin/sh
# If bash is available (not in Alpine-based images by default)
docker exec -it my-app /bin/bash
# Run a single command
docker exec my-app cat /app/config.json
# Run as a specific user
docker exec -u root my-app apt-get update
# Set environment variables for the exec session
docker exec -e DEBUG=true my-app node -e "console.log(process.env.DEBUG)"
What to Check Inside a Container
Once you are inside, here is your checklist:
# Check environment variables (most common source of bugs)
env | sort
# Check if the expected files exist
ls -la /app/
cat /app/package.json | head -20
# Check DNS resolution
nslookup postgres-db
cat /etc/resolv.conf
# Check network connectivity
wget -qO- http://api-service:3000/health || echo "FAILED"
# Check what processes are running
ps aux
# Check disk usage
df -h
# Check memory usage
free -m
# Check if a port is being listened on
netstat -tlnp 2>/dev/null || ss -tlnp
One caveat: minimal images like alpine or distroless may not have these tools installed. If nslookup is missing, install it:
# Alpine
apk add --no-cache bind-tools curl
# Debian/Ubuntu
apt-get update && apt-get install -y dnsutils curl netcat-openbsd
Inspecting Container State
Docker Inspect
docker inspect gives you the complete configuration and state of a container in JSON format. It is the most information-dense command in the Docker CLI.
# Full output (usually too much)
docker inspect my-app
# Get the container's IP address
docker inspect --format='{{range .NetworkSettings.Networks}}{{.IPAddress}}{{end}}' my-app
# Check environment variables
docker inspect --format='{{range .Config.Env}}{{println .}}{{end}}' my-app
# Check the restart count (useful for crash loops)
docker inspect --format='{{.RestartCount}}' my-app
# Check the container's exit code
docker inspect --format='{{.State.ExitCode}}' my-app
# Check when the container started and if it's running
docker inspect --format='Status: {{.State.Status}}, Started: {{.State.StartedAt}}' my-app
# Check mounted volumes
docker inspect --format='{{range .Mounts}}{{.Source}} -> {{.Destination}}{{"\n"}}{{end}}' my-app
# Check the container's health check status
docker inspect --format='{{json .State.Health}}' my-app | jq .
Docker Stats
docker stats gives you a live view of resource consumption, similar to top but for containers:
# Live stats for all running containers
docker stats
# Stats for a specific container
docker stats my-app
# One-shot snapshot (useful in scripts)
docker stats --no-stream my-app
Output:
CONTAINER ID NAME CPU % MEM USAGE / LIMIT MEM % NET I/O BLOCK I/O
a1b2c3d4e5f6 my-app 2.34% 128.5MiB / 512MiB 25.10% 1.45kB / 892B 8.19MB / 0B
If memory usage is constantly climbing toward the limit, you have a memory leak. If CPU is pegged at 100%, you have a runaway loop or compute-heavy operation blocking the event loop.
Docker Top
docker top shows the processes running inside a container without needing to exec into it:
docker top my-app
Output:
UID PID PPID C STIME TTY TIME CMD
node 1234 1233 0 10:00 ? 00:00:05 node /app/server.js
node 1240 1234 0 10:00 ? 00:00:01 /app/node_modules/.bin/...
If you see PID 1 is your node process, that is important. When PID 1 receives SIGTERM, it must handle it properly or Docker will forcibly kill the container after the grace period (default 10 seconds).
Debugging Networking Issues
Networking problems are the most common category of container debugging I encounter. A container's network is isolated by default, and there are several layers where things can break.
DNS Resolution
Containers in a Docker network resolve other container names via Docker's embedded DNS server at 127.0.0.11. If DNS resolution fails, inter-container communication breaks entirely.
# From inside the container
nslookup postgres-db
Expected output:
Server: 127.0.0.11
Address 1: 127.0.0.11
Name: postgres-db
Address 1: 172.18.0.3 postgres-db.my-network
If you get NXDOMAIN or a timeout, the containers are probably not on the same Docker network.
# Check which networks a container is connected to
docker inspect --format='{{json .NetworkSettings.Networks}}' my-app | jq 'keys'
# List all containers on a specific network
docker network inspect my-network --format='{{range .Containers}}{{.Name}} {{end}}'
Port Binding
A classic mistake is confusing container ports with host ports.
# This publishes container port 3000 on host port 8080
docker run -p 8080:3000 my-app
# This only exposes the port to other containers, NOT the host
docker run --expose 3000 my-app
Check what ports are actually published:
docker port my-app
Output:
3000/tcp -> 0.0.0.0:8080
Inter-Container Communication
If two containers need to talk to each other, they must be on the same Docker network. The default bridge network does not support DNS-based service discovery. You need a user-defined network.
# Create a network
docker network create app-network
# Run containers on that network
docker run -d --name postgres-db --network app-network postgres:16
docker run -d --name my-app --network app-network my-app:latest
# Now my-app can reach postgres-db by hostname
docker exec my-app wget -qO- http://postgres-db:5432 || echo "Connection attempt made"
With Docker Compose, all services are automatically placed on the same network:
# docker-compose.yml
version: "3.8"
services:
app:
build: .
ports:
- "8080:3000"
depends_on:
- db
environment:
- DATABASE_URL=postgresql://user:pass@db:5432/mydb
db:
image: postgres:16
environment:
- POSTGRES_PASSWORD=pass
- POSTGRES_USER=user
- POSTGRES_DB=mydb
Testing Connectivity From a Temporary Container
Sometimes you want to test network access without modifying your running containers. Spin up a throwaway debug container on the same network:
docker run --rm -it --network app-network alpine sh -c \
"apk add --no-cache curl && curl -v http://my-app:3000/health"
Debugging Build Failures
Layer Caching Issues
Docker caches each layer of your build. When you change a file, every layer after the COPY that includes that file gets invalidated. A common mistake is copying package.json and the entire source tree in the same layer:
# BAD: Any source change invalidates the npm install cache
COPY . /app
RUN npm install
# GOOD: npm install is only re-run when package.json changes
COPY package.json package-lock.json /app/
RUN npm install --production
COPY . /app
When you suspect caching is causing issues, force a clean build:
docker build --no-cache -t my-app .
Multi-Stage Build Problems
Multi-stage builds can be confusing to debug because intermediate stages are not kept by default. You can build a specific stage to inspect it:
# Dockerfile
FROM node:20-alpine AS builder
WORKDIR /app
COPY package*.json ./
RUN npm ci
COPY . .
RUN npm run build
FROM node:20-alpine AS production
WORKDIR /app
COPY --from=builder /app/dist ./dist
COPY --from=builder /app/node_modules ./node_modules
COPY --from=builder /app/package.json ./
CMD ["node", "dist/server.js"]
# Build only the builder stage and inspect it
docker build --target builder -t my-app:debug .
docker run --rm -it my-app:debug /bin/sh
# Inside the container, check that the build output exists
ls -la /app/dist/
Reading Build Output
When a build fails, Docker shows which step failed. Read the output carefully:
docker build -t my-app . 2>&1 | tee build.log
Step 5/8 : RUN npm ci
---> Running in 3a4b5c6d7e8f
npm ERR! code ENOENT
npm ERR! syscall open
npm ERR! path /app/package-lock.json
npm ERR! errno -2
npm ERR! enoent ENOENT: no such file or directory, open '/app/package-lock.json'
This tells you package-lock.json was not copied before npm ci ran. Check your COPY instructions and your .dockerignore file.
Debugging Node.js Apps Inside Containers
Attaching the Node.js Debugger
You can attach Chrome DevTools or VS Code to a Node.js process running inside a container. The key is to expose the debug port and start Node.js with the inspect flag.
# Dockerfile for development debugging
FROM node:20-alpine
WORKDIR /app
COPY package*.json ./
RUN npm install
COPY . .
# Expose both the app port and the debug port
EXPOSE 3000 9229
CMD ["node", "--inspect=0.0.0.0:9229", "server.js"]
# Run with the debug port published
docker run -p 3000:3000 -p 9229:9229 my-app:debug
Then open Chrome and navigate to chrome://inspect. Click "Configure" and add localhost:9229. Your container's Node.js process will appear as a remote target.
For VS Code, add a launch configuration:
{
"type": "node",
"request": "attach",
"name": "Docker: Attach to Node",
"port": 9229,
"address": "localhost",
"localRoot": "${workspaceFolder}",
"remoteRoot": "/app",
"protocol": "inspector"
}
Debugging Environment Differences
The number one cause of "it works on my machine but not in the container" is environment differences. Check these systematically:
// debug-env.js - drop this into your project temporarily
var os = require("os");
console.log("Node version:", process.version);
console.log("Platform:", os.platform());
console.log("Architecture:", os.arch());
console.log("Working directory:", process.cwd());
console.log("Memory:", Math.round(os.totalmem() / 1024 / 1024) + "MB total");
console.log("Environment variables:");
var keys = Object.keys(process.env).sort();
keys.forEach(function(key) {
if (key.indexOf("SECRET") === -1 && key.indexOf("PASSWORD") === -1 && key.indexOf("TOKEN") === -1) {
console.log(" " + key + "=" + process.env[key]);
} else {
console.log(" " + key + "=****");
}
});
docker exec my-app node /app/debug-env.js
Common environment-related issues:
- Missing environment variables: The app expects
DATABASE_URLbut it was not passed todocker run - Different file paths: The app hardcodes
/Users/shane/datainstead of using a relative or configurable path - Different Node.js versions: Your host runs Node 22, the container runs Node 18
- Missing native dependencies: Packages like
bcryptorsharpcompile native bindings; the ones built on macOS will not work in a Linux container
Source Maps in Containers
If you are running transpiled or bundled code inside the container, stack traces will be useless without source maps. Make sure your build process generates them and that Node.js can find them:
// At the very top of your entry point
require("source-map-support").install();
var express = require("express");
var app = express();
// ...
# Make sure source maps are included in the production image
COPY --from=builder /app/dist ./dist
# Include .map files
COPY --from=builder /app/dist/*.map ./dist/
Using Debug and Distroless Images
Debug Images
When your production container does not have the tools you need, use a debug sidecar or swap to a debug-friendly base image temporarily.
# Alpine with debugging tools
docker run --rm -it \
--network container:my-app \
--pid container:my-app \
alpine sh -c "apk add --no-cache strace curl bind-tools procps && sh"
Using --network container:my-app shares the network namespace, so you see the same interfaces, IPs, and ports. Using --pid container:my-app lets you see and interact with processes in the target container.
Distroless Images
Google's distroless images contain only your application and its runtime dependencies. No shell. No package manager. No ls. This makes debugging harder, but they are significantly more secure.
# Production: distroless
FROM gcr.io/distroless/nodejs20-debian12
WORKDIR /app
COPY --from=builder /app/dist ./dist
COPY --from=builder /app/node_modules ./node_modules
CMD ["dist/server.js"]
To debug a distroless container, you have two options:
- Use
docker cpto extract files for inspection:
docker cp my-app:/app/dist/server.js ./server-from-container.js
- Use a debug variant of the distroless image:
# Debug build with busybox shell included
FROM gcr.io/distroless/nodejs20-debian12:debug
docker run --rm -it --entrypoint /busybox/sh my-app:debug
Container Filesystem Inspection
Sometimes you need to look at the filesystem of a container that will not start. You cannot docker exec into a stopped container, but you can still inspect its filesystem.
# Create a container without starting it
docker create --name temp-inspect my-app:latest
# Copy files out
docker cp temp-inspect:/app/package.json ./package.json
docker cp temp-inspect:/app/ ./app-contents/
# Or export the entire filesystem as a tar
docker export temp-inspect > container-fs.tar
tar tf container-fs.tar | head -30
# Clean up
docker rm temp-inspect
You can also look at the image layers directly:
# See the history of an image (what commands created each layer)
docker history my-app:latest
# Dive is a third-party tool for exploring image layers interactively
# https://github.com/wagoodman/dive
dive my-app:latest
Comparing Working and Broken Images
When a new build breaks things, diff the images:
# Save the file listing from both images
docker run --rm my-app:working find /app -type f | sort > working-files.txt
docker run --rm my-app:broken find /app -type f | sort > broken-files.txt
diff working-files.txt broken-files.txt
Debugging Crash Loops and OOM Kills
Crash Loops
A container that keeps restarting is in a crash loop. Docker's restart policies (--restart=always or --restart=on-failure) will keep restarting it, but each restart loses the previous state.
# Check the restart count and last exit code
docker inspect --format='Restarts: {{.RestartCount}}, ExitCode: {{.State.ExitCode}}, Error: {{.State.Error}}' my-app
Common exit codes:
| Exit Code | Meaning |
|---|---|
| 0 | Normal exit |
| 1 | Application error (unhandled exception, failed assertion) |
| 137 | Killed by SIGKILL (OOM kill or docker kill) |
| 139 | Segmentation fault (native module crash) |
| 143 | Killed by SIGTERM (normal shutdown via docker stop) |
To capture logs from a crash loop before the container restarts:
# Disable the restart policy temporarily
docker update --restart=no my-app
# Now start it and watch
docker start my-app && docker logs -f my-app
OOM Kills (Out of Memory)
When a container exceeds its memory limit, the Linux kernel kills it. This shows up as exit code 137 and is one of the most frustrating debugging scenarios because there is no graceful shutdown.
# Check if the container was OOM-killed
docker inspect --format='{{.State.OOMKilled}}' my-app
Output:
true
To identify the memory problem:
# Run with a memory limit and monitor usage
docker run -d --name my-app --memory=256m --memory-swap=256m my-app:latest
docker stats my-app
Inside your Node.js app, you can track memory usage:
// Add to your app for memory monitoring
setInterval(function() {
var usage = process.memoryUsage();
console.log(JSON.stringify({
level: "debug",
type: "memory",
rss: Math.round(usage.rss / 1024 / 1024) + "MB",
heapTotal: Math.round(usage.heapTotal / 1024 / 1024) + "MB",
heapUsed: Math.round(usage.heapUsed / 1024 / 1024) + "MB",
external: Math.round(usage.external / 1024 / 1024) + "MB"
}));
}, 30000);
If your container is getting OOM-killed, the typical causes are:
- Memory leaks: Event listeners not being removed, caches growing unbounded, closures retaining references
- Insufficient memory limit: Your app legitimately needs more memory than allocated
- Node.js heap size: The default V8 heap limit may exceed your container's memory limit
Set the Node.js heap limit to stay within the container's memory budget:
# Container has 512MB, give Node.js 384MB for heap (leave room for native memory)
docker run -d --memory=512m -e NODE_OPTIONS="--max-old-space-size=384" my-app:latest
Generating Heap Dumps
If you suspect a memory leak, take a heap dump from inside the container:
var v8 = require("v8");
var fs = require("fs");
var path = require("path");
function writeHeapSnapshot() {
var filename = v8.writeHeapSnapshot();
console.log("Heap snapshot written to: " + filename);
return filename;
}
// Expose via an endpoint (only in development)
if (process.env.NODE_ENV !== "production") {
app.get("/debug/heapdump", function(req, res) {
var file = writeHeapSnapshot();
res.json({ file: file });
});
}
# Trigger the heap dump
docker exec my-app curl -s http://localhost:3000/debug/heapdump
# Copy it out of the container
docker cp my-app:/app/Heap.20260208.120000.1234.0.001.heapsnapshot ./
# Open it in Chrome DevTools -> Memory tab
Complete Working Example
Let us walk through a real debugging session. You have a Node.js Express app that connects to PostgreSQL. It works perfectly on your machine, but the container keeps crashing in the Docker Compose stack.
The Application
// server.js
var express = require("express");
var { Pool } = require("pg");
var app = express();
var port = process.env.PORT || 3000;
var pool = new Pool({
connectionString: process.env.DATABASE_URL
});
app.get("/health", function(req, res) {
pool.query("SELECT 1", function(err) {
if (err) {
res.status(500).json({ status: "unhealthy", error: err.message });
} else {
res.json({ status: "healthy" });
}
});
});
app.get("/api/users", function(req, res) {
pool.query("SELECT id, name, email FROM users", function(err, result) {
if (err) {
res.status(500).json({ error: err.message });
} else {
res.json(result.rows);
}
});
});
app.listen(port, function() {
console.log("Server running on port " + port);
});
FROM node:20-alpine
WORKDIR /app
COPY package*.json ./
RUN npm ci --production
COPY . .
EXPOSE 3000
CMD ["node", "server.js"]
# docker-compose.yml
version: "3.8"
services:
app:
build: .
ports:
- "8080:3000"
environment:
- PORT=3000
depends_on:
- db
restart: on-failure
db:
image: postgres:16-alpine
environment:
- POSTGRES_USER=appuser
- POSTGRES_PASSWORD=secret123
- POSTGRES_DB=myapp
volumes:
- pgdata:/var/lib/postgresql/data
volumes:
pgdata:
Step 1: Observe the Problem
docker compose up -d
docker compose ps
Output:
NAME IMAGE COMMAND SERVICE STATUS
myapp-app-1 myapp-app "node server.js" app restarting (1)
myapp-db-1 postgres:16-alpine "docker-entrypoint.s..." db running
The app is in a restart loop. The database is fine.
Step 2: Check the Logs
docker compose logs app
Output:
myapp-app-1 | Server running on port 3000
myapp-app-1 | /app/node_modules/pg/lib/connection.js:73
myapp-app-1 | this._handleError(new Error('Connection terminated unexpectedly'));
myapp-app-1 | ^
myapp-app-1 |
myapp-app-1 | Error: connect ECONNREFUSED 127.0.0.1:5432
myapp-app-1 | at TCPConnectWrap.afterConnect [as oncomplete] (node:net:1595:16)
myapp-app-1 |
myapp-app-1 | Node.js v20.11.0
The app is trying to connect to 127.0.0.1:5432. That is the loopback address, which means it is trying to connect to PostgreSQL on its own container, not the db service. This happens when DATABASE_URL is not set and pg falls back to defaults.
Step 3: Verify the Environment
docker compose exec app env | grep DATABASE
Output:
(empty - no output)
Confirmed. DATABASE_URL is not set. We can see in docker-compose.yml that we defined PORT but forgot DATABASE_URL.
Step 4: Check That the Database Is Actually Reachable
Before fixing the env var, verify network connectivity:
# Check that the containers are on the same network
docker network ls | grep myapp
Output:
a1b2c3d4e5f6 myapp_default bridge local
# Test DNS resolution from the app container
docker compose exec app nslookup db
Output:
Server: 127.0.0.11
Address 1: 127.0.0.11
Name: db
Address 1: 172.20.0.2 myapp-db-1.myapp_default
DNS works. The db hostname resolves correctly.
# Test TCP connectivity to the database port
docker compose exec app sh -c "nc -zv db 5432"
Output:
db (172.20.0.2:5432) open
The database is reachable. The only problem is the missing environment variable.
Step 5: Apply the Fix
# docker-compose.yml (fixed)
services:
app:
build: .
ports:
- "8080:3000"
environment:
- PORT=3000
- DATABASE_URL=postgresql://appuser:secret123@db:5432/myapp
depends_on:
- db
restart: on-failure
docker compose up -d
docker compose logs -f app
Output:
myapp-app-1 | Server running on port 3000
No crash. Verify the health endpoint:
curl http://localhost:8080/health
Output:
{"status":"healthy"}
Step 6: Prevent This in the Future
Add startup validation to your application so missing configuration fails fast with a clear message:
// config.js
var requiredVars = ["DATABASE_URL", "PORT"];
var missing = [];
requiredVars.forEach(function(varName) {
if (!process.env[varName]) {
missing.push(varName);
}
});
if (missing.length > 0) {
console.error("FATAL: Missing required environment variables: " + missing.join(", "));
console.error("The application cannot start without these variables.");
process.exit(1);
}
module.exports = {
databaseUrl: process.env.DATABASE_URL,
port: parseInt(process.env.PORT, 10) || 3000
};
Now instead of a cryptic ECONNREFUSED, you get:
FATAL: Missing required environment variables: DATABASE_URL
The application cannot start without these variables.
Common Issues and Troubleshooting
1. "exec format error" When Running a Container
exec /app/server.js: exec format error
This happens when the image was built for a different CPU architecture than the host. Typically, you built on an M1/M2 Mac (ARM64) and are trying to run on a Linux server (AMD64).
Fix:
# Build for the target platform explicitly
docker build --platform linux/amd64 -t my-app .
# Or use buildx for multi-arch builds
docker buildx build --platform linux/amd64,linux/arm64 -t my-app .
2. "EACCES: permission denied" Inside the Container
Error: EACCES: permission denied, open '/app/data/cache.json'
Your Dockerfile might create files as root, but your CMD runs as a non-root user (which is a security best practice). The non-root user cannot write to directories owned by root.
Fix:
FROM node:20-alpine
# Create app directory
WORKDIR /app
# Create a non-root user
RUN addgroup -S appgroup && adduser -S appuser -G appgroup
# Copy files and install deps as root
COPY package*.json ./
RUN npm ci --production
COPY . .
# Create data directory and set ownership BEFORE switching users
RUN mkdir -p /app/data && chown -R appuser:appgroup /app
# Switch to non-root user
USER appuser
CMD ["node", "server.js"]
3. Container Exits Immediately With No Logs
docker run -d my-app
docker ps -a
# STATUS: Exited (0) 1 second ago
docker logs my-app
# (empty)
This usually means your CMD process exited immediately. Common causes:
- The entrypoint script has Windows-style line endings (
\r\n) and the Linux shell cannot parse it - The command is wrong (typo in filename, wrong path)
- The process forks to the background, so PID 1 exits
Fix for line endings:
# Check for carriage returns
docker run --rm -it my-app xxd /app/entrypoint.sh | head -5
# Fix: add to Dockerfile
RUN sed -i 's/\r$//' /app/entrypoint.sh
Fix for backgrounding:
// WRONG: This will cause the container to exit
var { spawn } = require("child_process");
spawn("node", ["worker.js"], { detached: true, stdio: "ignore" });
process.exit(0);
// RIGHT: Keep the parent process running
var { fork } = require("child_process");
var worker = fork("./worker.js");
worker.on("exit", function(code) {
console.log("Worker exited with code " + code);
process.exit(code);
});
4. "bind: address already in use" Inside the Container
Error: listen EADDRINUSE: address already in use :::3000
This means another process inside the container is already listening on that port, or a previous instance did not shut down cleanly. This is common with process managers like pm2 or when using nodemon in development.
Fix:
# Find what's using the port inside the container
docker exec my-app sh -c "ss -tlnp | grep 3000"
# Or kill the process and let the container restart
docker restart my-app
If you are running nodemon in a development container, make sure signal forwarding works:
# Use tini as PID 1 for proper signal handling
RUN apk add --no-cache tini
ENTRYPOINT ["/sbin/tini", "--"]
CMD ["npx", "nodemon", "server.js"]
Best Practices
Always validate configuration at startup. Check for required environment variables, database connectivity, and file permissions before your app starts serving traffic. Fail fast and fail loudly with a clear error message that tells the operator exactly what is wrong.
Use health checks in your Dockerfile and Compose files. A
HEALTHCHECKinstruction lets Docker (and orchestrators like Kubernetes) know when your app is genuinely ready, not just that the process is running. Use an HTTP endpoint, not just a process check.
HEALTHCHECK --interval=30s --timeout=5s --start-period=10s --retries=3 \
CMD wget -qO- http://localhost:3000/health || exit 1
Run as a non-root user. This limits the blast radius of a container escape vulnerability. It also surfaces permission bugs during development instead of production.
Use
tiniordumb-initas PID 1. Node.js does not handle signals properly when running as PID 1. An init process ensures SIGTERM is forwarded correctly and zombie processes are reaped.Log in structured JSON format. When you are searching through thousands of log lines across dozens of containers,
console.log("something happened")is useless. Use a structured logger likepinoorwinstonwith JSON output, and include correlation IDs for request tracing.Set memory limits explicitly and tune Node.js accordingly. If your container has a 512MB limit, set
--max-old-space-size=384so Node.js garbage-collects before the OOM killer strikes. Leave headroom for native memory allocations.Keep production images minimal. Use multi-stage builds. Do not include
devDependencies, build tools, or test files in production images. Fewer files means a smaller attack surface and faster debugging because there is less noise.Tag images with specific versions, not just
latest. When debugging, you need to know exactly which version of the code is running. Use git commit SHAs or semantic versions as image tags.my-app:latesttells you nothing.Use
.dockerignoreto exclude unnecessary files. At minimum, excludenode_modules,.git,.env, test files, and documentation. This reduces build context size and prevents secrets from leaking into images.
# .dockerignore
node_modules
.git
.env
*.md
tests/
coverage/
.vscode/
- Add a
docker-compose.debug.ymloverride for development. Keep your production compose file clean and layer debugging tools on top with an override file that adds volume mounts, debug ports, and development environment variables.
docker compose -f docker-compose.yml -f docker-compose.debug.yml up
References
- Docker CLI Reference: docker logs
- Docker CLI Reference: docker exec
- Docker CLI Reference: docker inspect
- Node.js Debugging Guide
- Docker Compose Networking
- Google Distroless Container Images
- Dive: A Tool for Exploring Docker Image Layers
- Tini: A Tiny Init for Containers
- Pino: Super Fast Node.js Logger
- Docker Container Exit Codes Explained
