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
| Instruction | Purpose |
|---|---|
FROM | Set the base image (must be first) |
WORKDIR | Set working directory inside container |
COPY | Copy files from host into image |
ADD | Like COPY but also handles URLs & tar extraction |
RUN | Execute a command during the build |
ENV | Set environment variables |
ARG | Build-time variable (not in final image) |
EXPOSE | Document which port the container listens on |
CMD | Default command when container starts (overridable) |
ENTRYPOINT | Fixed command — arguments are appended |
VOLUME | Create a mount point for persistent data |
USER | Switch to a non-root user |
LABEL | Add 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!