Skip to content

Docker for web developers: from “works on my machine” to production

Docker packages your app with everything it needs to run, so it behaves the same on your laptop, your teammate’s laptop and your server. Here’s how images, containers and Compose work — with a production-ready Dockerfile for a Node.js app.

Nasir Mollah

6 min read

On this page10 sections

“It works on my machine” is one of the oldest jokes in software — and one of the most expensive problems. Different Node.js versions, missing system libraries, a database configured slightly differently: small environment differences cause bugs that take hours to track down.

Docker solves this by packaging your application together with everything it needs to run — runtime, libraries, system tools, configuration — into a single, portable unit. If it runs in the container on your laptop, it runs the same way on a teammate’s laptop, in CI and on your production server.

The core ideas

Images

An image is a read-only template: a snapshot of a filesystem plus instructions for how to start your app. Think of it as a recipe that’s already been cooked and frozen. Images are built from a Dockerfile and can be shared through registries such as Docker Hub or GitHub Container Registry.

Containers

A container is a running instance of an image — an isolated process with its own filesystem, network and environment. You can run many containers from the same image, and destroy and recreate them in seconds.

Containers vs virtual machines

A virtual machine emulates a whole computer, including its own operating system kernel. A container shares the host’s kernel and only isolates the process. That’s why containers start in about a second and use far less memory — but also why they are a lighter form of isolation than full virtual machines.

Layers

Images are built in layers, one for each instruction in a Dockerfile. Layers are cached and reused: if a layer hasn’t changed, Docker doesn’t rebuild it. Understanding this is the key to fast builds.

Your first Dockerfile

Here’s a simple Dockerfile for a Node.js API:

FROM node:22-alpine

WORKDIR /app

# Install dependencies first (cached unless package files change)
COPY package.json package-lock.json ./
RUN npm ci --omit=dev

# Then copy the source code
COPY . .

ENV NODE_ENV=production
EXPOSE 3000
USER node
CMD ["node", "src/server.js"]

What each instruction does:

  • FROM — start from an official base image with Node.js already installed. Alpine variants are small.
  • WORKDIR — set the working directory inside the image.
  • COPY — copy files from your project into the image.
  • RUN — execute a command at build time.
  • ENV — set environment variables.
  • EXPOSE — document which port the app listens on.
  • USER — run as a non-root user (the official Node images include a node user).
  • CMD — the command that starts the container.

Build and run it:

docker build -t my-api .
docker run --rm -p 3000:3000 --env-file .env my-api

-p 3000:3000 maps port 3000 on your machine to port 3000 in the container.

Why the order of COPY instructions matters

Notice that package.json is copied and dependencies are installed before the rest of the source. Because of layer caching, changing a line of application code only invalidates the final COPY layer — the slow npm ci step is reused. Copy everything first and every code change reinstalls all dependencies.

Don’t forget .dockerignore

Keep unnecessary and sensitive files out of the build context:

node_modules
.next
.git
.env*
npm-debug.log
Dockerfile
docker-compose*.yml

This makes builds faster and stops secrets from being baked into images.

Multi-stage builds: small, secure production images

Build tools, dev dependencies and source files aren’t needed at runtime. Multi-stage builds use one stage to build and another, minimal stage to run.

Here’s a production Dockerfile for a Next.js app using Next.js’s standalone output (set output: "standalone" in next.config):

# 1) Install dependencies
FROM node:22-alpine AS deps
WORKDIR /app
COPY package.json package-lock.json ./
RUN npm ci

# 2) Build the app
FROM node:22-alpine AS builder
WORKDIR /app
COPY --from=deps /app/node_modules ./node_modules
COPY . .
RUN npm run build

# 3) Run it with only what's needed
FROM node:22-alpine AS runner
WORKDIR /app
ENV NODE_ENV=production
ENV PORT=3000
ENV HOSTNAME=0.0.0.0

COPY --from=builder /app/public ./public
COPY --from=builder --chown=node:node /app/.next/standalone ./
COPY --from=builder --chown=node:node /app/.next/static ./.next/static

USER node
EXPOSE 3000
CMD ["node", "server.js"]

The final image contains the compiled app and only the dependencies it actually uses — often a fraction of the size of a naive image, with fewer packages that could contain vulnerabilities.

Docker Compose: your whole stack in one file

Real apps need more than one service: the app, a database, maybe Redis or a worker. Docker Compose describes them all in a single file and starts them together.

# compose.yaml
services:
  app:
    build: .
    ports:
      - "3000:3000"
    env_file: .env
    environment:
      DATABASE_URL: postgres://app:secret@db:5432/app
      REDIS_URL: redis://cache:6379
    depends_on:
      db:
        condition: service_healthy
      cache:
        condition: service_started

  db:
    image: postgres:16
    environment:
      POSTGRES_USER: app
      POSTGRES_PASSWORD: secret
      POSTGRES_DB: app
    volumes:
      - db-data:/var/lib/postgresql/data
    healthcheck:
      test: ["CMD-SHELL", "pg_isready -U app"]
      interval: 5s
      retries: 10

  cache:
    image: redis:7-alpine

volumes:
  db-data:

Run docker compose up and the whole stack starts. New teammates go from cloning the repository to a running app with one command — no installing databases by hand.

A few things to notice:

  • Service names are hostnames. The app connects to the database at db, not localhost — Compose puts the services on a shared network.
  • Volumes persist data. Without the db-data volume, your database would be wiped every time the container is recreated.
  • Health checks with depends_on make the app wait until the database is actually ready.

Development workflow with Docker

For local development you usually want code changes to appear instantly without rebuilding the image. Mount your source code as a volume and run the dev server:

services:
  app:
    build:
      context: .
      target: deps          # reuse the dependency stage
    command: npm run dev
    volumes:
      - .:/app
      - /app/node_modules   # keep the container's node_modules
    ports:
      - "3000:3000"

Many teams also take a pragmatic middle path: run the app directly on their machine for fast iteration, and use Docker Compose only for databases and other services.

Useful commands

docker ps                     # running containers
docker logs -f <container>    # follow logs
docker exec -it <container> sh  # open a shell inside a container
docker compose up -d          # start the stack in the background
docker compose down           # stop and remove containers (volumes stay)
docker image ls               # list images
docker system prune           # clean up unused data (read the prompt!)

Production best practices

  • Pin versions. Use specific base image tags (for example node:22-alpine rather than node:latest) so builds are reproducible. For maximum reproducibility, pin by digest.
  • Run as non-root. Use the USER instruction. If an attacker compromises the app, they shouldn’t be root inside the container.
  • Never bake secrets into images. Pass them at runtime as environment variables or through your platform’s secret management. Anything copied into an image can be extracted from it.
  • Keep images small. Multi-stage builds, slim base images and .dockerignore reduce size, build time and attack surface.
  • Scan images for known vulnerabilities and rebuild regularly to pick up base image security updates.
  • One process per container. Run the app, the worker and the database as separate services.
  • Log to stdout/stderr. Let Docker and your platform collect logs instead of writing log files inside the container.
  • Add health checks so orchestrators can restart unhealthy containers.
  • Handle shutdown signals. Make sure your app exits cleanly on SIGTERM so deployments don’t drop requests.

Where containers run in production

Once your app is an image, you have many options:

  • A single VPS running Docker Compose — simple and inexpensive for small apps, especially with an automated deployment pipeline.
  • Managed container platforms that run your image and handle scaling and TLS.
  • Kubernetes for large systems with many services — powerful, but significant operational complexity you may not need yet.

When Docker isn’t worth it

Docker isn’t mandatory. A static site on a CDN or a Next.js app on a platform that builds from your repository may not benefit from containers at all. Docker earns its keep when you have multiple services, need identical environments across a team and CI, deploy to your own servers, or want portability between hosting providers.

The takeaway

Docker turns your application’s environment into code: versioned, reviewable and reproducible. Learn the basics — images, containers, layers, Dockerfiles and Compose — and you’ll spend less time debugging environments and more time building features. Add multi-stage builds, non-root users and runtime secrets, and the same image becomes a solid foundation for production.

  • Docker
  • DevOps
  • Node.js
  • Deployment

Building something? Let’s talk.

If you’re planning a web app, a website or a new feature, a free call is a good place to start — you’ll get honest options, a fixed price and a timeline.