Skip to content
All posts
Tutorial8 min read

Docker for Developers: From Zero to Production-Ready in One Guide

J
Jamith Nimantha
April 5, 2026
Container ship in a port, visual metaphor for Docker containers
Photo by Unsplash
On this page

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:

bash
curl -fsSL https://get.docker.com | sh
sudo usermod -aG docker $USER

Verify:

bash
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:

dockerfile
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:

bash
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:

dockerfile
# ── 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 .dockerignore file. It works like .gitignore and prevents node_modules, .git, and local env files from being copied into the build context.

gitignore
.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:

yaml
# 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:

bash
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:

yaml
services:
  app:
    env_file:
      - .env.local
bash
# .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:

dockerfile
# 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:

dockerfile
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:

text
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:

BaseApproximate 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

bash
# 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:

javascript
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:

yaml
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:

yaml
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:

dockerfile
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:

javascript
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)
  • [ ] .dockerignore configured
  • [ ] Running as non-root user: USER node in 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 just latest
  • [ ] 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 free
J

Written by

Jamith Nimantha

Software developer. Builds the DebuggerMe tools and writes about the things he runs into shipping them.

Share this post

Back to all posts

Related Articles

All articles →