DEV Community

Aditya Rawas
Aditya Rawas

Posted on Originally published at adityarawas.in

Docker for Developers: Containers, Images, and Compose Explained

Originally published at adityarawas.in


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 Machine Docker Container
OS Full guest OS per VM Shares host OS kernel
Size Gigabytes Megabytes
Startup Minutes Milliseconds
Isolation Strong (hardware-level) Good (process-level)
Performance ~10-15% overhead Near 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.
Enter fullscreen mode Exit fullscreen mode

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
Enter fullscreen mode Exit fullscreen mode

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
Enter fullscreen mode Exit fullscreen mode

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"]
Enter fullscreen mode Exit fullscreen mode

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

Instruction Purpose
FROM Base image to build from
WORKDIR Set the working directory for subsequent instructions
COPY Copy files from host into the image
RUN Execute a command during build (creates a new layer)
ENV Set environment variables
EXPOSE Document the port the app listens on (doesn't publish it)
CMD Default command to run when the container starts
ENTRYPOINT Fixed 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
Enter fullscreen mode Exit fullscreen mode

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
Enter fullscreen mode Exit fullscreen mode

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
Enter fullscreen mode Exit fullscreen mode

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
Enter fullscreen mode Exit fullscreen mode

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:
Enter fullscreen mode Exit fullscreen mode
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)
Enter fullscreen mode Exit fullscreen mode

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 . .
Enter fullscreen mode Exit fullscreen mode

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
Enter fullscreen mode Exit fullscreen mode

Run as a non-root user:

RUN addgroup -S appgroup && adduser -S appuser -G appgroup
USER appuser
Enter fullscreen mode Exit fullscreen mode

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
Enter fullscreen mode Exit fullscreen mode

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.

Top comments (0)