DevOps

Understanding Docker: Containers for Developers

Mayur Dabhi
Mayur Dabhi
February 26, 2026
22 min read

A new teammate clones the repo, runs npm install, and gets a native module build error because their Node version is two majors behind yours. Or the app works locally, then breaks on the server because production has a different OpenSSL, a missing system library, or Postgres 13 instead of 15. You end up debugging the machine instead of the code.

Docker fixes that by shipping the environment along with the app. This post covers the parts I actually use day to day: images and containers, the commands worth memorizing, Dockerfiles (including multi-stage builds), volumes, networking, Docker Compose, and a few security habits. At the end I'll go over when I'd reach for Docker and when I wouldn't bother.

What Docker actually gives you

The same image runs on your laptop, a colleague's machine, CI, staging, and production. The OS packages, runtime version, and dependencies are baked in, so "it works on my machine" mostly stops being a thing. What's left to differ between environments is configuration, which you pass in at runtime.

What is Docker?

The short version: Docker packages your app together with everything it needs to run (code, runtime, libraries, system tools) into an isolated unit called a container. You build it once and run it anywhere Docker is installed.

Containers aren't virtual machines. A VM emulates hardware and boots a full guest OS. A container shares the host's kernel and only isolates the process, its filesystem, and its network. That's why a container starts in about a second and an image is often tens of megabytes instead of gigabytes.

Virtual Machines vs Docker Containers Virtual Machines Infrastructure (Hardware) Host Operating System Hypervisor Guest OS Bins/Libs App A Guest OS Bins/Libs App B Guest OS Bins/Libs App C Docker Containers Infrastructure (Hardware) Host Operating System Docker Engine Bins/Libs App A Bins/Libs App B Bins/Libs App C Heavy • Slow startup • GB in size Lightweight • Fast startup • MB in size

Virtual machines include entire guest operating systems, while containers share the host kernel

Core Docker Concepts

Six terms come up constantly. If these are clear, the commands later on will make sense.

Images

Read-only templates used to create containers. Think of them as blueprints.

Containers

Running instances of images. Isolated environments where your app executes.

Dockerfile

A script with instructions to build a Docker image automatically.

Volumes

Storage that outlives the container, so your data survives a rebuild.

Networks

Enable communication between containers and the outside world.

Registry

Repository for storing and distributing Docker images (like Docker Hub).

Docker Architecture

When you type docker run, the CLI doesn't run anything itself. The Docker client sends the request over a REST API to the Docker daemon (dockerd), and the daemon builds images, starts containers, and pulls from registries.

This is why you'll sometimes see Cannot connect to the Docker daemon at unix:///var/run/docker.sock. Is the docker daemon running? The CLI is fine; the daemon isn't up (start Docker Desktop, or run sudo systemctl start docker on Linux). If you get permission denied on that socket instead, your user isn't in the docker group yet, or you added it and haven't logged out and back in.

Docker Architecture Docker Client (CLI) docker build docker run docker pull REST API Docker Host (Daemon) Containers Images node:18 nginx:latest postgres:15 Registry (Docker Hub)

Docker client sends commands to the daemon, which manages containers, images, and communicates with registries

Installing Docker

Docker Desktop is the easiest way to get started on Windows and macOS. For Linux, you'll install Docker Engine directly.

  1. Download Docker Desktop for Windows
  2. Run the installer and follow the prompts
  3. Enable WSL 2 when prompted (recommended)
  4. Restart your computer
  5. Open Docker Desktop and wait for it to start

Requires Windows 10/11 64-bit with WSL 2 or Hyper-V enabled.

  1. Download Docker Desktop for Mac
  2. Open the .dmg file and drag Docker to Applications
  3. Launch Docker from Applications
  4. Grant necessary permissions when prompted
  5. Wait for Docker to start (whale icon in menu bar)

Works on both Intel and Apple Silicon Macs.

# Update package index
sudo apt-get update

# Install prerequisites
sudo apt-get install ca-certificates curl gnupg

# Add Docker's official GPG key
sudo install -m 0755 -d /etc/apt/keyrings
curl -fsSL https://download.docker.com/linux/ubuntu/gpg | sudo gpg --dearmor -o /etc/apt/keyrings/docker.gpg

# Set up the repository
echo "deb [arch=$(dpkg --print-architecture) signed-by=/etc/apt/keyrings/docker.gpg] https://download.docker.com/linux/ubuntu $(lsb_release -cs) stable" | sudo tee /etc/apt/sources.list.d/docker.list > /dev/null

# Install Docker Engine
sudo apt-get update
sudo apt-get install docker-ce docker-ce-cli containerd.io docker-buildx-plugin docker-compose-plugin

# Add your user to docker group (to run without sudo)
sudo usermod -aG docker $USER

Verify your installation by running:

Terminal
docker --version
# Docker version 24.0.7, build afdd53b

docker run hello-world
# Hello from Docker! This message shows your installation is working correctly.

Essential Docker Commands

You can get surprisingly far with about ten commands. Here they are, split into images and containers.

Image Commands

docker pull
docker pull nginx:latest

Download an image from a registry (Docker Hub by default)

docker images
docker images

List all locally stored images with their sizes and tags

docker build
docker build -t myapp:1.0 .

Build an image from a Dockerfile in the current directory

docker rmi
docker rmi nginx:latest

Remove one or more images from local storage

Container Commands

docker run
docker run -d -p 8080:80 --name webserver nginx

Create and start a container from an image

docker ps
docker ps -a

List running containers (-a shows all, including stopped)

docker stop / start
docker stop webserver && docker start webserver

Stop or start existing containers

docker exec
docker exec -it webserver /bin/bash

Run a command inside a running container (-it for interactive terminal)

docker logs
docker logs -f webserver

View container logs (-f follows/streams new logs)

Docker Run Flags Explained
  • -d: run in detached mode (in the background)
  • -p 8080:80: map host port 8080 to container port 80 (host first, container second)
  • --name: give the container a name instead of a random one
  • -v: mount a volume or host directory
  • -e: set an environment variable
  • --rm: delete the container automatically when it exits

Writing Dockerfiles

Your image is defined by a Dockerfile, a plain text file of build instructions. Each instruction produces a layer, and Docker caches layers. If nothing in a layer's inputs changed, Docker reuses it instead of rebuilding, which is the whole reason instruction order matters.

Docker Image Layers FROM node:18-alpine Base Image (Read-only) WORKDIR /app Layer 2 (Read-only) COPY package*.json ./ Layer 3 (Read-only) RUN npm install Layer 4 (Read-only) Container Layer Writable Layer

Each Dockerfile instruction creates a cached layer. The container adds a writable layer on top.

Here's a typical Dockerfile for a Node.js app:

Dockerfile
# Use official Node.js image as base
FROM node:18-alpine

# Set working directory inside container
WORKDIR /app

# Copy package files first (for better caching)
COPY package*.json ./

# Install dependencies
RUN npm ci --only=production

# Copy application source code
COPY . .

# Expose the port the app runs on
EXPOSE 3000

# Define environment variable
ENV NODE_ENV=production

# Command to run the application
CMD ["node", "server.js"]
Dockerfile Best Practices
  • Order matters for caching: put things that change often (like COPY . .) near the end, so a code change doesn't trigger a full npm ci
  • Pin your tags: node:18-alpine, not node:latest, or your build can change under you
  • Combine related RUN commands: RUN apt-get update && apt-get install -y curl in one line, so a cached update layer never pairs with a stale package list
  • Add a .dockerignore: exclude node_modules, .git, and .env. Without it, COPY . . can overwrite the Linux-built node_modules with your host's copy and leak secrets into the image
  • Use multi-stage builds to keep build tools out of the production image

Multi-Stage Builds

If your app has a build step (TypeScript, a bundler), the compiler and dev dependencies don't need to ship. With multiple FROM statements, you build in one stage and copy only the output into a clean final stage:

Dockerfile (Multi-Stage)
# Stage 1: Build
FROM node:18-alpine AS builder
WORKDIR /app
COPY package*.json ./
RUN npm ci
COPY . .
RUN npm run build

# Stage 2: Production
FROM node:18-alpine AS production
WORKDIR /app
COPY --from=builder /app/dist ./dist
COPY --from=builder /app/node_modules ./node_modules
EXPOSE 3000
CMD ["node", "dist/server.js"]

Docker Volumes: Persistent Data

Anything a container writes to its own filesystem is gone once you remove the container. That's fine for an API server and a disaster for a database. Volumes store data outside the container, so you can delete and recreate the container and keep the data.

Docker Volumes Container A /app/data Container B /app/data Named Volume my-data-volume

Multiple containers can share the same volume for data persistence and sharing

Volume Commands
# Create a named volume
docker volume create my-data

# Run container with volume mounted
docker run -d \
  --name postgres-db \
  -v my-data:/var/lib/postgresql/data \
  -e POSTGRES_PASSWORD=secret \
  postgres:15

# List volumes
docker volume ls

# Inspect volume details
docker volume inspect my-data

# Remove unused volumes
docker volume prune

Volume Types

Type Syntax Use Case
Named Volume -v myvolume:/path Production data, databases
Bind Mount -v /host/path:/container/path Development, config files
tmpfs Mount --tmpfs /path Sensitive data, cache

Docker Networking

As soon as you have two containers (say an API and a database), they need to talk to each other. Docker gives each container a network, and which network you pick decides who can reach whom.

Docker Network Types Bridge Network (Default) App DB docker0 bridge Host Network Container Host Network Stack None Network Container (Isolated) No network access
Network Commands
# Create a custom bridge network
docker network create my-app-network

# Run containers on the same network
docker run -d --name api --network my-app-network node-api
docker run -d --name db --network my-app-network postgres:15

# Containers can reach each other by name!
# From api container: postgres://db:5432

# List networks
docker network ls

# Inspect network details
docker network inspect my-app-network

# Connect running container to network
docker network connect my-app-network existing-container
Container DNS

Containers on the same user-defined bridge network can resolve each other by container name through Docker's built-in DNS, so you never hardcode IPs. Two gotchas: this doesn't work on the default bridge network, only on networks you create. And inside a container, localhost means the container itself, not your machine, so postgres://localhost:5432 from the API container will get ECONNREFUSED. Use the service name (db) instead.

Docker Compose: Multi-Container Apps

Typing out four docker run commands with networks, volumes, and env vars gets old fast. Docker Compose puts all of that in one YAML file you can commit. It's what I use for local development, and it's fine for small single-server deployments.

1

Define Services

Create a docker-compose.yml file describing your application's services, networks, and volumes.

2

Build and Run

Use docker compose up to build images and start all services with a single command.

3

Scale and Manage

Scale services, tail logs, and start or stop the whole stack as one unit.

Here's a setup for an API, a React frontend, Postgres, and Redis. One thing to know before copying it: depends_on only controls start order. It doesn't wait for Postgres to be ready to accept connections, so your API may crash on first boot. Add a healthcheck to the database and use condition: service_healthy, or make the app retry its connection.

docker-compose.yml
version: '3.8'

services:
  # Node.js API server
  api:
    build: ./api
    ports:
      - "3000:3000"
    environment:
      - NODE_ENV=development
      - DATABASE_URL=postgres://user:password@db:5432/myapp
    volumes:
      - ./api:/app
      - /app/node_modules
    depends_on:
      - db
    networks:
      - app-network

  # React frontend
  frontend:
    build: ./frontend
    ports:
      - "5173:5173"
    volumes:
      - ./frontend:/app
      - /app/node_modules
    depends_on:
      - api
    networks:
      - app-network

  # PostgreSQL database
  db:
    image: postgres:15-alpine
    environment:
      - POSTGRES_USER=user
      - POSTGRES_PASSWORD=password
      - POSTGRES_DB=myapp
    volumes:
      - postgres-data:/var/lib/postgresql/data
    networks:
      - app-network

  # Redis cache
  redis:
    image: redis:7-alpine
    networks:
      - app-network

networks:
  app-network:
    driver: bridge

volumes:
  postgres-data:
Docker Compose Commands
# Start all services (build if needed)
docker compose up -d

# View logs from all services
docker compose logs -f

# View logs from specific service
docker compose logs -f api

# Stop all services
docker compose stop

# Stop and remove containers, networks
docker compose down

# Rebuild and restart a specific service
docker compose up -d --build api

# Scale a service
docker compose up -d --scale api=3

# Execute command in running service
docker compose exec api npm test

Docker Security Best Practices

Containers isolate processes, but they're not a security boundary you can stop thinking about. These four habits cover most of the risk:

Don't Run as Root

By default, containers run as root. Create a non-root user in your Dockerfile:

# Create non-root user
RUN addgroup -g 1001 appgroup && \
    adduser -u 1001 -G appgroup -D appuser

# Switch to non-root user
USER appuser

Use Official & Minimal Images

Prefer official images and use minimal variants like Alpine:

  • node:18-alpine instead of node:18
  • python:3.11-slim instead of python:3.11
  • Smaller images = smaller attack surface

Never Hardcode Secrets

Don't put secrets in Dockerfiles or images:

  • Use environment variables at runtime
  • Use Docker secrets for Swarm/Kubernetes
  • Use secret management tools (Vault, AWS Secrets Manager)
# Good: Pass at runtime
docker run -e DATABASE_PASSWORD=$DB_PASS myapp

# Better: Use .env file (not committed!)
docker compose --env-file .env.local up

Keep Images Updated

Regularly update base images to get security patches:

# Scan images for vulnerabilities
docker scout cve myimage:latest

# Force pull latest base image
docker build --pull -t myapp .

Real-World Example: Full Stack App

This is how the pieces fit together in a full-stack project:

Project Structure
my-fullstack-app/
├── docker-compose.yml
├── docker-compose.prod.yml
├── .env.example
├── api/
│   ├── Dockerfile
│   ├── package.json
│   └── src/
├── frontend/
│   ├── Dockerfile
│   ├── package.json
│   └── src/
└── nginx/
    └── nginx.conf
api/Dockerfile
FROM node:18-alpine

# Create non-root user
RUN addgroup -g 1001 nodejs && adduser -u 1001 -G nodejs -D nodejs

WORKDIR /app

# Copy package files
COPY package*.json ./

# Install dependencies
RUN npm ci --only=production

# Copy source code
COPY --chown=nodejs:nodejs . .

# Switch to non-root user
USER nodejs

EXPOSE 3000

CMD ["node", "src/index.js"]
frontend/Dockerfile
# Build stage
FROM node:18-alpine AS builder
WORKDIR /app
COPY package*.json ./
RUN npm ci
COPY . .
RUN npm run build

# Production stage
FROM nginx:alpine
COPY --from=builder /app/dist /usr/share/nginx/html
COPY nginx.conf /etc/nginx/conf.d/default.conf
EXPOSE 80
CMD ["nginx", "-g", "daemon off;"]

Docker Command Cheat Sheet

Task Command
Build image docker build -t name:tag .
Run container docker run -d -p 8080:80 name
List containers docker ps -a
View logs docker logs -f container
Enter container docker exec -it container sh
Stop container docker stop container
Remove container docker rm container
Remove image docker rmi image:tag
Cleanup everything docker system prune -a
Compose up docker compose up -d
Compose down docker compose down
Careful with prune

docker system prune -a removes every image not used by a running container, so your next build pulls everything again. It leaves volumes alone unless you add --volumes, and that flag will happily delete the local database you forgot was stopped. Run docker system df first to see what's actually taking up space.

When I'd use Docker (and when I wouldn't)

I reach for Docker when a project has more than one moving part: an app plus a database, a queue, or Redis. Compose turns "install Postgres 15 and Redis 7 locally" into one command, and every developer gets the same versions. It's also the easiest way I know to make CI and production run the exact artifact you tested.

Where I'd skip it or think twice:

If you're new to it, containerize one existing app first, get the Dockerfile caching right, then add Compose once you have a second service.

Docker Containers DevOps Docker Compose Microservices
Mayur Dabhi

Mayur Dabhi

Full-stack developer passionate about clean code, modern web technologies, and sharing knowledge with the developer community.