30 Days of DevOps — a series by @syssignals Every article is a working project. Every command is verified. No fluff.

The problem with “it works on my machine”

A new developer joins your team. They clone the repo, follow the README, spend 3 hours debugging a Node version mismatch, give up, and ping you on Slack.

You’ve been there. You’ve been both people in that story.

Docker was supposed to fix this. And it does — but only if you use it correctly. Most teams don’t. They write a Dockerfile that works, ship it, and never look back. That Dockerfile ends up in production running as root, carrying 800 MB of build tools nobody needs, with 47 CVEs sitting quietly in a base image from 2021.

This article fixes that. You’ll take a real Node.js application from a 1.2 GB naive image down to a 45 MB hardened production image. Every decision is explained. Every command is verified. By the end you’ll have a Dockerfile template you can drop into any project and trust.


What you’ll build

A production-grade Docker setup for a Node.js REST API, including:

  • A naive Dockerfile (the before — so you understand what you’re fixing)
  • A multi-stage Dockerfile that reduces image size by ~96%
  • A hardened production image running as a non-root user
  • A .dockerignore that prevents secrets and junk from leaking into images
  • Docker Compose for local development with hot reload
  • A Docker Scout vulnerability scan comparing naive vs hardened image
  • A reusable docker/ folder structure for any project

Estimated time: 60 minutes
Final image size: ~45 MB (down from ~1.2 GB)

Here is the complete picture of what this build produces:

%%{init: {'theme': 'dark'}}%%
flowchart TD
    SRC(["Your Node.js App\nsrc/  ·  package.json"]):::src

    subgraph BUILD ["Multi-Stage Dockerfile"]
        DEPS["Stage 1 · deps\nnode:20-alpine\nnpm ci --omit=dev"]:::stage
        TEST["Stage 2 · test\nnode:20-alpine\nnpm run test:ci"]:::stage
        FINAL["Stage 3 · production\ndistroless/nodejs20\nUSER nonroot"]:::stage
        DEPS -->|"prod node_modules only"| FINAL
        TEST -->|"CI gate — must pass"| FINAL
    end

    DEV_IMG["myapp:dev\nnodemon · hot reload\ndocker compose up"]:::dev
    PROD_IMG["myapp:production\n47 MB · uid 65532\nzero critical CVEs"]:::prod

    SRC --> BUILD
    DEPS --> DEV_IMG
    FINAL --> PROD_IMG

    classDef src fill:#1a2744,stroke:#58a6ff,color:#e6edf3
    classDef stage fill:#1c2128,stroke:#30363d,color:#8b949e
    classDef dev fill:#1a2744,stroke:#58a6ff,color:#79b8ff
    classDef prod fill:#0d2818,stroke:#3fb950,color:#3fb950

Prerequisites

Operating system

OS Status Notes
Ubuntu 22.04 LTS Recommended All commands tested here
Ubuntu 20.04 LTS Supported Works identically
macOS 13+ (Sonoma/Ventura) Supported Use Docker Desktop for Mac
macOS 12 (Monterey) Supported Use Docker Desktop for Mac
Windows 11 Supported Requires WSL2 + Docker Desktop
Windows 10 (21H2+) Supported Requires WSL2 + Docker Desktop

Windows users: All commands must be run inside WSL2 (Ubuntu), not PowerShell or CMD. Docker Desktop routes WSL2 commands through the same daemon. Open Ubuntu from the Start Menu and work from there.


Required software

1. Docker Engine 24.0+ or Docker Desktop 4.20+

# Check if Docker is already installed
docker --version
# Expected: Docker version 24.x.x or higher

docker info | grep "Server Version"
# Expected: Server Version: 24.x.x

Install Docker Engine on Ubuntu (skip if already installed):

# Remove old versions
sudo apt-get remove docker docker-engine docker.io containerd runc 2>/dev/null

# Install dependencies
sudo apt-get update
sudo apt-get install -y ca-certificates curl gnupg lsb-release

# Add Docker's official GPG key
sudo mkdir -p /etc/apt/keyrings
curl -fsSL https://download.docker.com/linux/ubuntu/gpg | \
  sudo gpg --dearmor -o /etc/apt/keyrings/docker.gpg

# Add the repository
echo \
  "deb [arch=$(dpkg --print-architecture) signed-by=/etc/apt/keyrings/docker.gpg] \
  https://download.docker.com/linux/ubuntu \
  $(lsb_release -cs) stable" | \
  sudo tee /etc/apt/sources.list.d/docker.list > /dev/null

# Install Docker Engine, CLI, containerd, and plugins
sudo apt-get update
sudo apt-get install -y docker-ce docker-ce-cli containerd.io \
  docker-buildx-plugin docker-compose-plugin

# Add your user to the docker group (avoids sudo on every command)
sudo usermod -aG docker $USER

# Apply group membership without logout
newgrp docker

# Verify installation
docker run --rm hello-world

Expected final line:

Hello from Docker!

2. Docker Buildx (for BuildKit cache mounts)

# Verify Buildx is available
docker buildx version
# Expected: github.com/docker/buildx v0.12.x linux/amd64

# Enable BuildKit globally (set as default builder)
docker buildx create --name mybuilder --use
docker buildx inspect --bootstrap

Expected output (last line):

[+] Building 4.2s (1/1) FINISHED

3. Docker Scout (for vulnerability scanning)

# Install Docker Scout CLI plugin
curl -fsSL https://raw.githubusercontent.com/docker/scout-cli/main/install.sh | sh

# Verify
docker scout version
# Expected: docker scout version v1.x.x

If curl isn’t available: sudo apt-get install -y curl

4. Node.js 20 LTS (for the sample application)

# Install via nvm — avoids permission issues with global packages
curl -o- https://raw.githubusercontent.com/nvm-sh/nvm/v0.39.7/install.sh | bash
source ~/.bashrc

nvm install 20
nvm use 20
nvm alias default 20

# Verify
node --version   # v20.x.x
npm --version    # 10.x.x

5. IDE recommendations

IDE Recommended extensions
VS Code (recommended) Docker (ms-azuretools.vscode-docker), Remote - WSL
JetBrains IDEs Docker plugin (bundled)
Vim / Neovim dockerfile.vim, coc-docker

The VS Code Docker extension adds Dockerfile syntax highlighting, image management from the sidebar, and build error detection inline. Not required but saves debugging time.


Full environment check

Run this before starting. If anything fails, fix it before continuing:

echo "=== Docker ===" && docker --version && \
echo "=== Buildx ===" && docker buildx version && \
echo "=== Compose ===" && docker compose version && \
echo "=== Node ===" && node --version && \
echo "=== npm ===" && npm --version && \
echo "=== Scout ===" && docker scout version 2>/dev/null | head -1 && \
echo "" && echo "All checks passed. Ready to build."

Expected output:

=== Docker ===
Docker version 24.0.7, build afdd53b
=== Buildx ===
github.com/docker/buildx v0.12.1 linux/amd64
=== Compose ===
Docker Compose version v2.24.5
=== Node ===
v20.11.0
=== npm ===
10.2.4
=== Scout ===
docker scout version v1.4.0

All checks passed. Ready to build.

Part 1: The sample application

We need a real application — not a toy. This is a Node.js REST API with actual dependencies: Express, input validation, security middleware, and structured logging. Realistic enough that the image size problem is genuine.

Step 1: Create the project structure

mkdir docker-best-practices && cd docker-best-practices
npm init -y
mkdir -p src/routes src/middleware

Step 2: Install dependencies

# Production dependencies — go into the final image
npm install express helmet cors morgan zod dotenv

# Development dependencies — must NEVER go into a production image
npm install --save-dev nodemon jest supertest

Verify the size impact:

du -sh node_modules/
# ~75MB — this entire directory should never reach a production image

Step 3: Create the application source

src/index.js — application entry point:

'use strict';

const express = require('express');
const helmet = require('helmet');
const cors = require('cors');
const morgan = require('morgan');
const { healthRouter } = require('./routes/health');
const { usersRouter } = require('./routes/users');
const { errorHandler } = require('./middleware/errorHandler');

const app = express();
const PORT = process.env.PORT || 3000;

app.use(helmet());
app.use(cors({
  origin: process.env.ALLOWED_ORIGINS?.split(',') || ['http://localhost:3000'],
}));
app.use(morgan(process.env.NODE_ENV === 'production' ? 'combined' : 'dev'));
app.use(express.json({ limit: '10kb' }));

app.use('/health', healthRouter);
app.use('/api/users', usersRouter);

app.use((req, res) => res.status(404).json({ error: 'Route not found' }));
app.use(errorHandler);

const server = app.listen(PORT, '0.0.0.0', () => {
  console.log(`Server running on port ${PORT} [${process.env.NODE_ENV || 'development'}]`);
});

process.on('SIGTERM', () => {
  console.log('SIGTERM received — shutting down gracefully');
  server.close(() => process.exit(0));
});

process.on('SIGINT', () => {
  server.close(() => process.exit(0));
});

module.exports = { app };

src/routes/health.js:

'use strict';

const { Router } = require('express');
const router = Router();

router.get('/', (req, res) => {
  res.json({
    status: 'healthy',
    timestamp: new Date().toISOString(),
    uptime: Math.floor(process.uptime()),
    environment: process.env.NODE_ENV || 'development',
  });
});

router.get('/ready', (req, res) => res.json({ status: 'ready' }));

module.exports = { healthRouter: router };

src/routes/users.js:

'use strict';

const { Router } = require('express');
const { z } = require('zod');

const router = Router();

const UserSchema = z.object({
  name: z.string().min(1).max(100),
  email: z.string().email(),
  role: z.enum(['admin', 'user', 'viewer']).default('user'),
});

const db = new Map([
  ['1', { id: '1', name: 'Alice', email: 'alice@example.com', role: 'admin' }],
  ['2', { id: '2', name: 'Bob',   email: 'bob@example.com',   role: 'user'  }],
]);

router.get('/', (req, res) => {
  res.json({ users: Array.from(db.values()), total: db.size });
});

router.get('/:id', (req, res) => {
  const user = db.get(req.params.id);
  if (!user) return res.status(404).json({ error: 'User not found' });
  res.json(user);
});

router.post('/', (req, res) => {
  const result = UserSchema.safeParse(req.body);
  if (!result.success) {
    return res.status(400).json({ error: 'Validation failed', details: result.error.flatten() });
  }
  const id = String(Date.now());
  const user = { id, ...result.data };
  db.set(id, user);
  res.status(201).json(user);
});

module.exports = { usersRouter: router };

src/middleware/errorHandler.js:

'use strict';

const errorHandler = (err, req, res, next) => {
  const status = err.statusCode || err.status || 500;
  const message = process.env.NODE_ENV === 'production' ? 'An error occurred' : err.message;

  console.error({ error: err.message, path: req.path, method: req.method });
  res.status(status).json({ error: message });
};

module.exports = { errorHandler };

.env.example — document required variables (commit this, never .env):

cat > .env.example << 'EOF'
NODE_ENV=development
PORT=3000
ALLOWED_ORIGINS=http://localhost:3000
EOF

Update package.json scripts:

node -e "
const fs = require('fs');
const pkg = JSON.parse(fs.readFileSync('package.json', 'utf8'));
pkg.main = 'src/index.js';
pkg.engines = { node: '>=20.0.0' };
pkg.scripts = {
  start: 'node src/index.js',
  dev: 'nodemon src/index.js',
  test: 'jest --coverage',
  'test:ci': 'jest --ci --forceExit'
};
fs.writeFileSync('package.json', JSON.stringify(pkg, null, 2));
console.log('Updated package.json');
"

Verify the app works outside Docker first:

NODE_ENV=development node src/index.js &
APP_PID=$!
sleep 1

curl -s http://localhost:3000/health | python3 -m json.tool

kill $APP_PID 2>/dev/null

Expected output:

{
    "status": "healthy",
    "timestamp": "2025-01-15T10:23:45.123Z",
    "uptime": 0,
    "environment": "development"
}

Part 2: The naive Dockerfile — the before picture

This is the Dockerfile most tutorials show you. It works. It is also dangerous.

cat > Dockerfile.naive << 'EOF'
FROM node:20

WORKDIR /app

COPY . .

RUN npm install

EXPOSE 3000

CMD ["node", "src/index.js"]
EOF

Build it and inspect:

docker build -f Dockerfile.naive -t myapp:naive .

Expected build output:

[+] Building 42.1s (8/8) FINISHED
 => [internal] load build definition from Dockerfile.naive
 => [1/4] FROM docker.io/library/node:20
 => [2/4] WORKDIR /app
 => [3/4] COPY . .
 => [4/4] RUN npm install
 => exporting to image

Now check what you actually built:

# Image size
docker images myapp:naive --format "Size: "
# Size: 1.21GB

# Is it running as root?
docker run --rm myapp:naive whoami
# root   ← critical security problem

# Are devDependencies included?
docker run --rm myapp:naive ls node_modules | grep nodemon
# nodemon   ← dev tools in production

# How many system tools are exposed?
docker run --rm myapp:naive which curl bash apt wget
# /usr/bin/curl
# /bin/bash
# /usr/bin/apt
# /usr/bin/wget
# Every one of these is an attacker's tool

The naive image has three fundamental problems:

  1. 1.21 GB — the full Debian + Node.js + devDependencies + build tools
  2. Runs as root — any container escape gives root on the host
  3. Full shell and system utilities — an attacker who gets RCE has a full toolkit

Here is exactly what each image contains, and what the production image leaves out:

%%{init: {'theme': 'dark'}}%%
flowchart LR
    subgraph NAIVE ["❌  Naive Image · 1.21 GB · runs as root"]
        direction TB
        N1["node:20 Debian base\n~1.0 GB"]:::bad
        N2["devDependencies\nnodemon · jest · supertest"]:::bad
        N3["Shell: bash · curl · wget · apt"]:::bad
        N4["Build tools: gcc · python · make"]:::bad
        N5["Your app source"]:::neutral
        N1 --> N2 --> N3 --> N4 --> N5
    end

    subgraph PROD ["✓  Production Image · 47 MB · uid 65532"]
        direction TB
        P1["distroless/nodejs20-debian12\n~28 MB"]:::good
        P2["prod node_modules only\n~19 MB"]:::good
        P3["Your app source\n< 1 MB"]:::good
        P1 --> P2 --> P3
    end

    classDef bad fill:#2d1215,stroke:#f85149,color:#f85149
    classDef good fill:#0d2818,stroke:#3fb950,color:#3fb950
    classDef neutral fill:#1c2128,stroke:#30363d,color:#e6edf3

Part 3: The production Dockerfile — multi-stage build

Multi-stage builds use multiple FROM statements in one Dockerfile. Each stage builds on the previous. The final image receives only what you explicitly copy — build tools, test frameworks, and dev dependencies never make it in.

cat > Dockerfile << 'EOF'
# ─── Stage 1: deps ────────────────────────────────────────────────────────────
# Install production dependencies only.
# This stage is never shipped. Its only output is node_modules.
FROM node:20-alpine AS deps

WORKDIR /app

# Copy lockfiles first — before source code.
# This layer is cached until package.json or package-lock.json changes.
# Changing src/ files won't invalidate this cache layer.
COPY package.json package-lock.json ./

# npm ci: uses lockfile exactly, fails if lockfile is out of sync
# --omit=dev: excludes devDependencies (nodemon, jest, etc.)
RUN npm ci --omit=dev --frozen-lockfile

# ─── Stage 2: test ────────────────────────────────────────────────────────────
# Runs tests with all dependencies (including dev).
# CI can build this target to gate on test failures before shipping prod.
FROM node:20-alpine AS test

WORKDIR /app

COPY package.json package-lock.json ./
RUN npm ci --frozen-lockfile

COPY . .

RUN npm run test:ci --if-present

# ─── Stage 3: production ──────────────────────────────────────────────────────
# The only stage that ships. Built on distroless — no shell, no package manager,
# no system utilities. The absolute minimum to run a Node.js process.
FROM gcr.io/distroless/nodejs20-debian12 AS production

# OCI image labels — document the image for registries and scanners
LABEL org.opencontainers.image.title="myapp"
LABEL org.opencontainers.image.description="Node.js REST API — 30 Days of DevOps"
LABEL org.opencontainers.image.source="https://github.com/syssignals/30-days-devops"
LABEL org.opencontainers.image.licenses="MIT"

WORKDIR /app

# Pull only production node_modules from the deps stage.
# Build tools, compilers, and package caches from that stage don't follow.
COPY --from=deps /app/node_modules ./node_modules

# Copy application source
COPY src/ ./src/
COPY package.json ./

# Distroless images run as nonroot (uid=65532) by default.
# Declaring it explicitly satisfies security scanners and makes intent clear.
USER nonroot

EXPOSE 3000

# Exec form (array) is required in distroless — there is no shell to interpret
# the shell form. It also makes node PID 1, so SIGTERM reaches it directly.
CMD ["/nodejs/bin/node", "src/index.js"]
EOF

Build and measure:

DOCKER_BUILDKIT=1 docker build \
  --target production \
  --tag myapp:production \
  .

Expected output:

[+] Building 18.4s (13/13) FINISHED
 => [deps 1/3] FROM docker.io/library/node:20-alpine
 => [deps 2/3] COPY package.json package-lock.json ./
 => [deps 3/3] RUN npm ci --omit=dev --frozen-lockfile
 => [production 1/4] COPY --from=deps /app/node_modules
 => [production 2/4] COPY src/ ./src/
 => [production 3/4] COPY package.json ./
 => exporting to image
 => => writing image sha256:b7e3d4...

Compare sizes:

docker images | grep myapp

Expected output:

REPOSITORY   TAG          IMAGE ID       CREATED          SIZE
myapp        production   b7e3d4f8a1c2   10 seconds ago   47.2MB
myapp        naive        a3f8c2d1e4b9   5 minutes ago    1.21GB

96% reduction. 1.21 GB → 47 MB.

Verify it runs and is correctly hardened:

# Start it
docker run -d \
  --name myapp-test \
  -p 3000:3000 \
  -e NODE_ENV=production \
  myapp:production

sleep 2

# Test the health endpoint
curl -s http://localhost:3000/health | python3 -m json.tool

# Verify non-root
docker run --rm myapp:production id
# uid=65532(nonroot) gid=65532(nonroot) groups=65532(nonroot)

# Verify no shell available (this should fail — that's the point)
docker exec myapp-test sh 2>&1
# OCI runtime exec failed: exec failed: unable to start container process:
# exec: "sh": executable file not found in $PATH

# Stop and remove
docker stop myapp-test && docker rm myapp-test

Part 4: The .dockerignore file

Without .dockerignore, every COPY . . sends your entire project — node_modules, .env, .git, IDE configs — to the Docker daemon as build context. This leaks secrets, bloats cache, and slows builds.

cat > .dockerignore << 'EOF'
# Dependencies — reinstalled inside the image
node_modules/
npm-debug.log*
yarn-debug.log*

# Secrets — must never reach an image layer
.env
.env.*
!.env.example
*.pem
*.key
*.p12
*.pfx
.npmrc

# Version control
.git/
.gitignore
.gitattributes

# IDE and OS artefacts
.vscode/
.idea/
*.swp
.DS_Store
Thumbs.db

# Test artefacts
coverage/
.nyc_output/
__tests__/
*.test.js
*.spec.js

# Docker config (no need inside the image)
Dockerfile*
docker-compose*.yml
.dockerignore

# Documentation
*.md
docs/

# CI/CD config
.github/
.gitlab-ci.yml
Jenkinsfile
EOF

Measure the build context before vs after:

# Build with verbose output to see context size
DOCKER_BUILDKIT=1 docker build --progress=plain --target production -t myapp:production . 2>&1 \
  | grep "transferring context"

Expected output:

#1 transferring context: 38.4kB 0.1s done

Without .dockerignore that number would be ~180 MB (dominated by node_modules).


Part 5: Docker Compose for local development

Development needs hot reload, all devDependencies, and live-mounted source. Production needs read-only filesystem, dropped capabilities, and resource limits. These are different Compose files.

%%{init: {'theme': 'dark'}}%%
flowchart TD
    subgraph DEV ["docker-compose.yml  ·  Development"]
        DH["Browser / curl\nlocalhost:3000"]:::host
        DC["myapp:dev\nnode:20-alpine + nodemon"]:::dev
        DV[("node_modules\nnamed volume")]:::vol
        DS[("./src  bind mount\nread-only into container")]:::vol
        DH -->|"port 3000:3000"| DC
        DC --> DV
        DC <-->|"file changes trigger reload"| DS
    end

    subgraph PROD ["docker-compose.prod.yml  ·  Production-like"]
        PH["Browser / curl\nlocalhost:3000"]:::host
        PC["myapp:production\ndistroless · nonroot"]:::prod
        PT[("tmpfs: /tmp\nread-only filesystem")]:::vol
        PH -->|"port 3000:3000"| PC
        PC --> PT
    end

    classDef host fill:#1c2128,stroke:#30363d,color:#8b949e
    classDef dev fill:#1a2744,stroke:#58a6ff,color:#e6edf3
    classDef prod fill:#0d2818,stroke:#3fb950,color:#3fb950
    classDef vol fill:#1f2210,stroke:#d29922,color:#d29922

docker-compose.yml — development:

version: '3.9'

services:
  app:
    build:
      context: .
      dockerfile: Dockerfile
      target: deps
    image: myapp:dev
    container_name: myapp-dev
    restart: unless-stopped
    ports:
      - "3000:3000"
    volumes:
      - ./src:/app/src:ro
      - node_modules:/app/node_modules
    environment:
      NODE_ENV: development
      PORT: 3000
    command: ["node_modules/.bin/nodemon", "--watch", "src", "src/index.js"]
    healthcheck:
      test:
        - CMD
        - node
        - -e
        - "require('http').get('http://localhost:3000/health', r => process.exit(r.statusCode===200?0:1)).on('error',()=>process.exit(1))"
      interval: 30s
      timeout: 10s
      retries: 3
      start_period: 15s

volumes:
  node_modules:

docker-compose.prod.yml — production-like local testing:

version: '3.9'

services:
  app:
    build:
      context: .
      dockerfile: Dockerfile
      target: production
    image: myapp:production
    container_name: myapp-prod
    restart: unless-stopped
    ports:
      - "3000:3000"
    environment:
      NODE_ENV: production
      PORT: 3000
    read_only: true
    tmpfs:
      - /tmp
    security_opt:
      - no-new-privileges:true
    cap_drop:
      - ALL
    deploy:
      resources:
        limits:
          cpus: '0.50'
          memory: 256M
        reservations:
          cpus: '0.10'
          memory: 64M

Start development:

docker compose up --build

Expected output:

[+] Building 19.3s (11/11) FINISHED
[+] Running 2/2
 ✔ Volume "docker-best-practices_node_modules"  Created
 ✔ Container myapp-dev                          Started
myapp-dev  | [nodemon] 3.0.2
myapp-dev  | [nodemon] watching path(s): src/**/*
myapp-dev  | Server running on port 3000 [development]

Test hot reload without rebuilding:

# In a second terminal
sed -i 's/"healthy"/"all-systems-go"/' src/routes/health.js

# Watch the container log — nodemon restarts automatically
# Then verify the change
curl -s http://localhost:3000/health | grep status
# {"status":"all-systems-go",...}

# Revert
sed -i 's/"all-systems-go"/"healthy"/' src/routes/health.js

Stop and clean up:

docker compose down -v   # -v also removes named volumes

Part 6: Docker Scout vulnerability scan

Compare both images side by side:

# Naive image — expect many CVEs
echo "=== naive image ===" && \
docker scout cves myapp:naive --only-severity critical,high --format table 2>/dev/null | head -30

echo ""

# Production image — expect zero
echo "=== production image ===" && \
docker scout cves myapp:production --only-severity critical,high 2>/dev/null

Expected output:

=== naive image ===
  CRITICAL CVE-2023-38545  curl/libcurl4  ...
  HIGH     CVE-2024-0727   openssl        ...
  HIGH     CVE-2023-44487  nghttp2        ...
  ... (30+ vulnerabilities) ...

=== production image ===
    ✓ Analyzed image myapp:production
    No critical or high vulnerabilities found

Run a full comparison:

docker scout compare myapp:production --to myapp:naive

Expected output:

Comparing myapp:production to myapp:naive

Image reference: myapp:production

Changes:
  ✓ Critical vulnerabilities:  0  (-3)
  ✓ High vulnerabilities:      0  (-12)
  ✓ Medium vulnerabilities:    0  (-18)
  ✓ Low vulnerabilities:       0  (-14)
  ✓ Image size:                47.2 MB  (-1.17 GB)

Part 7: The reusable template

mkdir -p docker-template
cp Dockerfile docker-template/
cp .dockerignore docker-template/
cp docker-compose.yml docker-template/
cp docker-compose.prod.yml docker-template/
cp .env.example docker-template/

cat > docker-template/bootstrap.sh << 'SCRIPT'
#!/bin/bash
set -euo pipefail

APP="${1:-myapp}"

echo "Bootstrapping Docker setup for: $APP"

# Preflight checks
command -v docker  >/dev/null || { echo "Docker not found"; exit 1; }
docker info &>/dev/null      || { echo "Docker daemon not running"; exit 1; }

# Create .env from example if missing
[ ! -f .env ] && [ -f .env.example ] && cp .env.example .env && \
  echo "Created .env from .env.example — update before production use"

echo "Building dev image..."
DOCKER_BUILDKIT=1 docker build --target deps --tag "${APP}:dev" .

echo "Building production image..."
DOCKER_BUILDKIT=1 docker build --target production --tag "${APP}:production" .

echo ""
echo "=== Image sizes ==="
docker images | grep "$APP"

echo ""
echo "Ready."
echo "  Development:   docker compose up"
echo "  Production:    docker compose -f docker-compose.prod.yml up"
echo "  Scan:          docker scout cves ${APP}:production"
SCRIPT

chmod +x docker-template/bootstrap.sh

How it works under the hood

Why multi-stage builds achieve such dramatic size reduction

Docker images are a stack of layers. Every instruction adds a layer — and layers can only be added, never removed, in a single-stage build. Even if you run RUN rm -rf /build-tools after installing them, those files still exist in the lower layer; the removal just adds a new layer that marks them as deleted. The final image carries every byte from every layer.

Multi-stage builds escape this entirely. The deps stage can install gcc, python, and every native build tool it needs to compile npm packages. The production stage uses COPY --from=deps to lift only the finished node_modules directory out. The build tools never touch the production stage.

Why the order of COPY matters for build speed

Every layer is cached. When a layer changes, Docker rebuilds it and every layer after it. Copy order determines how often the expensive npm ci step gets skipped.

%%{init: {'theme': 'dark'}}%%
flowchart LR
    CHANGE(["src/index.js\nchanged"]):::src

    subgraph WRONG ["❌  Slow — cache busted on every change"]
        W1["COPY . ."]:::bad --> W2["RUN npm install\nre-runs every build"]:::bad --> W3["Ready"]:::neutral
    end

    subgraph RIGHT ["✓  Fast — npm ci stays cached"]
        R1["COPY package*.json"]:::good --> R2["RUN npm ci\ncached until deps change"]:::good --> R3["COPY src/\nonly this re-runs"]:::good --> R4["Ready"]:::neutral
    end

    CHANGE -->|"+45 sec"| WRONG
    CHANGE -->|"+2 sec"| RIGHT

    classDef src fill:#1a2744,stroke:#58a6ff,color:#e6edf3
    classDef bad fill:#2d1215,stroke:#f85149,color:#f85149
    classDef good fill:#0d2818,stroke:#3fb950,color:#3fb950
    classDef neutral fill:#1c2128,stroke:#30363d,color:#8b949e

Copy only package.json and package-lock.json first, run npm ci, then copy source. Now npm ci only reruns when your dependency manifest changes. Source code changes are a fast copy operation.

Why distroless instead of Alpine

Alpine Linux (~5 MB) is the standard lightweight base. It’s a real improvement over node:20 (1.1 GB). But Alpine still contains a shell (ash), a package manager (apk), and standard Unix utilities. If an attacker achieves remote code execution in your container, they have a complete Unix environment to work with.

Distroless contains none of that. The gcr.io/distroless/nodejs20-debian12 image contains: the Debian C library, the Node.js binary, and nothing else. No bash. No sh. No curl. No wget. An attacker who gets RCE can only execute your Node.js binary — there are no other tools available. The attack surface reduction is measurable. The trade-off is debugging: docker exec mycontainer bash won’t work. Add a debug stage to your Dockerfile for development use.

Why exec form CMD matters for signal handling

CMD node src/index.js is shell form. Docker wraps it as /bin/sh -c node src/index.js. Your Node process runs as a child of /bin/sh, not as PID 1. When Kubernetes or Docker sends SIGTERM to stop your container, it sends it to PID 1 — the shell. The shell may or may not forward the signal. Your graceful shutdown handler may never fire. Connections get dropped mid-request. Databases get dirty disconnects.

CMD ["/nodejs/bin/node", "src/index.js"] is exec form. Node becomes PID 1 directly. SIGTERM reaches your process.on('SIGTERM') handler reliably. Graceful shutdown works as designed.


Common errors and fixes

Error 1: npm ci fails — missing: express@^4.18.2

npm error missing: express@4.18.2, required by myapp@1.0.0

Cause: package-lock.json is out of sync with package.json, or package-lock.json was not committed.

Fix:

# Regenerate the lockfile
rm package-lock.json
npm install
git add package-lock.json
git commit -m "chore: regenerate package-lock.json"

# Now rebuild
docker build --target production -t myapp:production .

Error 2: Container exits immediately — no output

docker run myapp:production
# (exits instantly, no logs)

Cause: Shell form CMD in a distroless image. There is no shell to parse it.

Fix: Use exec form (array syntax) and the correct Node path for distroless:

# Wrong — shell form, silently fails in distroless
CMD node src/index.js

# Correct — exec form with distroless node path
CMD ["/nodejs/bin/node", "src/index.js"]

Verify the correct node path in the distroless image:

docker run --rm gcr.io/distroless/nodejs20-debian12 \
  /nodejs/bin/node --version
# v20.x.x

Error 3: COPY --from=deps results in empty node_modules

Error: Cannot find module 'express'

Cause: WORKDIR differs between stages, or the path in COPY --from doesn’t match.

Fix: Ensure WORKDIR is identical in all stages and COPY path is absolute:

FROM node:20-alpine AS deps
WORKDIR /app                          # ← must be /app
RUN npm ci --omit=dev

FROM gcr.io/distroless/nodejs20-debian12 AS production
WORKDIR /app                          # ← same: /app
COPY --from=deps /app/node_modules ./node_modules   # ← full path from deps

Error 4: Hot reload not working on Linux

[nodemon] watching: src/**/*
# (file changes have no effect)

Cause: Docker on Linux doesn’t propagate inotify events into containers by default in some configurations.

Fix: Switch nodemon to polling mode:

# Update the command in docker-compose.yml
command: ["node_modules/.bin/nodemon", "--legacy-watch", "--watch", "src", "src/index.js"]

Or add nodemon.json to the project root:

{
  "watch": ["src"],
  "ext": "js,json",
  "legacy-watch": true,
  "delay": 500
}

Error 5: Build context is 850 MB despite .dockerignore

=> transferring context: 850.00MB

Cause: .dockerignore is in the wrong location, not saved, or the node_modules/ line has a typo.

Fix:

# Confirm .dockerignore exists in the build context directory
ls -la .dockerignore

# Check the node_modules line is correct (no trailing space)
grep "node_modules" .dockerignore
# node_modules/

# Test: measure context size with a dry run
DOCKER_BUILDKIT=1 docker build --progress=plain --target deps . 2>&1 \
  | grep "transferring context"
# Should show < 100kB

Error 6: docker scout permission denied

Error response from daemon: permission denied while trying to connect

Cause: User not in the docker group, or group change not applied to current session.

Fix:

sudo usermod -aG docker $USER
newgrp docker

# Verify
groups | grep docker

# Test
docker scout version

What’s next — extend this project

  1. Wire this into a full CI pipeline. Day 4 of this series builds a GitHub Actions workflow that builds your Docker image, runs the test stage as a CI gate, scans with Docker Scout, and pushes to GitHub Container Registry — automatically on every PR.

  2. Add multi-platform builds for ARM. AWS Graviton2/3 instances and Apple Silicon both run ARM64. Build once for both architectures:

docker buildx create --name multibuilder --use
docker buildx build \
  --platform linux/amd64,linux/arm64 \
  --target production \
  --tag ghcr.io/syssignals/myapp:latest \
  --push \
  .
  1. Add a debug target. Distroless has no shell, which is the point — but debugging a production issue is hard without one. Add a debug stage that includes busybox:
FROM gcr.io/distroless/nodejs20-debian12:debug AS debug
WORKDIR /app
COPY --from=deps /app/node_modules ./node_modules
COPY src/ ./src/
COPY package.json ./
CMD ["/nodejs/bin/node", "src/index.js"]

Build with --target debug when you need a shell for investigation. Never ship this tag.


Day 2 recap

You now have:

  • A working multi-stage Dockerfile that reduces image size by 96% (1.21 GB → 47 MB)
  • A distroless production image with zero critical or high CVEs
  • A non-root runtime (uid 65532) that can’t escalate privileges
  • A .dockerignore that reduces build context from 180 MB to 38 KB
  • A Docker Compose dev setup with hot reload working in under 2 seconds
  • A production Compose override with read-only filesystem and dropped capabilities
  • Scan output proving the security improvement with Docker Scout

The difference between a working Docker image and a production-grade one is everything you leave out.


Day 3 preview

Day 3: Docker Compose for a full local dev environment

Full local dev environment: Node.js app + PostgreSQL + Redis + Nginx reverse proxy. Health checks, named volumes, environment variable injection, and service dependency ordering. The docker compose up your team actually deserves.


This is Day 2 of the 30 Days of DevOps series.
Follow @syssignals on X — Day 3 drops tomorrow.
Found a command that doesn’t work? Reply on X with your OS and Docker version.