Backend Tech Productivity

Docker Complete Guide: From Zero to Production Containers

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 this
  • WORKDIR — sets the working directory inside the image (creates it if missing)
  • COPY — copies files from host into the image
  • ADD — 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 layer
  • ENV — sets an environment variable available at build and runtime
  • ARG — build-time variable (not available at runtime)
  • EXPOSE — documents a port — does not actually publish it
  • VOLUME — declares a mount point for persistent data
  • USER — sets the user for subsequent RUN / CMD / ENTRYPOINT instructions
  • CMD — default command when the container starts (overridable at docker run)
  • ENTRYPOINT — fixed executable; CMD becomes its default arguments
  • HEALTHCHECK — 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 .dockerignore is critical. Without it, your entire local node_modules (potentially gigabytes) gets sent to the Docker daemon on every build, even though RUN npm ci will 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 to node:20.12-alpine3.19 for reproducible builds.
  • Run as a non-root user — create a dedicated user with adduser and switch with USER.
  • 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 RUN creates 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 history and live forever in the layer cache.
  • Scan images for vulnerabilities — use docker scout cves my-app:latest or 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 -d starts everything.
  • Use depends_on: condition: service_healthy + HEALTHCHECK instead of sleep hacks to sequence startup correctly.
  • Never put secrets in images or Compose files — use .env files 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.

Dhiraj Roy
Dhiraj Roy

Backend developer & tech writer. Writing about Java, Spring Boot, Python, and AI at Digital Drift.