A Dockerfile describes how to build an image; an image is a filesystem snapshot; a container is a running instance of one. Almost every Docker confusion traces back to blurring those three.
This covers a working setup end to end: a first Dockerfile, multi-stage builds that cut image size by an order of magnitude, Compose for local dependencies, and the handful of production settings that are easy to skip and expensive to skip.
Install Docker
Download Docker Desktop for Mac or Windows. On Linux:
curl -fsSL https://get.docker.com | sh
sudo usermod -aG docker $USER
Verify:
docker --version # Docker version 25.x.x
docker compose version # Docker Compose version v2.x.x
Your First Dockerfile
For a Node.js/Next.js app:
FROM node:22-alpine
WORKDIR /app
# Copy dependency files first (layer caching)
COPY package*.json ./
RUN npm ci
# Copy source
COPY . .
# Build
RUN npm run build
EXPOSE 3000
CMD ["npm", "start"]
Build and run:
docker build -t my-app .
docker run -p 3000:3000 my-app
Visit http://localhost:3000. That's it.
Multi-Stage Builds (The Right Way)
Single-stage builds ship your node_modules and source into production. Multi-stage builds keep your image lean:
# ── Stage 1: Dependencies ─────────────────────────────
FROM node:22-alpine AS deps
WORKDIR /app
COPY package*.json ./
RUN npm ci --only=production
# ── Stage 2: Builder ──────────────────────────────────
FROM node:22-alpine AS builder
WORKDIR /app
COPY package*.json ./
RUN npm ci
COPY . .
RUN npm run build
# ── Stage 3: Runner ───────────────────────────────────
FROM node:22-alpine AS runner
WORKDIR /app
ENV NODE_ENV=production
# Only copy what's needed at runtime
COPY --from=deps /app/node_modules ./node_modules
COPY --from=builder /app/.next ./.next
COPY --from=builder /app/public ./public
COPY --from=builder /app/package.json ./
EXPOSE 3000
CMD ["npm", "start"]
Result: 180MB image instead of 1.2GB. Smaller image = faster pulls, faster cold starts.
[!TIP] Always add a
.dockerignorefile. It works like.gitignoreand prevents node_modules, .git, and local env files from being copied into the build context.
.dockerignore
node_modules
.next
.git
.env*.local
*.md
Docker Compose for Local Development
Running a full stack locally (app, database, Redis) without Docker Compose means managing multiple terminal windows. With it:
# docker-compose.yml
services:
app:
build: .
ports:
- "3000:3000"
environment:
- DATABASE_URL=postgresql://postgres:password@db:5432/myapp
- REDIS_URL=redis://cache:6379
volumes:
- .:/app
- /app/node_modules # Don't override node_modules with host
depends_on:
db:
condition: service_healthy
cache:
condition: service_started
db:
image: postgres:16-alpine
environment:
POSTGRES_DB: myapp
POSTGRES_USER: postgres
POSTGRES_PASSWORD: password
ports:
- "5432:5432"
volumes:
- postgres_data:/var/lib/postgresql/data
healthcheck:
test: ["CMD-SHELL", "pg_isready -U postgres"]
interval: 5s
timeout: 5s
retries: 5
cache:
image: redis:7-alpine
ports:
- "6379:6379"
volumes:
postgres_data:
Start everything:
docker compose up -d # Start in background
docker compose logs -f # Tail logs
docker compose down # Stop everything
docker compose down -v # Stop + delete volumes (fresh state)
Environment Variables
Never hardcode secrets. Use .env files with Docker Compose:
services:
app:
env_file:
- .env.local
# .env.local
DATABASE_URL=postgresql://...
REDIS_URL=redis://...
RESEND_API_KEY=re_...
For production, use your platform's secret management (Railway secrets, Vercel env, AWS Secrets Manager).
Layer caching is the whole performance story
A Docker build is a stack of layers, one per instruction, and each is cached against the instructions and files that produced it. Change something and every layer after it rebuilds. That single rule explains why one build takes four seconds and the next takes four minutes.
The classic mistake:
# Every source change reinstalls all dependencies
COPY . .
RUN npm ci
COPY . . invalidates its layer whenever any file changes, so npm ci reruns on every edit. Copy the manifests first, install, then copy the source:
COPY package*.json ./
RUN npm ci
COPY . .
Now the install layer is cached until package.json or the lockfile actually changes. On a real project this is the difference between a four-minute rebuild and a five-second one.
Order instructions by how often they change: base image, system packages, dependency manifests, dependency install, then source. Least volatile first.
.dockerignore matters more than people expect. Without it, COPY . . sends your entire working directory to the daemon as build context, including node_modules, .git, and .next. That is slow, it busts the cache on every local install, and it can leak secrets into the image:
node_modules
.next
.git
.env*
Image size, and why your Node image is 1GB
Base image choice dominates. Roughly, before your app is added at all:
| Base | Approximate size |
|---|---|
node:22 | ~1.1GB |
node:22-slim | ~200MB |
node:22-alpine | ~130MB |
gcr.io/distroless/nodejs22 | ~110MB |
The default tag is the full Debian image with a compiler toolchain you almost certainly do not need at runtime. -slim is the safe default. Alpine is smaller but uses musl instead of glibc, which breaks native modules and can change DNS behaviour, so treat it as a deliberate choice rather than a free win.
Combined with the multi-stage build above, moving from node:22 to node:22-slim with only production dependencies in the final stage is usually a ten-fold reduction. Smaller images pull faster, which shows up directly in deploy time and cold starts.
Useful Commands
# List running containers
docker ps
# Execute a command inside a running container
docker exec -it <container_name> sh
# Check container logs
docker logs <container_name> -f
# Remove all stopped containers + unused images
docker system prune
# Remove everything including volumes (destructive!)
docker system prune --volumes
# Inspect a container's filesystem
docker exec -it <container_name> ls /app
# Copy files out of a container
docker cp <container_name>:/app/output.json ./output.json
The three things that break for everyone
Your app binds to localhost and nothing can reach it. Inside a container, 127.0.0.1 means the container itself, not your machine. A server listening on localhost:3000 is unreachable even with -p 3000:3000 published. Bind to all interfaces:
app.listen(3000, '0.0.0.0');
This is the single most common "the port is published but I get connection refused".
Containers cannot reach each other by localhost either. In Compose, services reach each other by service name, and by the container port rather than the published one:
services:
app:
environment:
DATABASE_URL: postgres://user:pass@db:5432/mydb # "db", not localhost
db:
image: postgres:17
Your database is empty every restart. Container filesystems are ephemeral. Anything not on a volume disappears when the container is removed:
services:
db:
image: postgres:17
volumes:
- pgdata:/var/lib/postgresql/data
volumes:
pgdata:
Related: depends_on waits for the container to start, not for Postgres to accept connections, so your app will still fail on first boot. Use a healthcheck and condition: service_healthy, or retry the connection in your app, which you want anyway for production.
Signals, and why your container takes ten seconds to stop
If docker stop always seems to hang, this is why. Docker sends SIGTERM, waits ten seconds, then SIGKILL. Two things commonly stop SIGTERM arriving.
Shell form runs your process under /bin/sh, which does not forward signals. These are not equivalent:
CMD npm start # shell form: PID 1 is sh, signals are swallowed
CMD ["node", "server.js"] # exec form: your process is PID 1
Always use the exec form. If you genuinely need a shell, exec into the real process so it replaces the shell.
Nothing handles the signal. As PID 1 your process gets no default handlers, so it must terminate deliberately:
process.on('SIGTERM', () => server.close(() => process.exit(0)));
Without it, every deploy kills in-flight requests after the ten-second wait, which shows up as intermittent 502s during rollouts and is miserable to diagnose from the other end.
Production Checklist
Before deploying a Dockerised app:
[ ]Multi-stage build (minimal image size)[ ].dockerignoreconfigured[ ]Running as non-root user:USER nodein Dockerfile[ ]Health check endpoint:HEALTHCHECK CMD curl -f http://localhost:3000/api/health[ ]Secrets via environment, not baked into image[ ]Image tagged with git SHA, not justlatest[ ]Resource limits set in your orchestration config
Next Steps
This gets you to a working Docker setup. For production at scale, the next layers to explore:
- Kubernetes: orchestrating many containers across many machines
- Docker Hub / GHCR: storing and versioning your images
- CI/CD integration: building and pushing images automatically on merge
But for most apps, especially early-stage products, Docker plus Docker Compose plus a managed platform (Railway, Fly.io, Render) is all you need.
If you take one thing from this: put COPY package*.json ./ and RUN npm ci above COPY . ., use the exec form for CMD, and add a .dockerignore. Those three changes cost about a minute and fix the slow builds, the hanging stops, and the accidentally enormous images that make people conclude Docker is not worth it.
Tools in this post
Related Tool
JSON Parser & Formatter
Validate, format, and minify JSON data with error highlighting.
Try it freeTagged with
Written by
Jamith NimanthaSoftware developer. Builds the DebuggerMe tools and writes about the things he runs into shipping them.
Related Articles
All articles →package.json vs package-lock.json – What's the Difference?
Understanding why both package.json and package-lock.json exist in your Node.js project, and when each file matters.
5 Git Workflows That Will Make Your Team More Productive
Most teams use Git without a consistent workflow. The result: messy history, painful merges, and deploy anxiety. These 5 workflows, from simple to advanced, will clean up your process.
Run Two Claude Code Accounts at Once (Personal + Office)
Claude Code has no account switcher yet, but one environment variable lets you keep a personal and an office login active at the same time. Here's the full setup.