Understanding Docker: Containers for Developers
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.
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 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 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.
- Download Docker Desktop for Windows
- Run the installer and follow the prompts
- Enable WSL 2 when prompted (recommended)
- Restart your computer
- Open Docker Desktop and wait for it to start
Requires Windows 10/11 64-bit with WSL 2 or Hyper-V enabled.
- Download Docker Desktop for Mac
- Open the .dmg file and drag Docker to Applications
- Launch Docker from Applications
- Grant necessary permissions when prompted
- 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:
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)
-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.
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:
# 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"]
- Order matters for caching: put things that change often (like
COPY . .) near the end, so a code change doesn't trigger a fullnpm ci - Pin your tags:
node:18-alpine, notnode:latest, or your build can change under you - Combine related RUN commands:
RUN apt-get update && apt-get install -y curlin one line, so a cachedupdatelayer never pairs with a stale package list - Add a .dockerignore: exclude
node_modules,.git, and.env. Without it,COPY . .can overwrite the Linux-builtnode_moduleswith 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:
# 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.
Multiple containers can share the same volume for data persistence and sharing
# 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.
# 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
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.
Define Services
Create a docker-compose.yml file describing your application's services, networks, and volumes.
Build and Run
Use docker compose up to build images and start all services with a single command.
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.
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:
# 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-alpineinstead ofnode:18python:3.11-sliminstead ofpython: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:
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
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"]
# 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 |
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:
- Small static sites or a single serverless function. The platform already handles the runtime, and a Dockerfile is one more thing to maintain.
- Bind-mounted dev setups on macOS and Windows. File I/O through a bind mount is noticeably slower than native, so big
node_modulesorvendorfolders can make hot reload and test runs drag. Keeping those folders in a named volume (as in the Compose file above) helps. - Production databases. Running Postgres in a container on a single VM works, but backups, upgrades, and disk handling are now your job. A managed database is usually less risk.
- Jumping straight to Kubernetes. If one server with Compose handles your traffic, orchestration adds a lot of operational weight for little gain. Learn it when you actually need multiple hosts.
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.