DockerDockerfileNode.jsBest Practices Beginner 5 min read

03-Writing Your First Dockerfile

Learn to write production-grade Dockerfiles for Node.js, Python, and Go applications with multi-stage builds and best practices.

What is a Dockerfile?

A Dockerfile is a plain text file with a series of instructions that Docker reads top-to-bottom to build an image. Each instruction creates a new layer.

Dockerfile  ──►  docker build  ──►  Image  ──►  docker run  ──►  Container

Dockerfile Instructions Reference

InstructionPurpose
FROMSet the base image (must be first)
WORKDIRSet working directory inside container
COPYCopy files from host into image
ADDLike COPY but also handles URLs & tar extraction
RUNExecute a command during the build
ENVSet environment variables
ARGBuild-time variable (not in final image)
EXPOSEDocument which port the container listens on
CMDDefault command when container starts (overridable)
ENTRYPOINTFixed command — arguments are appended
VOLUMECreate a mount point for persistent data
USERSwitch to a non-root user
LABELAdd metadata (maintainer, version, etc.)

Your First Dockerfile — Node.js App

Let’s Dockerize a simple Express API.

Project structure:

my-api/
├── Dockerfile
├── .dockerignore
├── package.json
├── package-lock.json
└── src/
    └── index.js

src/index.js:

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

app.get('/', (req, res) => {
  res.json({ message: 'Hello from Docker!', version: '1.0.0' });
});

app.get('/health', (req, res) => {
  res.json({ status: 'healthy' });
});

app.listen(PORT, () => {
  console.log(`Server running on port ${PORT}`);
});

Dockerfile:

# Stage 1: Use official Node.js Alpine image as base
FROM node:18-alpine

# Set working directory
WORKDIR /app

# Copy dependency files FIRST (layer caching trick)
COPY package*.json ./

# Install production dependencies only
RUN npm ci --omit=dev

# Copy the rest of the app
COPY src/ ./src/

# Document the port (does NOT actually publish it)
EXPOSE 3000

# Switch to non-root user for security
USER node

# Start the application
CMD ["node", "src/index.js"]

.dockerignore (prevents copying unnecessary files):

node_modules
npm-debug.log
.git
.gitignore
.env
*.md
Dockerfile
.dockerignore
Layer caching — the #1 optimization
Always COPY package*.json ./ and RUN npm install before copying your source code. Since package.json rarely changes, Docker reuses this cached layer on every build — saving minutes.

Build and Run

# Build the image (. means "use current directory as build context")
docker build -t my-api:1.0.0 .

# View the built image
docker images my-api

# Run the container
docker run -d -p 3000:3000 --name my-api my-api:1.0.0

# Test it
curl http://localhost:3000
# {"message":"Hello from Docker!","version":"1.0.0"}

curl http://localhost:3000/health
# {"status":"healthy"}

Multi-Stage Builds

Multi-stage builds create smaller production images by separating the build environment from the runtime environment.

# ════════════════════════════════════════
# Stage 1: Builder
# ════════════════════════════════════════
FROM node:18-alpine AS builder

WORKDIR /build

# Install ALL dependencies (including devDependencies for build tools)
COPY package*.json ./
RUN npm ci

COPY . .

# Run TypeScript compilation / tests / linting
RUN npm run build
RUN npm test

# ════════════════════════════════════════
# Stage 2: Production Runtime
# ════════════════════════════════════════
FROM node:18-alpine AS production

# Security: create a non-root user
RUN addgroup -S appgroup && adduser -S appuser -G appgroup

WORKDIR /app

# Only copy the compiled output + production deps from builder
COPY --from=builder /build/dist ./dist
COPY --from=builder /build/package*.json ./

# Install only production deps in the final image
RUN npm ci --omit=dev && npm cache clean --force

# Use non-root user
USER appuser

EXPOSE 3000
CMD ["node", "dist/index.js"]

Result: Builder image ~450 MB → Production image ~120 MB ✅

Python Dockerfile Example

FROM python:3.11-slim

WORKDIR /app

# Install system deps (in one RUN to minimise layers)
RUN apt-get update && apt-get install -y \
    gcc \
    && rm -rf /var/lib/apt/lists/*

# Copy and install Python dependencies
COPY requirements.txt .
RUN pip install --no-cache-dir -r requirements.txt

COPY . .

# Create a non-root user
RUN useradd --create-home appuser
USER appuser

EXPOSE 8000

CMD ["uvicorn", "main:app", "--host", "0.0.0.0", "--port", "8000"]

Dockerfile Best Practices

1. Use specific tags — never latest in production

# ❌ Bad — unpredictable
FROM node:latest

# ✅ Good — reproducible builds
FROM node:18.19.1-alpine3.19

2. Combine RUN commands to reduce layers

# ❌ Bad — 3 layers
RUN apt-get update
RUN apt-get install -y curl
RUN rm -rf /var/lib/apt/lists/*

# ✅ Good — 1 layer
RUN apt-get update && apt-get install -y curl \
    && rm -rf /var/lib/apt/lists/*

3. Always run as non-root

# ✅ Add this before CMD
RUN addgroup -S app && adduser -S app -G app
USER app

4. Use .dockerignore religiously

Excluding node_modules alone can reduce build context from 500 MB to under 1 MB.

5. Scan for vulnerabilities

# Scan your image with Docker Scout
docker scout cves my-api:1.0.0

Debugging a Dockerfile Build

# Build with verbose output
docker build --progress=plain -t my-api .

# Stop at a specific stage (for debugging multi-stage builds)
docker build --target builder -t my-api-debug .

# Run the intermediate image to poke around
docker run -it --rm my-api-debug /bin/sh
Build context size warning
If docker build is slow at “Sending build context to Docker daemon”, your .dockerignore is missing. A huge node_modules folder is the most common culprit.

Summary

# Minimal production-ready Dockerfile template
FROM node:18-alpine          # Use specific, small base image
WORKDIR /app                 # Set working directory
COPY package*.json ./        # Copy deps first (cache optimization)
RUN npm ci --omit=dev        # Install deps
COPY . .                     # Copy source
USER node                    # Never run as root
EXPOSE 3000                  # Document port
CMD ["node", "index.js"]     # Default start command

Next up: Docker Networking — how containers talk to each other!