Why Docker Matters for Developers
Before Docker, setting up a project on a new machine was a ritual of pain. Clone the repo. Install the right Node version. Oh wait, you need a specific version of PostgreSQL. And Redis. And now your global npm packages conflict with another project. And somehow it works fine on your colleague's MacBook but not on yours.
Docker eliminates this entirely. A container packages everything the app needs — the runtime, system libraries, dependencies, configuration — into a single portable unit. Run it on your laptop, your teammate's Linux machine, or a cloud server, and the behavior is identical. The environment is part of the code.
For teams, the productivity gain compounds. Onboarding goes from "follow this 20-step setup guide and pray" to docker compose up. New developers are productive within minutes, not days.
Containers vs Virtual Machines
Before going further, it helps to know what a container actually is — because it's not a virtual machine.
A virtual machine emulates an entire computer, including its own operating system kernel. It's heavy: VMs take minutes to boot and consume gigabytes of RAM.
A container shares the host machine's OS kernel but isolates the application's filesystem, processes, and network. It's lightweight: containers start in seconds and use only the memory your app actually needs.
Docker is the tool that creates and manages these containers. A Docker image is a read-only snapshot of a filesystem — your app's code, dependencies, and runtime baked in. A container is a running instance of that image. One image can run as many containers as you want simultaneously.
Writing Your First Dockerfile
A Dockerfile is the recipe for building your image. It's a text file with a sequence of instructions that Docker executes layer by layer.
Here's a basic Node.js Dockerfile:
FROM node:20-alpine
WORKDIR /app
COPY package*.json ./
RUN npm ci
COPY . .
EXPOSE 3000
CMD ["node", "src/index.js"]
Walking through each line:
FROM node:20-alpine — Every image starts from a base image. node:20-alpine is the official Node.js 20 image built on Alpine Linux, a minimal distro that keeps the image small (~170MB vs ~1GB for the full Debian-based image).
WORKDIR /app — Sets the working directory inside the container. All subsequent commands run from here.
COPY package*.json ./ — Copies package.json and package-lock.json into the container before copying source code. This is intentional — keep reading.
RUN npm ci — Installs dependencies. npm ci (clean install) is preferred over npm install in Docker because it's faster, deterministic, and respects the lockfile exactly.
COPY . . — Copies the rest of your source code into the container.
EXPOSE 3000 — Documents that the container listens on port 3000. This doesn't publish the port — it's metadata for whoever runs the container.
CMD ["node", "src/index.js"] — The default command to run when the container starts. Use the array form (exec form) rather than a string — it avoids running your process as a child of a shell, which causes issues with signal handling.
Understanding Layer Caching — The Most Important Docker Concept
Each instruction in a Dockerfile creates a layer. Docker caches each layer and only rebuilds from the point where something changed. This makes rebuilds dramatically faster — but only if you structure your Dockerfile to take advantage of it.
Consider what happens if you copy everything first and then install:
# 🚫 Bad — cache busts on every source code change
COPY . .
RUN npm ci
Every time you change a single line of source code, Docker invalidates the cache at the COPY step — and then re-runs npm ci, downloading all your dependencies again. On a project with 500 packages, that's painful.
The fix is to copy dependency files first and install, then copy source code:
# ✅ Good — dependencies only reinstall when package.json changes
COPY package*.json ./
RUN npm ci
COPY . .
Now when you change source code, Docker uses the cached layer for npm ci and only rebuilds from the COPY . . step onward. Your build goes from 60 seconds to 3 seconds.
This pattern applies to any ecosystem. In Python, copy requirements.txt and run pip install before copying source. In Go, copy go.mod and go.sum and run go mod download first. Same principle everywhere.
Multi-Stage Builds: Keeping Production Images Small
Your development image needs build tools, compilers, dev dependencies, and test frameworks. Your production image needs none of that — just the compiled output and runtime dependencies. Multi-stage builds let you use a heavy image for building and copy only the result into a minimal final image.
Here's a TypeScript app with a multi-stage build:
# ── Stage 1: Build ────────────────────────────────────
FROM node:20-alpine AS builder
WORKDIR /app
COPY package*.json ./
RUN npm ci
COPY tsconfig.json ./
COPY src ./src
RUN npm run build # Outputs compiled JS to /app/dist
# ── Stage 2: Production ───────────────────────────────
FROM node:20-alpine AS production
WORKDIR /app
# Only copy production dependencies
COPY package*.json ./
RUN npm ci --omit=dev
# Copy compiled output from the builder stage
COPY --from=builder /app/dist ./dist
EXPOSE 3000
CMD ["node", "dist/index.js"]
The final image contains only the Alpine base, production node_modules, and your compiled code. No TypeScript compiler, no source files, no dev dependencies. A typical image shrinks from 800MB to under 150MB. Smaller images mean faster pulls, smaller attack surfaces, and lower storage costs.
The .dockerignore File
Just like .gitignore, a .dockerignore file tells Docker what to exclude when copying your project into the image. Without it, you're copying node_modules (hundreds of MB), .git history, test files, and local env files into every build context — which slows down builds and risks leaking sensitive files.
# .dockerignore
node_modules
.git
.gitignore
*.md
.env
.env.*
dist
coverage
.nyc_output
.DS_Store
Dockerfile*
docker-compose*
Always create this file before your first docker build. It's one of those things that's much easier to add upfront than to chase down why your builds are slow later.
Docker Compose: Running Multi-Service Apps Locally
Real applications don't run in isolation. They have a database, a cache, maybe a message queue, maybe a background worker. Docker Compose lets you define and run all of these together in a single configuration file.
Here's a docker-compose.yml for a Node.js app with PostgreSQL and Redis:
services:
app:
build: .
ports:
- "3000:3000"
environment:
NODE_ENV: development
DATABASE_URL: postgresql://postgres:password@db:5432/myapp
REDIS_URL: redis://cache:6379
volumes:
- .:/app
- /app/node_modules
depends_on:
db:
condition: service_healthy
cache:
condition: service_started
command: npm run dev
db:
image: postgres:16-alpine
environment:
POSTGRES_USER: postgres
POSTGRES_PASSWORD: password
POSTGRES_DB: myapp
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:
A few things worth understanding here:
volumes: - .:/app — This mounts your local source code directory into the container at /app. When you edit a file on your host machine, the change is immediately reflected inside the container. Combined with a dev server that watches for file changes (like nodemon), you get hot reloading inside Docker.
- /app/node_modules — This is an anonymous volume that prevents the host's node_modules from overwriting the container's. Without this, your macOS node_modules would overwrite the Linux ones inside the container, causing native module failures.
depends_on** with **condition: service_healthy — This tells Docker to wait until the database passes its healthcheck before starting the app. Without this, your app might start before PostgreSQL is ready to accept connections, resulting in a connection error on boot.
postgres_data** named volume** — Database data is stored in a named volume managed by Docker, not in your project directory. This persists across container restarts and docker compose down commands. Running docker compose down -v removes the volumes too — useful for a clean reset.
To start everything:
docker compose up # Start all services, watch logs
docker compose up -d # Start in background (detached)
docker compose down # Stop and remove containers
docker compose down -v # Stop, remove containers AND volumes (full reset)
Useful Docker Commands You'll Actually Use
Running commands inside a container:
# Open a shell in a running container
docker compose exec app sh
# Run a one-off command (e.g. database migration)
docker compose exec app npm run migrate
# Run a command in a new container (not the running one)
docker compose run --rm app npm run seed
Inspecting what's happening:
# Follow logs for all services
docker compose logs -f
# Follow logs for a specific service
docker compose logs -f app
# See running containers and resource usage
docker stats
Cleaning up:
# Remove stopped containers, unused networks, dangling images
docker system prune
# Remove everything including unused images (be careful)
docker system prune -a
Rebuilding after Dockerfile changes:
# Force a rebuild without cache
docker compose build --no-cache
docker compose up --build
Development vs Production Compose Files
You'll often want slightly different configurations for development and production — maybe development has volume mounts and a dev server, while production uses the built image. Docker Compose supports this with file layering:
# Base config used everywhere
docker-compose.yml
# Development overrides
docker-compose.dev.yml
# Production overrides
docker-compose.prod.yml
Run with a specific override:
docker compose -f docker-compose.yml -f docker-compose.dev.yml up
Or set COMPOSE_FILE in your .env:
COMPOSE_FILE=docker-compose.yml:docker-compose.dev.yml
This keeps the shared configuration DRY while allowing environment-specific differences.
Summary
Docker gives you reproducible environments, consistent behavior across machines, and a clean way to run complex multi-service applications locally without installing anything directly on your host. The key concepts to internalize: layer caching makes builds fast (copy dependency files before source code), multi-stage builds keep production images lean, .dockerignore prevents bloated build contexts, and Docker Compose orchestrates multi-service apps with a single command.
Once Docker becomes part of your workflow, you'll wonder how you ever managed without it. Onboarding new developers, reproducing bugs, running integration tests — all of it gets simpler when the environment is defined in code.
Originally published on ZyVOP
Top comments (0)