Containerization

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_URL but it was not passed to docker run
  • Different file paths: The app hardcodes /Users/shane/data instead 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 bcrypt or sharp compile 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:

  1. Use docker cp to extract files for inspection:
docker cp my-app:/app/dist/server.js ./server-from-container.js
  1. 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:

  1. Memory leaks: Event listeners not being removed, caches growing unbounded, closures retaining references
  2. Insufficient memory limit: Your app legitimately needs more memory than allocated
  3. 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 HEALTHCHECK instruction 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 tini or dumb-init as 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 like pino or winston with 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=384 so 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:latest tells you nothing.

  • Use .dockerignore to exclude unnecessary files. At minimum, exclude node_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.yml override 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

Powered by Contentful