DEV Community

Cover image for How to Write a Production-Ready Dockerfile (With Examples for Node.js and Python)
Parag Agrawal
Parag Agrawal

Posted on • Originally published at turbodeploy.dev

How to Write a Production-Ready Dockerfile (With Examples for Node.js and Python)

Here's a Dockerfile most of us have written at some point:

FROM node:20
WORKDIR /app
COPY . .
RUN npm install
EXPOSE 3000
CMD ["node", "index.js"]
Enter fullscreen mode Exit fullscreen mode

It works. It builds. It runs. And it's a security and performance problem that most people don't notice until something breaks:

  • Image size: 1.1 GB (the full node:20 image includes compilers, man pages, and 400+ system packages you'll never use)
  • Running as root: if your container is compromised, the attacker has root access
  • No layer caching: every code change reinstalls all dependencies from scratch
  • No health check: your orchestrator can't tell if the app is actually healthy
  • Dev dependencies shipped: test frameworks, linters, and type-checkers sitting in production

By the end of this post, you'll have a Dockerfile that produces images 9x smaller, builds 4x faster, and blocks the most common attack vectors.

Dockerfile Before vs After


The 10 Rules of Production Dockerfiles

Rule 1: Use Multi-Stage Builds

Multi-stage builds give you the biggest size reduction for the least effort. The idea is simple: build in one image, run in another.

# ❌ Single stage - everything ships to production
FROM node:20
WORKDIR /app
COPY . .
RUN npm install
RUN npm run build
CMD ["node", "dist/server.js"]
# Result: 1.1 GB image with source code, devDependencies, and build tools

# ✅ Multi-stage - only production artifacts ship
FROM node:20-alpine AS builder
WORKDIR /app
COPY package*.json ./
RUN npm ci
COPY . .
RUN npm run build

FROM node:20-alpine AS runner
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"]
# Result: 120 MB image with only compiled code and production dependencies
Enter fullscreen mode Exit fullscreen mode

Multi-Stage Build

Why it matters: Your build tools (TypeScript compiler, Webpack, Babel, testing frameworks) stay in Stage 1. Only the compiled output and production node_modules make it to the final image.


Rule 2: Choose the Right Base Image

Your base image controls your image size, security exposure, and compatibility:

Base Image Size CVEs (typical) C Library Best For
node:20 ~1.1 GB 200+ glibc Never in production
node:20-slim ~200 MB 30–50 glibc Apps with native C modules
node:20-alpine ~130 MB 5–15 musl Most web apps ⭐
python:3.12 ~1.0 GB 200+ glibc Never in production
python:3.12-slim ~150 MB 20–40 glibc Django/FastAPI with C deps ⭐
python:3.12-alpine ~60 MB 5–10 musl Pure Python apps
gcr.io/distroless/nodejs ~120 MB 0–5 glibc Maximum security
gcr.io/distroless/python3 ~50 MB 0–5 glibc Maximum security

Our recommendation:

  • Node.js: Start with node:20-alpine. Switch to node:20-slim if you encounter musl compatibility issues with native modules (e.g., sharp, bcrypt).
  • Python: Start with python:3.12-slim. Use Alpine only for pure Python apps without C extensions.

⚠️ Always pin your version. Use node:20.12-alpine3.19, not node:latest or node:20-alpine. Untagged/floating tags break reproducibility.


Rule 3: Optimize Layer Caching

Docker caches each layer. If a layer hasn't changed, Docker reuses it. The rule is straightforward: put things that change less often at the top, things that change often at the bottom.

# ❌ Bad: COPY all files first → cache busts on ANY file change
COPY . .
RUN npm ci

# ✅ Good: Copy dependency files first → cache busts only when deps change
COPY package.json package-lock.json ./
RUN npm ci
COPY . .
Enter fullscreen mode Exit fullscreen mode

Impact: When only your source code changes (the common case), Docker skips the npm ci step entirely. For a typical Node.js project, this turns a 90-second build into a 5-second build.

The optimal cache order:

# 1. Base image + system deps    (changes: rarely)
FROM node:20-alpine AS builder
RUN apk add --no-cache python3 make g++

# 2. Dependency manifest          (changes: weekly)
COPY package.json package-lock.json ./
RUN npm ci

# 3. Source code                   (changes: every deploy)
COPY . .
RUN npm run build
Enter fullscreen mode Exit fullscreen mode

Rule 4: Run as Non-Root

By default, Docker containers run as root. If your app is compromised, the attacker has root access inside the container and potentially to the host system.

# ✅ Node.js (Alpine)
RUN addgroup -g 1001 -S appgroup && \
    adduser -u 1001 -S appuser -G appgroup
USER appuser

# ✅ Python (Debian)
RUN groupadd -g 1001 appgroup && \
    useradd -u 1001 -g appgroup -m appuser
USER appuser
Enter fullscreen mode Exit fullscreen mode

Place the USER instruction after installing system packages and before the CMD. Some setup steps require root (like apk add or apt-get install).

Alpine shortcut: Node.js Alpine images ship with a built-in node user. You can simply use USER node instead of creating a new user.


Rule 5: Add a Health Check

Without a health check, your orchestrator (ECS, Kubernetes, Docker Compose) has no way to know if your app is actually responding to requests.

# ✅ Node.js (using wget - available in Alpine)
HEALTHCHECK --interval=30s --timeout=5s --start-period=30s --retries=3 \
  CMD wget --no-verbose --tries=1 --spider http://localhost:3000/health || exit 1

# ✅ Python (using python module - no extra installs needed)
HEALTHCHECK --interval=30s --timeout=5s --start-period=30s --retries=3 \
  CMD python -c "import urllib.request; urllib.request.urlopen('http://localhost:8000/health')" || exit 1
Enter fullscreen mode Exit fullscreen mode

Parameters explained:

Parameter Value Meaning
--interval 30s Check every 30 seconds
--timeout 5s Fail if the check takes longer than 5 seconds
--start-period 30s Wait 30 seconds before first check (app startup time)
--retries 3 Mark unhealthy after 3 consecutive failures

Your /health endpoint should return 200 OK if the service is ready:

// Express.js
app.get('/health', (req, res) => {
  res.status(200).json({ status: 'ok', uptime: process.uptime() });
});
Enter fullscreen mode Exit fullscreen mode
# FastAPI
@app.get("/health")
async def health():
    return {"status": "ok"}
Enter fullscreen mode Exit fullscreen mode

Rule 6: Write a Proper .dockerignore

The .dockerignore file stops unnecessary files from getting into the Docker build context. Without it, your .git history, test files, and local node_modules all get sent along.

# Version control
.git
.gitignore

# Dependencies (we install fresh in the container)
node_modules
__pycache__
*.pyc
.venv
venv

# Environment files (NEVER ship secrets)
.env
.env.local
.env.production

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

# Build output (we build fresh in the container)
dist
build
.next

# Documentation and tests
README.md
LICENSE
docs
tests
test
*.test.js
*.test.ts
*.spec.js
*.spec.ts
__tests__
pytest.ini
jest.config.*

# Docker files (avoid recursive builds)
Dockerfile*
docker-compose*
.dockerignore

# CI/CD configs
.github
.gitlab-ci.yml
.circleci
Enter fullscreen mode Exit fullscreen mode

Impact: A typical Node.js project sends 500 MB+ to the build context without .dockerignore. With it: around 5 MB.


Rule 7: Handle Signals for Graceful Shutdown

When ECS/Kubernetes stops your container, it sends SIGTERM. Your app needs to catch this signal and shut down gracefully (finish in-flight requests, close database connections).

The critical rule: Use CMD ["node", "server.js"] (exec form), NOT CMD node server.js (shell form).

# ❌ Shell form - node runs as a child of /bin/sh, never receives SIGTERM
CMD node server.js

# ✅ Exec form - node is PID 1, receives SIGTERM directly
CMD ["node", "server.js"]
Enter fullscreen mode Exit fullscreen mode

In your application code:

// Node.js graceful shutdown
const server = app.listen(3000);

process.on('SIGTERM', () => {
  console.log('SIGTERM received. Shutting down gracefully...');
  server.close(() => {
    console.log('Server closed. Exiting.');
    process.exit(0);
  });

  // Force exit after 10 seconds if connections don't close
  setTimeout(() => {
    console.error('Forcing shutdown after timeout.');
    process.exit(1);
  }, 10000);
});
Enter fullscreen mode Exit fullscreen mode
# Python / Gunicorn - graceful shutdown is built in
# Gunicorn handles SIGTERM natively.
# Just configure the timeout:
CMD ["gunicorn", "--timeout", "30", "--graceful-timeout", "10", \
     "--bind", "0.0.0.0:8000", "app:application"]
Enter fullscreen mode Exit fullscreen mode

Rule 8: Never Bake Secrets into the Image

This is the one that can actually get you fired:

# ❌ NEVER DO THIS - secrets are baked into the image layers forever
ENV DATABASE_URL=postgres://user:password@db:5432/myapp
ENV API_KEY=sk-1234567890abcdef

# ❌ ALSO NEVER DO THIS - the ARG is still visible in build history
ARG DATABASE_URL
ENV DATABASE_URL=$DATABASE_URL
Enter fullscreen mode Exit fullscreen mode

Even if you delete the ENV in a later layer, it's still visible with docker history.

The right way: Inject secrets at runtime.

# ✅ Declare that your app needs these vars, but don't set values
ENV NODE_ENV=production
# DATABASE_URL and API_KEY are injected at runtime via:
#   - ECS task definition (environment or secrets)
#   - docker run -e DATABASE_URL=xxx
#   - docker-compose.yml environment section
Enter fullscreen mode Exit fullscreen mode

For ECS, use AWS Secrets Manager:

{
  "containerDefinitions": [{
    "secrets": [
      {
        "name": "DATABASE_URL",
        "valueFrom": "arn:aws:secretsmanager:us-east-1:123456:secret:myapp/database-url"
      }
    ]
  }]
}
Enter fullscreen mode Exit fullscreen mode

Rule 9: Pin Dependencies and Base Images

Reproducible builds require pinned versions everywhere:

# ❌ Floating tags - your build is non-deterministic
FROM node:20-alpine
RUN npm install          # Uses whatever latest packages resolve to

# ✅ Pinned everything - identical builds every time
FROM node:20.12.2-alpine3.19
COPY package.json package-lock.json ./
RUN npm ci               # Uses exact versions from lockfile
Enter fullscreen mode Exit fullscreen mode
Practice ❌ Bad ✅ Good
Base image node:latest node:20.12.2-alpine3.19
Node deps npm install npm ci
Python deps pip install -r requirements.txt pip install --no-cache-dir -r requirements.txt
System packages apk add curl apk add --no-cache curl=8.5.0-r0

npm ci vs npm install: npm ci deletes existing node_modules, installs from package-lock.json exactly, and fails if the lockfile is out of sync. It's designed for CI/CD and Docker builds.


Rule 10: Scan for Vulnerabilities

Build a scan step into your CI/CD pipeline:

# Install Trivy (one-time)
brew install trivy  # macOS
# or: apt-get install trivy  # Debian/Ubuntu

# Scan your image
trivy image my-app:latest

# Fail CI on HIGH/CRITICAL vulnerabilities
trivy image --exit-code 1 --severity HIGH,CRITICAL my-app:latest
Enter fullscreen mode Exit fullscreen mode

In GitHub Actions:

- name: Scan Docker image
  uses: aquasecurity/trivy-action@master
  with:
    image-ref: my-app:latest
    format: 'table'
    exit-code: '1'
    severity: 'CRITICAL,HIGH'
Enter fullscreen mode Exit fullscreen mode

Complete Dockerfiles (Copy-Paste Ready)

1. Express.js (TypeScript) - Production

# =============================================================================
# Stage 1: Dependencies
# =============================================================================
FROM node:20.12-alpine3.19 AS deps
WORKDIR /app

# Install production dependencies only
COPY package.json package-lock.json ./
RUN npm ci --omit=dev

# =============================================================================
# Stage 2: Builder
# =============================================================================
FROM node:20.12-alpine3.19 AS builder
WORKDIR /app

# Install ALL dependencies (including devDeps for building)
COPY package.json package-lock.json ./
RUN npm ci

# Copy source code and build
COPY tsconfig.json ./
COPY src ./src
RUN npm run build

# =============================================================================
# Stage 3: Runner (Production)
# =============================================================================
FROM node:20.12-alpine3.19 AS runner
WORKDIR /app

# Set production environment
ENV NODE_ENV=production

# Create non-root user
RUN addgroup -g 1001 -S appgroup && \
    adduser -u 1001 -S appuser -G appgroup

# Copy production dependencies from deps stage
COPY --from=deps --chown=appuser:appgroup /app/node_modules ./node_modules

# Copy compiled output from builder stage
COPY --from=builder --chown=appuser:appgroup /app/dist ./dist
COPY --from=builder --chown=appuser:appgroup /app/package.json ./

# Switch to non-root user
USER appuser

# Expose port
EXPOSE 3000

# Health check
HEALTHCHECK --interval=30s --timeout=5s --start-period=30s --retries=3 \
  CMD wget --no-verbose --tries=1 --spider http://localhost:3000/health || exit 1

# Start application (exec form for signal handling)
CMD ["node", "dist/server.js"]
Enter fullscreen mode Exit fullscreen mode

Why 3 stages? Stage 1 installs only production node_modules (no TypeScript, no test tools). Stage 2 installs everything including devDependencies to compile TypeScript. Stage 3 takes node_modules from Stage 1 (small) and compiled code from Stage 2 (small) = smallest possible image.


2. Next.js (Standalone) - Production

# =============================================================================
# Stage 1: Dependencies
# =============================================================================
FROM node:20.12-alpine3.19 AS deps
WORKDIR /app

# Install dependencies (including sharp for image optimization)
COPY package.json package-lock.json ./
RUN npm ci

# =============================================================================
# Stage 2: Builder
# =============================================================================
FROM node:20.12-alpine3.19 AS builder
WORKDIR /app

COPY --from=deps /app/node_modules ./node_modules
COPY . .

# IMPORTANT: Ensure next.config.js has: output: 'standalone'
ENV NEXT_TELEMETRY_DISABLED=1
RUN npm run build

# =============================================================================
# Stage 3: Runner (Production)
# =============================================================================
FROM node:20.12-alpine3.19 AS runner
WORKDIR /app

ENV NODE_ENV=production
ENV NEXT_TELEMETRY_DISABLED=1

# Create non-root user
RUN addgroup --system --gid 1001 nodejs && \
    adduser --system --uid 1001 nextjs

# Copy public assets
COPY --from=builder /app/public ./public

# Copy standalone output (auto-traced minimal dependencies)
COPY --from=builder --chown=nextjs:nodejs /app/.next/standalone ./
COPY --from=builder --chown=nextjs:nodejs /app/.next/static ./.next/static

USER nextjs

EXPOSE 3000
ENV PORT=3000
ENV HOSTNAME="0.0.0.0"

HEALTHCHECK --interval=30s --timeout=5s --start-period=30s --retries=3 \
  CMD wget --no-verbose --tries=1 --spider http://localhost:3000/ || exit 1

CMD ["node", "server.js"]
Enter fullscreen mode Exit fullscreen mode

Prerequisite: Add output: 'standalone' to your next.config.js. This enables Next.js to auto-trace only the files needed at runtime, producing a minimal output bundle.

// next.config.js
/** @type {import('next').NextConfig} */
const nextConfig = {
  output: 'standalone',
};
module.exports = nextConfig;
Enter fullscreen mode Exit fullscreen mode

3. Django (Gunicorn) - Production

# =============================================================================
# Stage 1: Builder
# =============================================================================
FROM python:3.12-slim-bookworm AS builder

# Prevent Python from writing .pyc files and buffering stdout
ENV PYTHONDONTWRITEBYTECODE=1 \
    PYTHONUNBUFFERED=1

# Install build dependencies for C extensions (psycopg2, etc.)
RUN apt-get update && \
    apt-get install -y --no-install-recommends \
      build-essential \
      libpq-dev \
    && rm -rf /var/lib/apt/lists/*

# Install Python dependencies into /install directory
WORKDIR /app
COPY requirements.txt .
RUN pip install --no-cache-dir --prefix=/install -r requirements.txt

# =============================================================================
# Stage 2: Runner (Production)
# =============================================================================
FROM python:3.12-slim-bookworm AS runner

ENV PYTHONDONTWRITEBYTECODE=1 \
    PYTHONUNBUFFERED=1 \
    DJANGO_SETTINGS_MODULE=myproject.settings.production

# Install runtime-only system dependencies
RUN apt-get update && \
    apt-get install -y --no-install-recommends \
      libpq5 \
      wget \
    && rm -rf /var/lib/apt/lists/*

# Create non-root user
RUN groupadd -g 1001 appgroup && \
    useradd -u 1001 -g appgroup -m -s /bin/bash appuser

# Copy installed Python packages from builder
COPY --from=builder /install /usr/local

WORKDIR /app

# Copy application code
COPY --chown=appuser:appgroup . .

# Collect static files (runs as root, then we switch)
RUN python manage.py collectstatic --noinput 2>/dev/null || true

# Fix ownership
RUN chown -R appuser:appgroup /app

USER appuser

EXPOSE 8000

HEALTHCHECK --interval=30s --timeout=5s --start-period=30s --retries=3 \
  CMD wget --no-verbose --tries=1 --spider http://localhost:8000/health/ || exit 1

# Gunicorn with 2 workers (adjust based on vCPU - typically 2*CPU+1)
CMD ["gunicorn", "myproject.wsgi:application", \
     "--bind", "0.0.0.0:8000", \
     "--workers", "2", \
     "--timeout", "30", \
     "--graceful-timeout", "10", \
     "--access-logfile", "-", \
     "--error-logfile", "-"]
Enter fullscreen mode Exit fullscreen mode

Why --prefix=/install? This installs all Python packages into a separate /install directory in the builder stage, which we cleanly copy into the final image. No pip, no setuptools, no build tools in production.

Gunicorn worker count: The rule of thumb is 2 × CPU cores + 1. For a Fargate task with 0.5 vCPU, use 2 workers. For 1 vCPU, use 3.


4. FastAPI (Uvicorn + Gunicorn) - Production

# =============================================================================
# Stage 1: Builder
# =============================================================================
FROM python:3.12-slim-bookworm AS builder

ENV PYTHONDONTWRITEBYTECODE=1 \
    PYTHONUNBUFFERED=1

# Install build dependencies
RUN apt-get update && \
    apt-get install -y --no-install-recommends build-essential && \
    rm -rf /var/lib/apt/lists/*

WORKDIR /app
COPY requirements.txt .
RUN pip install --no-cache-dir --prefix=/install -r requirements.txt

# =============================================================================
# Stage 2: Runner (Production)
# =============================================================================
FROM python:3.12-slim-bookworm AS runner

ENV PYTHONDONTWRITEBYTECODE=1 \
    PYTHONUNBUFFERED=1

# Create non-root user
RUN groupadd -g 1001 appgroup && \
    useradd -u 1001 -g appgroup -m appuser

# Copy installed packages from builder
COPY --from=builder /install /usr/local

WORKDIR /app
COPY --chown=appuser:appgroup . .

USER appuser

EXPOSE 8000

HEALTHCHECK --interval=30s --timeout=5s --start-period=30s --retries=3 \
  CMD python -c "import urllib.request; urllib.request.urlopen('http://localhost:8000/health')" || exit 1

# Gunicorn with Uvicorn worker class for async FastAPI
CMD ["gunicorn", "main:app", \
     "-k", "uvicorn.workers.UvicornWorker", \
     "--bind", "0.0.0.0:8000", \
     "--workers", "2", \
     "--timeout", "30", \
     "--graceful-timeout", "10", \
     "--access-logfile", "-", \
     "--error-logfile", "-"]
Enter fullscreen mode Exit fullscreen mode

Why Gunicorn + Uvicorn? Uvicorn is a great ASGI server, but it runs a single process. Gunicorn acts as the process manager, spawning multiple Uvicorn workers for concurrent request handling. The -k uvicorn.workers.UvicornWorker flag tells Gunicorn to use Uvicorn's event loop inside each worker.


5. Using Poetry (Python) - Production

If your Python project uses Poetry instead of pip:

# =============================================================================
# Stage 1: Builder (with Poetry)
# =============================================================================
FROM python:3.12-slim-bookworm AS builder

ENV PYTHONDONTWRITEBYTECODE=1 \
    PYTHONUNBUFFERED=1 \
    POETRY_VERSION=1.8.5 \
    POETRY_HOME="/opt/poetry" \
    POETRY_NO_INTERACTION=1 \
    POETRY_VIRTUALENVS_CREATE=false

# Install Poetry
RUN apt-get update && \
    apt-get install -y --no-install-recommends curl build-essential libpq-dev && \
    curl -sSL https://install.python-poetry.org | python3 - && \
    rm -rf /var/lib/apt/lists/*

ENV PATH="$POETRY_HOME/bin:$PATH"

WORKDIR /app

# Copy only dependency files first (layer caching)
COPY pyproject.toml poetry.lock ./

# Install production dependencies only (no dev deps)
RUN poetry install --only main --no-root

# =============================================================================
# Stage 2: Runner (Production)
# =============================================================================
FROM python:3.12-slim-bookworm AS runner

ENV PYTHONDONTWRITEBYTECODE=1 \
    PYTHONUNBUFFERED=1

# Runtime system deps only
RUN apt-get update && \
    apt-get install -y --no-install-recommends libpq5 && \
    rm -rf /var/lib/apt/lists/*

RUN groupadd -g 1001 appgroup && \
    useradd -u 1001 -g appgroup -m appuser

# Copy installed Python packages from builder
COPY --from=builder /usr/local/lib/python3.12/site-packages /usr/local/lib/python3.12/site-packages
COPY --from=builder /usr/local/bin /usr/local/bin

WORKDIR /app
COPY --chown=appuser:appgroup . .

USER appuser
EXPOSE 8000

HEALTHCHECK --interval=30s --timeout=5s --start-period=30s --retries=3 \
  CMD python -c "import urllib.request; urllib.request.urlopen('http://localhost:8000/health')" || exit 1

CMD ["gunicorn", "main:app", \
     "-k", "uvicorn.workers.UvicornWorker", \
     "--bind", "0.0.0.0:8000", \
     "--workers", "2"]
Enter fullscreen mode Exit fullscreen mode

Key detail: Poetry itself is NOT in the final image. We only copy the installed site-packages. No poetry binary, no pyproject.toml parser, just the actual libraries your app needs.


Base Image Size Comparison (Real Numbers)

Here's what the same Express.js app looks like across different base images:

Base Image Final Image Size Known CVEs Build Time
node:20 (Debian full) 1,100 MB 200+ 45s
node:20-slim (Debian slim) 240 MB 30–50 38s
node:20-alpine (Alpine) 130 MB 5–15 30s
node:20-alpine + multi-stage 85 MB 5–15 32s
distroless/nodejs20 120 MB 0–5 35s

And for a Django app with Gunicorn + Postgres:

Base Image Final Image Size Known CVEs Build Time
python:3.12 (Debian full) 1,020 MB 200+ 60s
python:3.12-slim (Debian slim) 180 MB 20–40 50s
python:3.12-slim + multi-stage 140 MB 20–40 55s
python:3.12-alpine + multi-stage 95 MB 5–10 70s*

*Alpine Python builds are slower because C extensions (psycopg2) must compile against musl. Use psycopg2-binary or python:3.12-slim instead.


Docker Compose for Local Development

Your production Dockerfile shouldn't be what you use in development. Use Docker Compose to create a dev environment with hot-reloading:

# docker-compose.yml
services:
  app:
    build:
      context: .
      dockerfile: Dockerfile
      target: deps   # Stop at the deps stage, don't build production
    volumes:
      - .:/app       # Mount source code for hot-reloading
      - /app/node_modules  # Don't overwrite container's node_modules
    ports:
      - "3000:3000"
    environment:
      - NODE_ENV=development
      - DATABASE_URL=postgres://postgres:postgres@db:5432/myapp
    command: npm run dev   # Override CMD with dev server
    depends_on:
      - db
      - redis

  db:
    image: postgres:16-alpine
    environment:
      POSTGRES_PASSWORD: postgres
      POSTGRES_DB: myapp
    volumes:
      - pgdata:/var/lib/postgresql/data
    ports:
      - "5432:5432"

  redis:
    image: redis:7-alpine
    ports:
      - "6379:6379"

volumes:
  pgdata:
Enter fullscreen mode Exit fullscreen mode

The key trick: target: deps stops the build at the dependencies stage, and the volumes mount lets you edit code locally with hot-reloading. In production, the full multi-stage build runs.


Common Mistakes (and Fixes)

Mistake 1: Running npm install instead of npm ci

# ❌ npm install may update lockfile, non-deterministic
RUN npm install

# ✅ npm ci installs exact lockfile versions, fails if out of sync
RUN npm ci --omit=dev
Enter fullscreen mode Exit fullscreen mode

Mistake 2: Forgetting --omit=dev or --only=production

# ❌ Installs jest, eslint, typescript, prettier in production
RUN npm ci

# ✅ Production dependencies only
RUN npm ci --omit=dev
Enter fullscreen mode Exit fullscreen mode

Mistake 3: Using COPY . . Before Installing Dependencies

# ❌ Any source change invalidates the npm ci cache
COPY . .
RUN npm ci

# ✅ Dependency files first, then source
COPY package.json package-lock.json ./
RUN npm ci
COPY . .
Enter fullscreen mode Exit fullscreen mode

Mistake 4: Ignoring the apt/apk Cache

# ❌ Leaves package manager cache in the image (50-100 MB)
RUN apt-get update && apt-get install -y curl

# ✅ Clean up in the same layer
RUN apt-get update && \
    apt-get install -y --no-install-recommends curl && \
    rm -rf /var/lib/apt/lists/*
Enter fullscreen mode Exit fullscreen mode

Mistake 5: Not Using .dockerignore

Without .dockerignore, your 500 MB .git directory, your local node_modules, and your .env file all get sent to Docker. This slows down builds and creates security risks.

Mistake 6: Using Shell Form for CMD

# ❌ Shell form - node is a child of sh, doesn't receive SIGTERM
CMD npm start

# ✅ Exec form - node is PID 1, receives signals properly
CMD ["node", "dist/server.js"]
Enter fullscreen mode Exit fullscreen mode

How TurboDeploy Handles Dockerfiles

When you deploy through TurboDeploy, we auto-detect your framework (Express, Next.js, Django, FastAPI), generate an optimized Dockerfile with all the practices from this guide, build with multi-stage, push to ECR in your account, and scan with Trivy before deploying.

You can always bring your own Dockerfile instead. TurboDeploy deploys any valid Docker image.


The Production Checklist

Production Dockerfile Checklist

Before you deploy, verify:

  • [ ] Multi-stage build : build tools don't ship to production
  • [ ] Minimal base image : Alpine or Slim, not full Debian/Ubuntu
  • [ ] Pinned version tags : node:20.12-alpine3.19, never :latest
  • [ ] .dockerignore : excludes .git, node_modules, .env, tests
  • [ ] Layer caching optimized : dependency files copied before source
  • [ ] Non-root user : USER appuser before CMD
  • [ ] Health check : HEALTHCHECK instruction with proper intervals
  • [ ] Graceful shutdown : exec form CMD + SIGTERM handling in code
  • [ ] No secrets in image : environment variables injected at runtime
  • [ ] Security scan : trivy image --severity HIGH,CRITICAL passes

TL;DR

What Bad Good
Base image node:20 (1.1 GB) node:20-alpine (130 MB)
Build Single stage Multi-stage (build → run)
User root (default) Non-root (appuser)
Dependencies npm install npm ci --omit=dev
Cache COPY . . first COPY package*.json first
Secrets ENV API_KEY=xxx Injected at runtime
Health No health check HEALTHCHECK instruction
Shutdown CMD npm start CMD ["node", "server.js"]
Scanning None trivy image in CI
Tags :latest :20.12-alpine3.19

Don't want to write Dockerfiles? TurboDeploy generates production Dockerfiles for your Node.js and Python apps. Multi-stage builds, non-root users, health checks, Trivy scanning. Connect your repo, deploy.

Start deploying

Top comments (0)