Why containers won
Before containers, deploying meant reproducing an environment: the right runtime version, the right system libraries, the right configuration. “It works on my machine” was a genuine problem, and servers drifted from development over time.
Docker solved it by packaging the application and its environment into a single image. The image runs identically on a laptop, in CI and in production, because it carries everything it needs. That consistency is why containers became the baseline for deployment, and why learning Docker is one of the highest-value skills for shipping software.
Images and containers
An image is a read-only template built from a Dockerfile, composed of cached layers. A container is a running instance of an image with its own writable layer, process and network. You build once and run many.
docker build -t my-app:1.0 .
docker run -p 3000:3000 my-app:1.0
docker ps
docker logs <container>
The -p flag maps a host port to a container port. Containers are ephemeral by design: you can stop, remove and recreate them freely because the image is the source of truth.
The Dockerfile
The Dockerfile is the build recipe. Each instruction creates a layer.
# Dockerfile
FROM node:22-alpine
WORKDIR /app
# install dependencies first for better caching
COPY package*.json ./
RUN npm ci --omit=dev
# then copy source
COPY . .
ENV NODE_ENV=production
EXPOSE 3000
USER node
CMD ["node", "server.js"]
Order matters. Copying the dependency manifests and installing before copying source means a code change only invalidates the cheap source layer, not the expensive install. npm ci gives a reproducible install, and USER node runs as a non-root user.
Layer caching
Docker caches each layer and reuses it when the inputs are unchanged. This is why the order above is deliberate: put things that change rarely at the top and things that change often at the bottom.
# cached unless package files change
COPY package*.json ./
RUN npm ci
# invalidated on every source change
COPY . .
A .dockerignore file also matters. Without it, node_modules, .git and build output are copied into the build context, which slows the build and can leak files into the image.
node_modules
.git
dist
.env
Multi-stage builds
A multi-stage build compiles in one image and ships only the output in another.
# Dockerfile
FROM node:22-alpine AS build
WORKDIR /app
COPY package*.json ./
RUN npm ci
COPY . .
RUN npm run build
FROM nginx:alpine
COPY --from=build /app/dist /usr/share/nginx/html
EXPOSE 80
CMD ["nginx", "-g", "daemon off;"]
The final image contains only the built static files and a web server, not the Node runtime, the source or the build tools. The result is smaller, faster to pull and has a much smaller attack surface. For a server app, the final stage would be a slim Node image with production dependencies only.
Compose for multi-service apps
Real applications usually need more than one process. Compose defines them together.
# docker-compose.yml
services:
app:
build: .
ports:
- "3000:3000"
environment:
DATABASE_URL: postgres://db:5432/app
depends_on:
- db
db:
image: postgres:16-alpine
environment:
POSTGRES_PASSWORD: example
volumes:
- data:/var/lib/postgresql/data
volumes:
data:
depends_on controls startup order, volumes persists data across container restarts, and services reach each other by name on the shared network. Compose is ideal for local development and simple single-host deployments.
Registries and CI/CD
Images travel through a registry such as Docker Hub, GitHub Container Registry or a private registry. A typical pipeline:
- Install dependencies and run tests.
- Build the image and tag it with the commit SHA.
- Push it to the registry.
- Deploy the new tag to the target environment.
docker build -t ghcr.io/me/app:$GIT_SHA .
docker push ghcr.io/me/app:$GIT_SHA
Because the image is immutable and tagged by commit, rollbacks are just deploying a previous tag. This is the core of a reliable deployment process: build once, promote the same artifact through environments, and never rebuild for production.
Production practices
- Health checks. Expose an endpoint the platform can poll to know the container is ready.
- Non-root user. Run as a user with the least privilege it needs.
- Read-only filesystem where possible, with explicit writable volumes.
- Graceful shutdown. Handle
SIGTERMto finish in-flight requests. - Resource limits. Set CPU and memory so one container cannot starve others.
- Pin base image versions for reproducible builds.
- Secrets at runtime, never baked into the image.
- Small base images such as Alpine or distroless to reduce vulnerabilities.
Best practices
- Order Dockerfile instructions from least to most frequently changing.
- Use multi-stage builds and ship only what is needed.
- Add a
.dockerignorefornode_modules,.git,distand.env. - Run as a non-root user.
- Tag images by commit SHA, not just
latest. - Build the image once and promote it through environments.
- Handle
SIGTERMfor graceful shutdown.
Common mistakes
- Copying
node_modulesinto the image. - Running as root.
- Baking secrets into the image or committing
.env. - Using
latestas the only tag and losing reproducibility. - Rebuilding the image differently for each environment.
- Ignoring
.dockerignoreand creating huge, slow builds.
Where to go next
Containers make deployment predictable, and the same skills apply whether you ship to a VM, Kubernetes or a serverless container platform. Ground the runtime in the Node.js guide, manage dependencies with npm, serve it over HTTP and harden it with Web Security. Then containerise one small app and deploy it end to end.