Docker changed how software is built, shipped, and run. Before Docker, "it works on my machine" was a genuine crisis — dependencies, OS differences, and environment drift made deployments unpredictable. Docker packages your application and everything it needs into a single portable unit called a container, so it runs identically everywhere: your laptop, a CI server, or a cloud VM. This guide takes you from zero to running a production-ready multi-service application with Docker Compose.
Containers vs Virtual Machines
Both containers and virtual machines (VMs) isolate applications, but they do it at different levels of the stack:
- Virtual Machine — runs a full guest OS on top of a hypervisor (VMware, VirtualBox). Each VM gets its own kernel, drivers, and system libraries. Heavyweight: 1–10 GB per VM, boots in minutes.
- Container — shares the host OS kernel. Only the application and its dependencies are packaged. Lightweight: 5–500 MB per image, starts in milliseconds.
A VM virtualises the hardware. A container virtualises the operating system. You can run dozens of containers on the same machine you'd run 2–3 VMs on.
Step 1 — Install Docker
Install Docker Desktop on macOS and Windows (it bundles the Docker Engine, CLI, and Compose). On Linux, install the Docker Engine directly:
# Ubuntu / Debian — official install script (easiest)
curl -fsSL https://get.docker.com | sh
# Add your user to the docker group (avoid typing sudo every time)
sudo usermod -aG docker $USER
# Log out and back in, then verify
docker --version
docker compose version
Test your installation with the classic hello-world container:
docker run hello-world
Docker pulls the hello-world image from Docker Hub, creates a container,
runs it, prints a success message, and exits. That one command covered the entire
image → container → run lifecycle.
Step 2 — Core Concepts
Five terms you must understand before everything else clicks:
Image
A Docker image is a read-only template — a snapshot of a filesystem plus metadata (environment variables, default command, exposed ports). Images are built in layers; each instruction in a Dockerfile adds a layer. Images are stored in a registry (Docker Hub is the default public one).
Container
A container is a running instance of an image. You can run many containers from the same image simultaneously. Containers are ephemeral by default — when they stop, any data written inside them is lost (unless you use volumes).
Dockerfile
A Dockerfile is a plain-text recipe that tells Docker how to build your image, step by step. You commit this file to your repository alongside your code.
Volume
A volume is persistent storage that lives outside the container filesystem. Data written to a volume survives container restarts and deletions. Essential for databases.
Registry
A registry is a storage and distribution system for images. Docker Hub is the public default. Teams typically also run a private registry (AWS ECR, GitHub Container Registry, or a self-hosted Registry).
Step 3 — Essential Docker Commands
Images
# Pull an image from Docker Hub
docker pull node:20-alpine
# List local images
docker images
# Remove an image
docker rmi node:20-alpine
# Build an image from a Dockerfile in the current directory
docker build -t my-app:1.0 .
# Tag an existing image
docker tag my-app:1.0 my-app:latest
# Push an image to Docker Hub
docker push username/my-app:1.0
Containers
# Run a container (pulls image if not local, then starts it)
docker run nginx
# Run in detached mode (background) and map host port 8080 → container port 80
docker run -d -p 8080:80 nginx
# Run with a name so you can reference it easily
docker run -d -p 8080:80 --name my-nginx nginx
# Run interactively with a shell (great for debugging)
docker run -it ubuntu bash
# List running containers
docker ps
# List ALL containers (including stopped)
docker ps -a
# Stop a running container
docker stop my-nginx
# Start a stopped container
docker start my-nginx
# Remove a stopped container
docker rm my-nginx
# Force-remove a running container
docker rm -f my-nginx
# View container logs
docker logs my-nginx
# Follow logs in real time
docker logs -f my-nginx
# Execute a command inside a running container
docker exec -it my-nginx bash
# Copy a file from a container to the host
docker cp my-nginx:/etc/nginx/nginx.conf ./nginx.conf
System cleanup
# Remove all stopped containers, dangling images, unused networks
docker system prune
# Also remove unused volumes (be careful — data loss possible)
docker system prune --volumes
# Show disk usage by Docker objects
docker system df
Step 4 — Writing a Dockerfile
A Dockerfile is where most of the real work happens. Here is a production-quality Dockerfile for a Node.js application, with every instruction explained:
# ── Stage 1: install dependencies ──────────────────────────────
# Use the official Node 20 Alpine image as the base (small, secure)
FROM node:20-alpine AS deps
WORKDIR /app
# Copy package files first — Docker caches this layer separately.
# If source code changes but package.json doesn't, this layer is reused.
COPY package.json package-lock.json ./
RUN npm ci --omit=dev
# ── Stage 2: build ─────────────────────────────────────────────
FROM node:20-alpine AS builder
WORKDIR /app
COPY --from=deps /app/node_modules ./node_modules
COPY . .
RUN npm run build
# ── Stage 3: production image ───────────────────────────────────
FROM node:20-alpine AS runner
# Run as a non-root user for security
RUN addgroup -S appgroup && adduser -S appuser -G appgroup
USER appuser
WORKDIR /app
COPY --from=builder /app/dist ./dist
COPY --from=deps /app/node_modules ./node_modules
COPY package.json ./
# Document which port the app listens on (does NOT publish it)
EXPOSE 3000
# Prefer CMD (runtime args can override) over ENTRYPOINT for apps
CMD ["node", "dist/index.js"]
Dockerfile instruction reference
FROM— base image; every Dockerfile starts with thisWORKDIR— sets the working directory inside the image (creates it if missing)COPY— copies files from host into the imageADD— like COPY but also unpacks tar archives and supports URLs (prefer COPY for clarity)RUN— executes a shell command and commits the result as a new layerENV— sets an environment variable available at build and runtimeARG— build-time variable (not available at runtime)EXPOSE— documents a port — does not actually publish itVOLUME— declares a mount point for persistent dataUSER— sets the user for subsequent RUN / CMD / ENTRYPOINT instructionsCMD— default command when the container starts (overridable atdocker run)ENTRYPOINT— fixed executable; CMD becomes its default argumentsHEALTHCHECK— tells Docker how to test that the container is healthy
Step 5 — The .dockerignore File
Just like .gitignore, a .dockerignore file prevents files from
being sent to the Docker build context. A bloated build context dramatically slows builds.
Always create this file at your project root:
# .dockerignore
node_modules/
.git/
.gitignore
dist/
build/
coverage/
*.log
.env
.env.*
.DS_Store
Dockerfile
docker-compose*.yml
README.md
*.md
Pro tip:node_modules/in.dockerignoreis critical. Without it, your entire local node_modules (potentially gigabytes) gets sent to the Docker daemon on every build, even thoughRUN npm ciwill replace it anyway.
Step 6 — Volumes & Bind Mounts
Containers are ephemeral. To persist data or share files between the host and a container, use volumes or bind mounts:
Named volumes (recommended for production data)
# Create a named volume
docker volume create postgres-data
# Use it when running a container
docker run -d \
--name postgres \
-e POSTGRES_PASSWORD=secret \
-v postgres-data:/var/lib/postgresql/data \
postgres:16-alpine
# List volumes
docker volume ls
# Inspect a volume (shows where data lives on the host)
docker volume inspect postgres-data
# Remove a volume (data is permanently deleted)
docker volume rm postgres-data
Bind mounts (best for development — live-reload your source code)
# Mount the current directory into /app inside the container
# Changes on host are immediately reflected inside the container
docker run -d \
-p 3000:3000 \
-v $(pwd):/app \
-v /app/node_modules \
--name dev-server \
my-app:dev
Step 7 — Docker Networking
Docker containers are isolated by default. Networking connects them to each other and to the outside world.
Port publishing
# -p HOST_PORT:CONTAINER_PORT
docker run -p 8080:80 nginx # host 8080 → container 80
docker run -p 127.0.0.1:8080:80 # bind to localhost only (more secure)
docker run -P nginx # publish ALL EXPOSE'd ports to random host ports
Networks
# Create a custom bridge network
docker network create my-network
# Connect containers to the same network so they can reach each other by name
docker run -d --network my-network --name api my-api:latest
docker run -d --network my-network --name db postgres:16-alpine
# From inside "api", you can now reach the DB at hostname "db" — no IP needed
# e.g. DATABASE_URL=postgres://user:pass@db:5432/mydb
# List networks
docker network ls
# Inspect a network
docker network inspect my-network
Network driver types
bridge— default for standalone containers. Containers on the same bridge network can communicate by name.host— container shares the host's network stack. Highest performance, least isolation.none— no networking at all. Maximum isolation.overlay— for Docker Swarm — spans multiple hosts.
Step 8 — Docker Compose
Running a single container with docker run is fine for experiments.
Real applications have multiple services — API, database, cache, queue.
Docker Compose lets you define and manage all of them in a single
docker-compose.yml file.
A production-ready Compose file
# docker-compose.yml
version: "3.9"
services:
api:
build:
context: .
dockerfile: Dockerfile
target: runner
container_name: api
restart: unless-stopped
ports:
- "3000:3000"
environment:
NODE_ENV: production
DATABASE_URL: postgres://postgres:secret@db:5432/appdb
REDIS_URL: redis://cache:6379
depends_on:
db:
condition: service_healthy
cache:
condition: service_started
networks:
- app-net
db:
image: postgres:16-alpine
container_name: db
restart: unless-stopped
environment:
POSTGRES_USER: postgres
POSTGRES_PASSWORD: secret
POSTGRES_DB: appdb
volumes:
- postgres-data:/var/lib/postgresql/data
- ./db/init.sql:/docker-entrypoint-initdb.d/init.sql:ro
healthcheck:
test: ["CMD-SHELL", "pg_isready -U postgres"]
interval: 10s
timeout: 5s
retries: 5
networks:
- app-net
cache:
image: redis:7-alpine
container_name: cache
restart: unless-stopped
command: redis-server --maxmemory 256mb --maxmemory-policy allkeys-lru
volumes:
- redis-data:/data
networks:
- app-net
nginx:
image: nginx:alpine
container_name: nginx
restart: unless-stopped
ports:
- "80:80"
- "443:443"
volumes:
- ./nginx/nginx.conf:/etc/nginx/nginx.conf:ro
- ./nginx/certs:/etc/nginx/certs:ro
depends_on:
- api
networks:
- app-net
volumes:
postgres-data:
redis-data:
networks:
app-net:
driver: bridge
Essential Compose commands
# Start all services (build images if needed)
docker compose up
# Start in detached mode
docker compose up -d
# Force rebuild images before starting
docker compose up -d --build
# Stop all services (containers remain)
docker compose stop
# Stop and remove containers, networks (volumes are preserved)
docker compose down
# Stop and remove everything including volumes (data loss!)
docker compose down -v
# View logs of all services
docker compose logs
# Follow logs of a specific service
docker compose logs -f api
# Scale a service to N replicas
docker compose up -d --scale api=3
# List running services
docker compose ps
# Execute a command in a running service container
docker compose exec api sh
# Run a one-off command in a new container
docker compose run --rm api node scripts/seed.js
Override files for dev vs production
# docker-compose.override.yml — auto-loaded on top of docker-compose.yml
# Use for development settings: bind mounts, debug ports, hot reload
services:
api:
build:
target: builder # use the builder stage for dev (includes dev tools)
volumes:
- .:/app # live source code
- /app/node_modules
environment:
NODE_ENV: development
command: npm run dev # override CMD with a dev server
# In CI / production: use the base file only
docker compose -f docker-compose.yml up -d
Step 9 — Multi-Stage Builds
Multi-stage builds are the most important Dockerfile technique for production. They let you use a heavy build image (with compilers, dev tools) and copy only the compiled output into a tiny final image — without shipping the build tools.
Java / Spring Boot example
# Stage 1: build the jar
FROM maven:3.9-eclipse-temurin-21 AS build
WORKDIR /app
COPY pom.xml .
RUN mvn dependency:go-offline -q
COPY src ./src
RUN mvn package -DskipTests -q
# Stage 2: run with minimal JRE image
FROM eclipse-temurin:21-jre-alpine AS runner
RUN addgroup -S spring && adduser -S spring -G spring
USER spring
WORKDIR /app
COPY --from=build /app/target/*.jar app.jar
EXPOSE 8080
ENTRYPOINT ["java", "-jar", "app.jar"]
The final image contains only the JRE and your JAR — not Maven, not the JDK, not source code. A typical Spring Boot image drops from ~600 MB (single-stage) to ~150 MB (multi-stage).
Step 10 — Health Checks
Docker needs to know whether your container is actually healthy, not just running. A healthy process can still be deadlocked or unable to serve requests.
# In your Dockerfile
HEALTHCHECK --interval=30s --timeout=10s --start-period=40s --retries=3 \
CMD curl -f http://localhost:3000/health || exit 1
Or in Docker Compose (overrides the Dockerfile HEALTHCHECK):
healthcheck:
test: ["CMD", "curl", "-f", "http://localhost:3000/health"]
interval: 30s
timeout: 10s
start_period: 40s
retries: 3
Use depends_on: condition: service_healthy in Compose to make your API wait
until the database is ready — far more reliable than fixed sleep hacks.
Step 11 — Environment Variables & Secrets
Never hard-code secrets in your Dockerfile or Compose file. Use environment variables
and .env files instead.
# .env (add to .gitignore AND .dockerignore)
POSTGRES_PASSWORD=supersecret
JWT_SECRET=myjwtsecret
NODE_ENV=production
# docker-compose.yml — reference .env automatically
services:
api:
environment:
- NODE_ENV=${NODE_ENV}
- JWT_SECRET=${JWT_SECRET}
env_file:
- .env # loads the entire file into the container
# Pass a single variable at runtime
docker run -e NODE_ENV=production my-app
# Load from a file
docker run --env-file .env my-app
Production secret management: For real production workloads use
Docker Secrets (Swarm), Kubernetes Secrets, AWS Secrets Manager, or HashiCorp Vault.
Never commit .env files to Git — not even in private repos.
Step 12 — Docker in CI/CD (GitHub Actions)
A typical CI pipeline builds, tests, and pushes your image on every push to main.
# .github/workflows/docker.yml
name: Build & Push Docker Image
on:
push:
branches: [main]
jobs:
build:
runs-on: ubuntu-latest
steps:
- name: Checkout code
uses: actions/checkout@v4
- name: Set up Docker Buildx
uses: docker/setup-buildx-action@v3
- name: Log in to Docker Hub
uses: docker/login-action@v3
with:
username: ${{ secrets.DOCKERHUB_USERNAME }}
password: ${{ secrets.DOCKERHUB_TOKEN }}
- name: Build and push
uses: docker/build-push-action@v5
with:
context: .
push: true
tags: |
username/my-app:latest
username/my-app:${{ github.sha }}
cache-from: type=gha
cache-to: type=gha,mode=max
The cache-from/cache-to: type=gha lines enable GitHub Actions layer caching —
unchanged layers are pulled from cache instead of rebuilt, cutting build times dramatically.
Production Dockerfile Best Practices
- Use specific image tags — never
FROM node:latest. Pin tonode:20.12-alpine3.19for reproducible builds. - Run as a non-root user — create a dedicated user with
adduserand switch withUSER. - Use multi-stage builds — keep final images small and free of build tools.
- Order layers by change frequency — copy dependency files before source code to maximise cache reuse.
- Combine RUN commands — each
RUNcreates a layer; chain commands with&&to reduce layers. - Always have a .dockerignore — prevents large or sensitive files from entering the build context.
- Add a HEALTHCHECK — essential for orchestrators (Compose, Kubernetes) to know when your app is truly ready.
- Never store secrets in images — they appear in
docker historyand live forever in the layer cache. - Scan images for vulnerabilities — use
docker scout cves my-app:latestor integrate Trivy into CI.
Docker Commands Cheat Sheet
# ── Images ────────────────────────────────────────────────────
docker pull <image> # download from registry
docker images # list local images
docker rmi <image> # remove image
docker build -t <name:tag> . # build from Dockerfile
docker push <name:tag> # push to registry
docker history <image> # show image layers
# ── Containers ────────────────────────────────────────────────
docker run -d -p HOST:CTR <img> # run detached with port map
docker run -it <img> sh # run interactively
docker ps # list running containers
docker ps -a # list all containers
docker stop <name> # graceful stop
docker start <name> # start stopped container
docker rm <name> # remove stopped container
docker rm -f <name> # force-remove running container
docker logs -f <name> # follow logs
docker exec -it <name> sh # shell into running container
docker inspect <name> # full container metadata (JSON)
docker stats # live CPU / memory usage
# ── Volumes ───────────────────────────────────────────────────
docker volume create <name> # create named volume
docker volume ls # list volumes
docker volume rm <name> # remove volume
docker volume prune # remove all unused volumes
# ── Networks ──────────────────────────────────────────────────
docker network create <name> # create network
docker network ls # list networks
docker network inspect <name> # inspect network
# ── Compose ───────────────────────────────────────────────────
docker compose up -d # start all services
docker compose up -d --build # rebuild and start
docker compose down # stop and remove containers
docker compose down -v # also remove volumes
docker compose logs -f <svc> # follow service logs
docker compose exec <svc> sh # shell into service container
docker compose ps # list service status
# ── System ────────────────────────────────────────────────────
docker system df # disk usage
docker system prune # remove all unused resources
docker scout cves <image> # scan image for CVEs
Key Takeaways
- A container is a running instance of an image — lightweight, isolated, and ephemeral.
- The Dockerfile is the recipe; commit it with your code and every developer builds the same environment.
- Multi-stage builds keep production images small — build in a fat image, run in a slim one.
- Always use a .dockerignore — it's the Dockerfile equivalent of
.gitignore. - Named volumes persist database data across container restarts; bind mounts give live-reload during development.
- Docker Compose is the right tool for multi-service local and staging environments — one
docker compose up -dstarts everything. - Use
depends_on: condition: service_healthy+HEALTHCHECKinstead of sleep hacks to sequence startup correctly. - Never put secrets in images or Compose files — use
.envfiles locally and a secret manager in production.
What's Next?
You now have everything needed to containerise any application and run a full multi-service stack locally. The natural next step is container orchestration — when you need to run containers across multiple machines, handle automatic restarts, rolling updates, and auto-scaling, you reach for Kubernetes (K8s). Managed options like AWS ECS, Google Cloud Run, and Azure Container Apps let you run containers in production without managing Kubernetes yourself.
Also worth exploring: Docker Scout for image vulnerability scanning, BuildKit secrets for securely passing credentials during builds, and Docker Extensions for developer tooling inside Docker Desktop.
Check out the archive for more backend and DevOps guides, or reach out via the contact page.