DevOps September 15, 2026 Aditya Rawas 6 min read

Docker for Developers: Containers, Images, and Compose Explained

If you’ve ever heard “works on my machine” — Docker is the solution. Docker lets you package your application and all its dependencies into a single unit called a container that runs identically everywhere: your laptop, a teammate’s Linux machine, or a cloud server.

This guide explains containers from first principles, then walks through building, running, and composing Docker-based applications — with a real Node.js example throughout.


Containers vs Virtual Machines

Before Docker, the standard way to isolate software environments was a Virtual Machine (VM). A VM emulates an entire computer — CPU, memory, disk, and a full operating system — on top of a hypervisor.

Containers are fundamentally different:

Virtual MachineDocker Container
OSFull guest OS per VMShares host OS kernel
SizeGigabytesMegabytes
StartupMinutesMilliseconds
IsolationStrong (hardware-level)Good (process-level)
Performance~10-15% overheadNear native

A container is just a process running on the host OS, isolated using two Linux kernel features:

  • Namespaces — isolate the process’s view of the system (filesystem, network, PID, users)
  • cgroups — limit the process’s resource usage (CPU, memory, I/O)

Docker provides the tooling to create, run, and manage these containers.


Core Concepts

Images

A Docker image is a read-only template that defines everything needed to run your application: the OS base layer, runtime, dependencies, app code, and startup command.

Images are built in layers. Each instruction in a Dockerfile adds a layer on top of the previous one. Layers are cached and shared — if 10 images all use the same Node.js base layer, that layer is stored once on disk.

Containers

A container is a running instance of an image. You can run many containers from the same image simultaneously. The container adds a thin writable layer on top of the image layers for any changes made at runtime.

Image (read-only layers):
  ubuntu:22.04 base
  node:20 runtime
  npm install output
  app source code

Container (writable layer on top):
  runtime logs, temp files, etc.

Registry

Images are stored in a registry. Docker Hub is the default public registry. Private registries include AWS ECR, GitHub Container Registry, and Google Artifact Registry.


Installing Docker

Download Docker Desktop from docker.com. It includes the Docker daemon, CLI, and Docker Compose.

Verify the installation:

docker --version
# Docker version 27.x.x

docker run hello-world
# Pulls the hello-world image and runs it — confirms Docker is working

Essential Docker Commands

# Images
docker pull node:20-alpine       # Download an image
docker images                    # List local images
docker rmi node:20-alpine        # Remove an image

# Containers
docker run node:20-alpine node --version   # Run a command in a container
docker run -it ubuntu bash                 # Interactive terminal
docker ps                                  # List running containers
docker ps -a                               # List all containers (including stopped)
docker stop <id>                           # Stop a container
docker rm <id>                             # Remove a stopped container

# Logs and inspection
docker logs <id>                           # View container output
docker exec -it <id> sh                    # Shell into a running container
docker inspect <id>                        # Full container metadata

Writing a Dockerfile

A Dockerfile is a text file with instructions for building an image. Each instruction becomes a layer.

Here’s a production-ready Dockerfile for a Node.js application:

# Stage 1: Install dependencies
FROM node:20-alpine AS deps
WORKDIR /app
COPY package.json package-lock.json ./
RUN npm ci --only=production

# Stage 2: Build (if needed — e.g. TypeScript)
FROM node:20-alpine AS builder
WORKDIR /app
COPY package.json package-lock.json ./
RUN npm ci
COPY . .
RUN npm run build

# Stage 3: Production image
FROM node:20-alpine AS runner
WORKDIR /app
ENV NODE_ENV=production

# Copy only what's needed
COPY --from=deps /app/node_modules ./node_modules
COPY --from=builder /app/dist ./dist
COPY package.json ./

EXPOSE 3000
CMD ["node", "dist/index.js"]

This uses a multi-stage build — a critical pattern for production images:

  • The deps and builder stages contain dev tools and source files
  • The final runner stage only has the compiled output and production dependencies
  • The final image is dramatically smaller because the intermediate stages are discarded

Common Dockerfile Instructions

InstructionPurpose
FROMBase image to build from
WORKDIRSet the working directory for subsequent instructions
COPYCopy files from host into the image
RUNExecute a command during build (creates a new layer)
ENVSet environment variables
EXPOSEDocument the port the app listens on (doesn’t publish it)
CMDDefault command to run when the container starts
ENTRYPOINTFixed command (CMD becomes arguments to it)

Building and Running Your Image

# Build the image — tag it with -t
docker build -t my-app:latest .

# Run a container from it
docker run -d \
  --name my-app \
  -p 3000:3000 \
  --env-file .env \
  my-app:latest

Flags explained:

  • -d — detached mode (runs in background)
  • --name my-app — give the container a name
  • -p 3000:3000 — map port 3000 on the host to port 3000 in the container
  • --env-file .env — load environment variables from a file

The .dockerignore File

Just like .gitignore, a .dockerignore file tells Docker what to exclude when copying files into the image:

node_modules
.git
.env
dist
*.log
.DS_Store

Always add node_modules — if it’s not ignored, Docker copies your local modules into the image before running npm ci, which bloats the image and can cause platform-specific binary conflicts.


Volumes: Persisting Data

Containers are ephemeral — when a container is removed, all its data is gone. Volumes solve this by mounting storage that lives outside the container lifecycle.

# Named volume — Docker manages the storage location
docker run -d \
  --name my-postgres \
  -v postgres-data:/var/lib/postgresql/data \
  -e POSTGRES_PASSWORD=secret \
  postgres:16

# Bind mount — maps a host directory into the container (useful in development)
docker run -d \
  -v $(pwd)/src:/app/src \
  -p 3000:3000 \
  my-app:dev

Use named volumes in production (Docker manages them, they survive container restarts). Use bind mounts in development (changes to host files are immediately reflected in the container).


Networking

Containers can communicate with each other through Docker networks.

# Create a network
docker network create my-network

# Connect containers to it
docker run -d --network my-network --name app my-app:latest
docker run -d --network my-network --name db postgres:16

# Inside the app container, connect to Postgres using its container name as hostname
# postgresql://db:5432/mydb

Containers on the same network can reach each other by container name — Docker’s internal DNS resolves them automatically.


Docker Compose: Multi-Container Applications

Real applications have multiple services — a web server, a database, a cache. Docker Compose lets you define and run all of them with a single file.

# docker-compose.yml
services:
  app:
    build: .
    ports:
      - "3000:3000"
    environment:
      - DATABASE_URL=postgresql://postgres:secret@db:5432/myapp
      - REDIS_URL=redis://cache:6379
    depends_on:
      db:
        condition: service_healthy
      cache:
        condition: service_started
    volumes:
      - ./src:/app/src   # hot reload in development

  db:
    image: postgres:16-alpine
    environment:
      POSTGRES_DB: myapp
      POSTGRES_PASSWORD: secret
    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
    volumes:
      - redis-data:/data

volumes:
  postgres-data:
  redis-data:
docker compose up -d          # Start all services in the background
docker compose logs -f app    # Follow logs for the app service
docker compose down           # Stop and remove containers
docker compose down -v        # Also remove volumes (WARNING: deletes data)

With this setup, a new developer on your team only needs to run docker compose up to get a fully working local environment — no manual database installation, no version conflicts.


Layer Caching: Making Builds Fast

Docker caches each layer. If a layer’s instruction and its inputs haven’t changed, Docker reuses the cached layer instead of re-running the instruction. This makes rebuilds fast.

The order of instructions matters — put the things that change least at the top:

# Good — dependencies rarely change, so this layer is cached on most rebuilds
COPY package.json package-lock.json ./
RUN npm ci

# Source code changes often — copy it after installing dependencies
COPY . .

If you copy the entire source first and then run npm ci, every code change invalidates the npm install layer and triggers a full reinstall.


Production Best Practices

Use specific image tags, not latest:

# Bad — unpredictable, breaks builds when the image updates
FROM node:latest

# Good — deterministic builds
FROM node:20.17-alpine3.20

Run as a non-root user:

RUN addgroup -S appgroup && adduser -S appuser -G appgroup
USER appuser

Don’t store secrets in images: Use environment variables or secret management tools (AWS Secrets Manager, HashiCorp Vault). Never COPY .env into an image.

Scan images for vulnerabilities:

docker scout cves my-app:latest

Keep images small: Use Alpine-based images (node:20-alpine instead of node:20). Alpine is ~5MB vs ~150MB for the Debian-based default. Use multi-stage builds to exclude build tools from the final image.


Key Takeaways

  • A container is an isolated process — it shares the host OS kernel but has its own filesystem, network, and process space via Linux namespaces and cgroups.
  • A Docker image is a read-only stack of layers; a container adds a writable layer on top.
  • The Dockerfile defines how to build your image — put slow-changing layers (dependencies) before fast-changing ones (source code) to maximize cache hits.
  • Multi-stage builds keep production images small by separating build-time tools from runtime output.
  • Volumes persist data beyond a container’s lifecycle — use named volumes in production, bind mounts in development.
  • Docker Compose orchestrates multi-container applications with a single YAML file, making local development reproducible.
  • Always use specific image tags, non-root users, and externalized secrets in production.

Never Miss an Article

Stay Updated

Get new deep-dives on JavaScript, TypeScript, Go, and cloud-native engineering delivered to your reader.

Aditya Rawas

Written by

Aditya Rawas

Full-stack engineer writing deep-dives on JavaScript, TypeScript, React, AWS, Docker, and Kubernetes. Passionate about making complex engineering concepts accessible to developers at every level.