The problem: "it works on my machine"
An application depends on an environment: a specific version of Node, certain variables, system libraries... If that environment differs between your laptop and the server, errors appear that are hard to reproduce. Containers solve this by packaging the application together with its environment so it runs the same everywhere.
Image vs container
This is the most important distinction in the topic:
- An image is an immutable, read-only template: the file system, the dependencies and the startup command. It's like the class.
- A container is a running instance of an image, with its own writable layer and live processes. It's like the object.
From the same image you can launch many containers. The image is built once; containers are created, stopped and destroyed continuously.
The Dockerfile
An image is defined with a Dockerfile: a recipe of instructions that are executed top to bottom.
# Base image we start from
FROM node:20-alpine
# Working directory inside the container
WORKDIR /app
# Copy the manifests first (to take advantage of the cache, see below)
COPY package.json package-lock.json ./
RUN npm ci
# Copy the rest of the source code
COPY . .
# Command that runs when the container starts
CMD ["node", "server.js"]
The key instructions:
FROM: starting base image.WORKDIR: working directory inside the image.COPY: copies files from the host to the image.RUN: runs a command at build time (install, compile).CMD: defines the command that runs at container startup.
Layers and build cache
Each instruction generates a layer. Docker caches each layer: if an
instruction and everything before it have not changed, it reuses the layer instead of
re-running it. That's why we copy package.json and run npm ci before
copying the rest of the code: as long as the dependencies don't change, that layer
(the slow one) is reused even if you edit your source code.
Practical rule: put what changes little at the top and what changes a lot at the bottom.
Multi-stage builds
To build you need tools (compilers, dev-dependencies) that you don't want in the final image. A multi-stage build uses one stage to build and another, clean and small, to run:
# Stage 1: build
FROM node:20-alpine AS build
WORKDIR /app
COPY package*.json ./
RUN npm ci
COPY . .
RUN npm run build
# Stage 2: final image, only with what's needed to run
FROM node:20-alpine
WORKDIR /app
COPY package*.json ./
RUN npm ci --omit=dev
COPY --from=build /app/dist ./dist
CMD ["node", "dist/server.js"]
The final image contains neither the source code nor the dev-dependencies: only the
build result (dist) and the production dependencies. Result:
smaller images, faster to deploy and with a smaller attack
surface.
Multiple services: docker-compose
A real app combines several containers (API, database, cache...). docker-compose describes and orchestrates them in a single file:
services:
api:
build: .
ports:
- "3000:3000"
environment:
DATABASE_URL: postgres://user:pass@db:5432/app
depends_on:
- db
db:
image: postgres:16
environment:
POSTGRES_PASSWORD: pass
volumes:
- db-data:/var/lib/postgresql/data
volumes:
db-data:
With docker compose up you bring up the whole stack at once. Services see
each other by their name (db), and volumes persist data even though
the container is destroyed.