DEV Community

Cover image for The Docker Handbook: From Zero to Production-Ready Containers
Ismile Hossain
Ismile Hossain

Posted on

The Docker Handbook: From Zero to Production-Ready Containers

Docker has become an essential tool for developers, DevOps engineers, and anyone deploying applications today. It solves the age-old problem of “it works on my machine” by packaging an application with everything it needs into a lightweight, isolated unit called a container.
This guide takes you from absolute beginner to confidently building and running real‑world applications with Docker.


1. Why Docker Exists (The Problem)

Before Docker:

  • “It works on my machine” is the daily mantra 😵
  • Different OS → different bugs, different dependency versions
  • Onboarding a new developer takes hours (Node, DB, caches, environment variables…)
  • Servers are hand‑configured snowflakes, impossible to reproduce exactly

Docker solves this:

👉 It packages your app plus everything it needs into a lightweight, isolated unit called a container.

That container runs identically everywhere:

  • Your laptop
  • A teammate’s machine
  • A CI/CD pipeline
  • A production server in the cloud

2. What is Docker?

Docker is a platform that lets you:

  • Define application environments as code (Dockerfile)
  • Build images from those definitions
  • Run containers from those images
  • Share images via a public registry (Docker Hub)

Simple analogy:

Docker = Lunch box 🍱

Your app + dependencies = the food inside

Container = the sealed box you can carry anywhere, and when you open it the meal is exactly the same


3. Key Concepts (Must Know First)

📦 Image

blueprint of your application environment. It contains the OS files, dependencies, code, and configuration needed to run your app.

Example images:

  • node:18-alpine – Node.js on a tiny Alpine Linux
  • postgres:15 – PostgreSQL database server
  • nginx – a fast web server

Think: “Class in OOP”

🚀 Container

running instance of an image. You can have multiple containers from the same image, each isolated from the others.

Think: “Object created from a class”

🧱 Dockerfile

A text file that defines how to build an image. It lists step‑by‑step instructions, like a recipe.

Example:

FROM node:18

WORKDIR /app

COPY package.json .

RUN npm install

COPY . .

CMD ["node", "index.js"]
Enter fullscreen mode Exit fullscreen mode

📦 Docker Hub

A public registry for Docker images (like GitHub for containers). You pull official images from here and can push your own.

📁 Build Context

When you run docker build ., Docker sends all files in the current directory (the build context) to the daemon. If you have huge logs or a node_modules folder, builds become slow, and images can balloon. That’s why you need a .dockerignore file.


4. Installing Docker

Windows / Mac

Install Docker Desktop:

https://www.docker.com/products/docker-desktop/

Linux

sudo apt update
sudo apt install docker.io
Enter fullscreen mode Exit fullscreen mode

Verify:

docker --version
docker compose version    # new plugin, replaces docker-compose
Enter fullscreen mode Exit fullscreen mode

5. Your First Docker Command

Run your first container:

docker run hello-world
Enter fullscreen mode Exit fullscreen mode

What happens:

  1. Docker looks for the hello-world image locally
  2. Not found → pulls it from Docker Hub
  3. Creates a container from the image
  4. Runs it → prints a success message
  5. Container stops (its job is done)

6. Working with Images

List images

docker images
Enter fullscreen mode Exit fullscreen mode

Pull image

docker pull node
Enter fullscreen mode Exit fullscreen mode

Build an image from a Dockerfile

docker build -t my-app .
Enter fullscreen mode Exit fullscreen mode
  • t my-app gives it a name (tag)
  • . is the build context (current directory)

Tag an image for a registry

docker tag my-app username/my-app:v1.0
Enter fullscreen mode Exit fullscreen mode

Push to Docker Hub

docker login
docker push username/my-app:v1.0
Enter fullscreen mode Exit fullscreen mode

Remove image

docker rmi node
Enter fullscreen mode Exit fullscreen mode

7. Working with Containers

Run a container in the foreground

docker run nginx
Enter fullscreen mode Exit fullscreen mode

Press Ctrl+C to stop.

Run in the background (detached)

docker run -d nginx
Enter fullscreen mode Exit fullscreen mode

List running containers

docker ps
Enter fullscreen mode Exit fullscreen mode

List all containers (including stopped)

docker ps -a
Enter fullscreen mode Exit fullscreen mode

Stop a container

docker stop <container_id>
Enter fullscreen mode Exit fullscreen mode

Remove container

docker rm <container_id>
Enter fullscreen mode Exit fullscreen mode

Add -f to force the removal of a running container.


8. Ports (VERY IMPORTANT)

Containers run in their own isolated network. To reach a service inside, you must map a host port to the container’s port.

docker run -p 8080:80 nginx
Enter fullscreen mode Exit fullscreen mode
  • 8080 → your machine’s port
  • 80 → the port inside the container where nginx listens

Now visit: http://localhost:8080


9. The Hard Way First (Why Dockerfiles Exist)

Before learning Dockerfiles, let’s build an image manually. This will make you feel why Dockerfiles are essential.

Step 1: Pull a bare OS

docker pull ubuntu
Enter fullscreen mode Exit fullscreen mode

Step 2: Run It Interactively

docker run -it ubuntu bash
Enter fullscreen mode Exit fullscreen mode

-i interactive, -t allocate a terminal, bash gives you a shell inside the container.

Step 3: Install Node.js manually (inside the container)

apt update
apt install -y nodejs npm
node --version
Enter fullscreen mode Exit fullscreen mode

Step 4: Write a tiny server (still inside the container)

apt install -y nano
nano server.js
Enter fullscreen mode Exit fullscreen mode

Paste:

const http = require('http');

http.createServer((req, res) => {
  res.writeHead(200);
  res.end('Hello from inside a container 🐳');
}).listen(3000);

console.log('Server running on port 3000');
Enter fullscreen mode Exit fullscreen mode

Run it: node server.js

It works! 🎉 But notice what just happened to get here.

Step 5: Save this container as an image

Open a second terminal (leave the server running) and check the container ID:

docker ps                 # get container ID
Enter fullscreen mode Exit fullscreen mode

Now save the current state of that container as a new image:

docker commit <container_id> my-manual-node-image
Enter fullscreen mode Exit fullscreen mode

Next time, instead of repeating steps 1 – 4, you could just run:

docker run -it my-manual-node-image bash
Enter fullscreen mode Exit fullscreen mode

…and Node + your server file would already be there.

Step 6: Notice the Problems 🚩

This works, but think about what you just did:

  • 😩 Not repeatable — if you forget a step, or do something slightly different next time, you get a different image. There's no record of what you actually typed.
  • 📦 Bloated imagedocker commit snapshots everything: apt cache, shell history, stray files, the nano editor you installed just to write one file. None of that belongs in a production image.
  • 🙈 No audit trail — open my-manual-node-image six months from now and you have no idea how it was built. You can peek with docker history my-manual-node-image, but it only shows vague layer commands, not your actual reasoning.
  • 🤝 Hard to share — to hand this to a teammate, you'd have to push a multi-hundred-MB binary blob, instead of sharing a few lines of text they can read in 10 seconds.
  • 🔁 Painful to rebuild — try recreating this image from scratch a second time using only docker commit and no notes. You'll forget something. Maybe the apt update, maybe the exact install flags.

The Better Way: Write It Down as a Dockerfile

Everything you just did by hand, pulling Ubuntu, installing Node, copying in your code, can be written as a simple text file that Docker runs for you, identically, every single time:

FROM node:18

WORKDIR /app

COPY . .

CMD ["node", "server.js"]
Enter fullscreen mode Exit fullscreen mode

And rebuilt, identically, from scratch with:

docker build -t my-node-app .
Enter fullscreen mode Exit fullscreen mode

No manual apt install, no nano, no remembering steps, no bloat from tools you only needed temporarily.

The Dockerfile is the documentation.

💡 This is actually how Docker images were built in the early days, manual changes + docker commit. Dockerfiles were introduced specifically to fix the problems you just experienced.


10. Writing Your First Dockerfile (Node.js App)

Step 1: Create a simple Node.js app

mkdir docker-demo
cd docker-demo
npm init -y
Enter fullscreen mode Exit fullscreen mode

Create index.js:

console.log("Hello from Docker 🚀");
Enter fullscreen mode Exit fullscreen mode

Step 2: Write the Dockerfile

FROM node:18

WORKDIR /app  # all subsequent commands run from /app
COPY . .      # copy everything from build context into /app
CMD ["node", "index.js"]
Enter fullscreen mode Exit fullscreen mode

Step 3: Build the image

docker build -t my-node-app .
Enter fullscreen mode Exit fullscreen mode

Step 4: Run the container

docker run my-node-app
Enter fullscreen mode Exit fullscreen mode

Critical addition: .dockerignore

Before you build, create a .dockerignore file next to your Dockerfile to exclude sensitive or huge files from the build context:

node_modules
.git
.env
*.log
Enter fullscreen mode Exit fullscreen mode

Understanding CMD and ENTRYPOINT (the #1 confusion)

Both define what command runs when the container starts, but they behave differently.

Instruction Default behavior What happens when you pass arguments after the image name
CMD only Runs the CMD command The entire CMD is replaced by the arguments you typed
ENTRYPOINT only Runs the ENTRYPOINT command The arguments you typed are appended to the ENTRYPOINT
ENTRYPOINT + CMD Runs ENTRYPOINT followed by CMD The CMD part is replaced; ENTRYPOINT stays and the new arguments are appended to it

Example: making a CLI tool

ENTRYPOINT ["curl"]
CMD ["--help"]
Enter fullscreen mode Exit fullscreen mode
  • docker run my-curl → runs curl --help
  • docker run my-curl https://example.com → runs curl https://example.com (CMD overridden)

Shell form vs Exec form (a separate issue)

  • Shell form – e.g. CMD node server.js

    Docker runs it inside /bin/sh -c. This adds a shell process in between, which can interfere with Unix signals (like SIGTERM). Not ideal for the main process.

  • Exec form – e.g. CMD ["node", "server.js"]

    Docker runs the executable directly, so signals are passed straight to your application. This is the recommended form for the main process.

The one‑line takeaway

CMD can be completely replaced; ENTRYPOINT is the fixed executable, and whatever you type after the image name becomes its arguments, overriding CMD.

Always use exec form (["..."]) for the main process.


11. What Are Docker Layers?

Every instruction in a Dockerfile creates a new layer (except some metadata-only instructions like MAINTAINER, LABEL, CMD, etc., which still add a layer but with minimal impact). A layer is essentially a filesystem diff – the changes made by that instruction compared to the previous state.

For example:

FROM node:18
WORKDIR /app
COPY package*.json ./
RUN npm install
COPY . .
Enter fullscreen mode Exit fullscreen mode

This creates layers like:

  1. Layer 1 – Base image (node:18) – contains OS, Node.js, npm, etc.
  2. Layer 2WORKDIR /app – sets the working directory (metadata).
  3. Layer 3COPY package*.json ./ – adds only the package files.
  4. Layer 4RUN npm install – installs dependencies (this often creates a huge layer with node_modules).
  5. Layer 5COPY . . – copies the rest of your source code.

Layers are stored separately and can be shared between images.


How Layer Caching Works

When you build an image, Docker checks each instruction to see if it can use a cached layer from a previous build.

  • For COPY and ADD, Docker compares the checksum of the files being copied. If the files haven’t changed, the layer is reused.
  • For RUN, Docker compares the command string and the checksum of all previous layers. If both are identical, it reuses the cached layer.
  • If an instruction changes, that layer and all subsequent layers are rebuilt (because they depend on the previous state).

This caching can dramatically speed up builds.


Why Order Matters: The “Dependency Manifest First” Pattern

Suppose you have a typical Node.js application:

project/
├── package.json
├── package-lock.json
├── src/
│   ├── index.js
│   ├── ...
└── ...
Enter fullscreen mode Exit fullscreen mode

Your Dockerfile might naively look like:

FROM node:18
WORKDIR /app
COPY . .
RUN npm install
CMD ["node", "src/index.js"]
Enter fullscreen mode Exit fullscreen mode

Now imagine you change one line in src/index.js and rebuild.

  • The COPY . . instruction sees that the source files changed, so its layer is invalidated.
  • Because that layer changed, RUN npm install must also be rebuilt, even though package.json did not change.
  • npm install might take minutes, and it repeats every time you change any source file.

The fix: Copy only the dependency manifests first, then run install, then copy the rest.

FROM node:18
WORKDIR /app

# Copy only files needed for dependency installation
COPY package*.json ./
RUN npm install

# Now copy the rest of the application
COPY . .

CMD ["node", "src/index.js"]
Enter fullscreen mode Exit fullscreen mode

Now when you change src/index.js:

  • The COPY package*.json ./ layer is unchanged → cache hit.
  • The RUN npm install layer is unchanged → cache hit.
  • Only the final COPY . . layer is rebuilt (which is fast).

This simple reordering can reduce build time from minutes to seconds during development.


General Best Practices for Layer Ordering

  1. Put instructions that change least often at the top.
    • Base image (FROM)
    • System package installation (RUN apt-get update && apt-get install ...)
    • Dependency manifests (COPY requirements.txt, package.json, go.mod, etc.)
    • Dependency installation (RUN pip install, npm install, go mod download)
  2. Put instructions that change most often at the bottom.
    • Copying the application source code (COPY . .)
    • Building the application (if not done earlier)
    • The final CMD or ENTRYPOINT
  3. Combine related RUN commands to reduce layers and avoid intermediate caches.
    For example, instead of:

    RUN apt-get update
    RUN apt-get install -y curl
    RUN apt-get clean
    

    Do:

    RUN apt-get update && \
        apt-get install -y curl && \
        apt-get clean
    

    This also helps avoid leaving cache files in the final image.

  4. Use .dockerignore to exclude unnecessary files from the build context. This prevents COPY . . from including node_modules, .git, logs, etc., which would invalidate the layer more often and bloat the image.


12. Volumes (Persist Data and Share Files)

Why Volumes Exist

Containers are designed to be ephemeral and immutable. When a container is deleted, any data written inside its writable layer is lost. Volumes (and other mount types) provide a way to persist data outside the container’s lifecycle, share data between containers, or inject host files.

Docker offers several mount types:

Type Managed by Lives where Use case
Named Volume Docker Docker’s storage area Databases, persistent app data
Bind Mount You Any host directory Local development, config injection
Anonymous Volume Docker Docker’s storage (random ID) Protecting a directory from bind mountsThe Solution: Mounts

1️⃣ Named Volumes

Docker creates and manages the storage location. You don’t need to know the path.

docker volume create my-data
docker run -v my-data:/var/lib/postgresql/data postgres
Enter fullscreen mode Exit fullscreen mode
  • ✅ Survives container removal
  • ✅ Easiest to back up (docker volume commands manage it)
  • ❌ You can't casually browse the files from the host

Commands:

docker volume ls              # list all volumes
docker volume inspect my-data # see where Docker stores it
docker volume rm my-data      # delete it (data is gone)
Enter fullscreen mode Exit fullscreen mode

2️⃣ Bind Mounts

You point a container at an exact folder on your host.

docker run -v $(pwd):/app node
Enter fullscreen mode Exit fullscreen mode
  • ✅ Perfect for development — edit code on your laptop, see it instantly inside the container
  • ✅ You can inspect/edit files directly with your normal tools
  • ❌ Breaks if the path doesn't exist on whatever machine runs it (not portable)
  • ❌ Can accidentally overwrite container files

3️⃣ Anonymous Volumes (the /app/node_modules trick)

You saw this line in Docker Compose examples:

volumes:
  - .:/app                 # bind mount
  - /app/node_modules      # anonymous volume
Enter fullscreen mode Exit fullscreen mode

What’s happening?

When you bind‑mount your host’s source code into /app, the container’s original node_modules would be replaced by whatever (or nothing) exists on your host. By adding an anonymous volume at /app/node_modules, Docker takes the container’s original node_modules, stores it in a new volume, and mounts that volume back onto the same path, shielding it from the bind mount.

This prevents OS/architecture mismatches (e.g., native modules compiled on Mac vs. Linux inside the container). Anonymous volumes are tied to the container; remove the container with -v to clean them up:

docker rm -v <container_id>
Enter fullscreen mode Exit fullscreen mode

Real‑world example: Node.js + PostgreSQL (with Compose)

Let's make this concrete with a setup you'll actually build: a Node API backed by Postgres, with live code reload in dev and durable database storage.

As raw docker run commands (so you can see what's happening)

# Named volume so Postgres data survives container restarts
docker volume create pg-data

docker network create app-net

docker run -d --name db \
  --network app-net \
  -e POSTGRES_PASSWORD=secret \
  -v pg-data:/var/lib/postgresql/data \
  postgres

docker run -d --name api \
  --network app-net \
  -p 3000:3000 \
  -v $(pwd):/app \
  -v /app/node_modules \
  node-api-image
Enter fullscreen mode Exit fullscreen mode

Notice two -v flags on the API container:

  • v $(pwd):/app — a bind mount, so your local code changes reflect instantly inside the container
  • v /app/node_modules — an anonymous volume, used here as a trick to prevent the bind mount from overwriting the container's own node_modules with your host's (which might not match the container's OS/architecture)

This is already a lot to type and remember correctly, which is exactly why this normally lives in a Dockerfile + Compose file instead.

As a Dockerfile

# Dockerfile
FROM node:18

WORKDIR /app

COPY package*.json ./
RUN npm install

COPY . .

EXPOSE 3000
CMD ["node", "index.js"]
Enter fullscreen mode Exit fullscreen mode

As a Compose file (this replaces ALL the commands above

# docker-compose.yml
services:
  api:
    build: .
    ports:
      - "3000:3000"
    volumes:
      - .:/app                # bind mount → live code reload
      - /app/node_modules     # anonymous volume → protects container's own deps
    environment:
      - DATABASE_URL=postgres://postgres:secret@db:5432/postgres
    depends_on:
      - db

  db:
    image: postgres
    environment:
      - POSTGRES_PASSWORD=secret
    volumes:
      - pg-data:/var/lib/postgresql/data   # named volume → durable DB storage

volumes:
  pg-data:   # must be declared here for named volumes used above
Enter fullscreen mode Exit fullscreen mode

Run the whole stack:

docker-compose up
Enter fullscreen mode Exit fullscreen mode

Now: edit a file in your project, and the API container picks it up immediately (bind mount). Stop and remove everything with docker-compose down, then bring it back up; your Postgres data is still there (named volume), because named volumes survive even when you tear down containers. Only docker-compose down -v would also delete the named volume and wipe the database.


13. Networking (How Containers Talk)

Back in the Volumes example, you saw this line and probably skipped past it:

docker network create app-net
Enter fullscreen mode Exit fullscreen mode

Let's actually explain what that does; it's the reason api was able to reach db by just using the name db as a hostname.

The Problem

Containers are isolated. Two containers can’t see each other unless connected to a shared network.

docker run -d --name db postgres
docker run -d --name api node-api-image
Enter fullscreen mode Exit fullscreen mode

If api's code tries to connect to db here, it fails. They're strangers — each living in its own private network bubble.

Docker's Network Types

Type What it does
bridge (default) Private internal network; containers can reach each other by IP, but IPs change
user-defined bridge Same as bridge, but with automatic DNS — containers can reach each other by name
host No isolation; container uses host’s network directly (Linux only)
none No networking

The one you’ll use: user‑defined bridge

docker network create app-net

docker run -d --name db --network app-net postgres

docker run -d --name api --network app-net -p 3000:3000 node-api-image
Enter fullscreen mode Exit fullscreen mode

Because both containers are on app-net, Docker gives them automatic DNS: inside the api container, the hostname db resolves to the database container's internal IP. Your app's connection string can just say:

postgres://postgres:secret@db:5432/postgres
Enter fullscreen mode Exit fullscreen mode

Useful Commands

docker network ls                  # list all networks
docker network inspect app-net     # see which containers are connected, their IPs
docker network connect app-net some-container   # add a running container to a network
docker network disconnect app-net some-container
docker network rm app-net          # delete the network (containers must be removed/disconnected first)
Enter fullscreen mode Exit fullscreen mode

Ports vs networks clarified

  • p 8080:80 maps a host port to a container port — this is for outside access (browser, curl).
  • A shared network gives container‑to‑container communication. You don’t need to publish a database port unless you need direct host access.

Compose Does This Automatically

This is the other half of why Compose is so much nicer than raw commands: every service in a docker-compose.yml file is automatically placed on the same user-defined network, with each service name usable as a hostname, with zero docker network create needed. That's exactly what lets the Compose file in the Volumes section just write depends_on: - db and db:5432 in the connection string with no extra networking setup at all.

You can still customize this if needed:

services:
  api:
    networks:
      - frontend
      - backend
  db:
    networks:
      - backend

networks:
  frontend:
  backend:
Enter fullscreen mode Exit fullscreen mode

This pattern, two custom networks, is a common security practice: db sits on backend only, so it's reachable from api, but a hypothetical public-facing service on frontend could never reach db directly even if compromised.


14. Environment Variables

Environment variables are the primary way to inject configuration into containers at runtime. They allow the same image to behave differently in development, staging, and production without rebuilding.

Setting Environment Variables

A. At runtime with docker run

docker run -e NODE_ENV=production -e API_KEY=123 my-app
Enter fullscreen mode Exit fullscreen mode
  • e or -env sets a variable inside the container.
  • You can pass multiple e flags.
  • To pass a variable from your host’s environment, just reference it:
docker run -e API_KEY=$API_KEY my-app
Enter fullscreen mode Exit fullscreen mode

B. In a Dockerfile with ENV

ENV NODE_ENV=production
ENV API_KEY=123
Enter fullscreen mode Exit fullscreen mode
  • ENV bakes the variable into the image. It becomes the default value, but can still be overridden at runtime with e.
  • ENV values are also available during the build process to subsequent instructions (unlike ARG).

C. In Docker Compose

services:
  app:
    environment:
      - NODE_ENV=production
      - API_KEY=${API_KEY}   # from host env or .env file
Enter fullscreen mode Exit fullscreen mode
  • The environment key can be a list (as above) or a map:
environment:
  NODE_ENV: production
  API_KEY: ${API_KEY}
Enter fullscreen mode Exit fullscreen mode
  • Variables like ${API_KEY} are substituted from the shell environment or from a .env file located in the same directory as the Compose file.

ARG vs ENV – Build-time vs Runtime

Instruction Purpose Available at build time? Available at runtime? Overridable at runtime?
ARG Build‑time variable Yes (with docker build --build-arg) No (unless you copy it to ENV) No
ENV Set environment variable Yes Yes Yes (with docker run -e)

Example:

ARG VERSION=latest
ENV APP_VERSION=$VERSION
Enter fullscreen mode Exit fullscreen mode
  • ARG lets you pass a value only during build: docker build --build-arg VERSION=1.2.3 .
  • ENV makes that value available inside the running container.

Environment Variable Precedence

If the same variable is set in multiple places, the order of precedence (highest to lowest) is:

  1. docker run -e or Compose environment (runtime)
  2. ENV in Dockerfile (image default)
  3. ARG (only during build, not present in final image unless promoted to ENV)

Example:

  • Dockerfile: ENV NODE_ENV=development
  • Run: docker run -e NODE_ENV=production my-app
  • Inside container: NODE_ENV=production

Using .env Files

You can load environment variables from a file into docker run using --env-file:

docker run --env-file ./config.env my-app
Enter fullscreen mode Exit fullscreen mode

The file format is simple:

NODE_ENV=production
API_KEY=123
Enter fullscreen mode Exit fullscreen mode
  • No quotes needed unless values contain spaces.
  • The file is not stored in the image, so it’s a good way to avoid baking secrets into the Dockerfile.

In Compose, a .env file in the project directory is automatically read for variable substitution, but it is not passed to containers unless you explicitly include the variables in the environment section.


Pitfalls

  • All values are strings—parse numbers/booleans in your app.
  • Don’t put secrets in ENV (they end up in image layers and docker history).
  • docker inspect shows all environment variables—secrets should never be there.
  • Use .env files for local development; never commit them to version control.

16. Docker Compose (Multiple Containers)

Used for multi‑service applications (backend, database, cache, etc.).

Example: Node + MongoDB

# docker-compose.yml
services:
  app:
    build: .
    ports:
      - "3000:3000"
    environment:
      - MONGO_URI=mongodb://mongo:27017/mydb

  mongo:
    image: mongo:6
    volumes:
      - mongo-data:/data/db

volumes:
  mongo-data:
Enter fullscreen mode Exit fullscreen mode

Run:

docker compose up      # or docker-compose up (older versions)
Enter fullscreen mode Exit fullscreen mode

Stop:

docker compose down
Enter fullscreen mode Exit fullscreen mode

Note: Newer Docker Desktop uses docker compose (no hyphen). The Compose files are identical.


Real‑World Architecture

A typical production stack:

[ Browser ]
    ↓
[ Frontend (React) :80 ]
    ↓
[ Backend (Node API) :3000 ]
    ├──→ [ Postgres :5432 ]
    └──→ [ Redis :6379 ]
Enter fullscreen mode Exit fullscreen mode

All services run as containers, connected by a shared network. Persistent data (databases, uploads) use named volumes. Source code in development uses bind mounts. Secrets are injected via files or a vault.


Debugging Containers

Need Command
View logs docker logs <container>
Follow logs docker logs -f <container>
Open a shell docker exec -it <container> sh (or bash)
Watch resource usage docker stats
Inspect metadata docker inspect <name_or_id>

Tips

  • Use docker logs --tail 50 to see recent lines.
  • docker exec is also how you run one‑off commands (e.g., database migrations).

Resource Limits (Production Essential)

Prevent one container from exhausting the host.

Docker run

docker run -d --memory="256m" --cpus="1.5" my-app
Enter fullscreen mode Exit fullscreen mode

Compose

services:
  app:
    deploy:
      resources:
        limits:
          cpus: '0.50'
          memory: 512M
Enter fullscreen mode Exit fullscreen mode

Why it matters

A memory leak or runaway process can take down the entire server. Limits ensure isolation and predictable performance.


Health Checks (Is the Container Actually Working?)

A container can be “running” while the app inside is deadlocked or not ready. Health checks let Docker and orchestrators know the real state.

Dockerfile

HEALTHCHECK --interval=30s --timeout=3s --retries=3 \
  CMD curl -f <http://localhost:3000/health> || exit 1
Enter fullscreen mode Exit fullscreen mode

Compose

services:
  api:
    healthcheck:
      test: ["CMD", "curl", "-f", "<http://localhost:3000/health>"]
      interval: 30s
      timeout: 3s
      retries: 3

  db:
    image: postgres
    healthcheck:
      test: ["CMD-SHELL", "pg_isready -U postgres"]
      interval: 5s
      timeout: 3s
      retries: 5
Enter fullscreen mode Exit fullscreen mode

Usage

  • depends_on with condition: service_healthy ensures the DB is accepting connections before the API starts.
  • Swarm and Kubernetes use health checks to restart unhealthy containers.

17. Multi-Stage Builds (The Production Game-Changer)

The Problem

When building a modern application, your environment needs a lot of heavy tools to compile or build your code. For example:

  • In TypeScript/Node.js, you need the full TypeScript compiler, development dependencies, and build tools.
  • In Go or Rust, you need a massive compiler toolchain.
  • In React/Vue/Angular, you need thousands of development npm packages just to output a few static HTML, CSS, and JS files.

If you keep all those build tools in your final image, your production image becomes bloated (often 1GB+), making deployments slow and increasing your security vulnerability surface area.

The Solution

Multi-stage builds allow you to use multiple FROM statements in a single Dockerfile. Each FROM instruction begins a new stage with a completely empty filesystem — it does not automatically inherit anything from the stage before it. Think of each stage as its own separate container that just happens to live in the same file.

This is the one idea that makes everything else click: nothing crosses from one stage to the next unless you explicitly copy it over. That's what makes it possible to use a heavy, bloated environment to build your app, then start completely fresh with a tiny environment for running it, taking only the finished output with you.

Real-World Example: TypeScript Node.js App

Let's see how a multi-stage Dockerfile looks in practice. Instead of two different Dockerfiles, everything happens in one file, divided into distinct stages using the AS keyword.

# ==========================================
# STAGE 1: The Build Environment
# ==========================================
FROM node:18-alpine AS builder

# WORKDIR sets the working directory inside the container for every
# command that follows. If /app doesn't exist yet, Docker creates it.
# Think of it like running `mkdir -p /app && cd /app`.
WORKDIR /app

# Copy ONLY the dependency manifest files first, before any source code.
# Why does order matter here? Docker caches each step (called a "layer").
# If package.json hasn't changed since your last build, Docker will reuse
# the cached result of `npm install` instead of running it again — even
# if you've changed your source code. This alone can save minutes per build.
COPY package*.json ./
RUN npm install

# NOW copy the rest of the source code, after dependencies are installed.
COPY . .

# Compile TypeScript to plain JavaScript. This outputs the production
# JS files into a /dist folder (this script is defined in package.json).
RUN npm run build

# package.json usually lists two kinds of dependencies:
#   - "dependencies": needed to RUN the app (e.g. express)
#   - "devDependencies": needed to BUILD the app (e.g. typescript, jest)
# `npm install` installs both. Now that the build is done, we no longer
# need devDependencies, so we remove them to shrink things down before
# the next stage copies this folder over.
RUN npm prune --production

# ==========================================
# STAGE 2: The Lean Production Environment
# ==========================================
# This FROM starts completely fresh — a brand new, empty filesystem.
# None of the TypeScript compiler, source code, or dev tools from
# Stage 1 exist here unless we explicitly copy them in below.
FROM node:18-alpine AS runner

WORKDIR /app

# Set environment to production (many libraries check this variable
# to disable debug logging, verbose errors, etc.)
ENV NODE_ENV=production

# CRITICAL STEP: Copy ONLY the compiled JS and production modules
# from Stage 1, by name ("builder"), not from your local laptop.
# This is the one and only bridge between the two stages.
COPY --from=builder /app/package*.json ./
COPY --from=builder /app/node_modules ./node_modules
COPY --from=builder /app/dist ./dist

# Run the application as a non-root user, instead of the default root.
# This specific user ("node") is pre-created inside official Node.js
# images as a security convenience — it won't exist on every base image,
# so check your base image's docs if you reuse this pattern elsewhere.
USER node

EXPOSE 3000

CMD ["node", "dist/index.js"]
Enter fullscreen mode Exit fullscreen mode

One more thing this example leaves out: add a .dockerignore file next to your Dockerfile, listing things like node_modules, .git, and .env. Without it, COPY . . will also copy your local node_modules folder and any secrets in .env straight into the image — slowing down your build and potentially leaking credentials into a layer that ends up on a registry somewhere.

Breaking Down the Magic 🪄

  • AS builder / AS runner: This names your stages, so later stages can refer back to an earlier one by name instead of by number.
  • COPY --from=builder: Instead of copying files from your local host laptop, Docker copies them directly out of the filesystem of the named stage above. This only works because that stage already ran and produced those files.
  • The Resulting Footprint: Stage 1 contains TypeScript compilers, local test tools, caches, and raw source code — often around 800MB, though the exact number depends on your dependencies. Stage 2 starts from a clean slate, pulls in only the lightweight compiled JavaScript and production packages, and typically finishes somewhere around 100MB.

Why This Matters for Production

  1. Blazing Fast Deployments: Pulling a 100MB image down to your AWS/GCP servers takes seconds; pulling a 1GB image takes minutes.
  2. Hardened Security: Attackers can't exploit build tools, package managers, or source code files if they don't exist inside your running production container.

Quick Recap for Beginners

If you only remember three things from this section:

  1. Every FROM starts a brand-new, empty environment — nothing carries over automatically.
  2. COPY --from=<stage name> is how you deliberately move specific files from one stage to another.
  3. Put your heavy build tools in an early stage, and copy only the finished output into a small final stage.

18. Security: Running as Non‑Root

When a container starts, it normally runs as a superuser called root (like an administrator). If a hacker breaks out of the container, they might get that same superuser power on your whole computer. That's dangerous.

So we tell Docker: "Don't use root. Use a normal, limited user instead."

How to do it in a Dockerfile

If your image is based on Alpine Linux (a small, common base), create a new user and switch to it:

RUN addgroup -g 1001 appgroup && \
    adduser -u 1001 -G appgroup -s /bin/sh -D appuser
USER appuser
Enter fullscreen mode Exit fullscreen mode

If you use the official Node image, there is already a user called node. Just switch:

USER node
Enter fullscreen mode Exit fullscreen mode

Important things to remember

  • Put USER at the end of the Dockerfile, after you've installed packages and done things that need root.
  • The app files must be readable by that normal user. If you copy files with COPY, you can add -chown to give ownership to the user:

    COPY --chown=node:node . .
    
  • If your app writes files (logs, uploads), make sure the folder is writable by that user. Example:

    RUN mkdir -p /app/data && chown node:node /app/data
    

Why this matters

  • It limits damage if something goes wrong.
  • It’s a basic safety rule for running containers in production.

Common mistakes

  • Trying to use ports below 1024 (like 80) as a non‑root user – normal users usually can't. Use ports like 3000, 8080.
  • Using a mounted folder from your host that has permissions your container user can't write to – you may need to adjust permissions or run the container with the same user ID.
  • Some images (like nginx) still run as root by default. Use their non‑root versions (e.g., nginx:alpine and add USER nginx).

Bottom line: Always switch to a non‑root user before your container runs in production.


19. Cleaning Up Docker (Reclaim Disk Space)

Docker stores images, containers, volumes, networks, and build cache. Over time, these accumulate and can eat gigabytes.

Check current disk usage

docker system df
Enter fullscreen mode Exit fullscreen mode

Shows disk usage by images, containers, volumes, and build cache.

Remove unused data (safe, but careful with volumes)

Command What it removes Danger level
docker container prune Stopped containers only Safe (data inside containers is lost anyway)
docker image prune Dangling images (untagged, unused) Safe
docker image prune -a All images not used by a running container Medium (you may need to re‑pull)
docker volume prune Anonymous and unused named volumes High (deletes database data!)
docker network prune Unused networks Safe
docker system prune Stopped containers, dangling images, unused networks, build cache Medium
docker system prune -a Everything above plus all unused images High (will delete many images)
docker system prune -a --volumes Adds unused volumes to the previous command Very high (deletes all unused volumes, including named ones)

Typical workflow

Development machine (safe, frequent)

docker system prune -f  # removes stopped containers, dangling images, etc.
Enter fullscreen mode Exit fullscreen mode

Need to free more space (but keep images in use)

docker image prune -a   # removes images not used by any container
Enter fullscreen mode Exit fullscreen mode

Careful cleanup of volumes (only if you're sure)

docker volume prune
Enter fullscreen mode Exit fullscreen mode

Tips

  • Use f or -force to skip confirmation.
  • docker system prune does not remove named volumes by default (good).
  • To also remove named volumes, use docker system prune --volumes – but that will wipe your databases.
  • Run cleanup regularly (weekly or after large builds) to keep your disk happy.

Rule of thumb:

  • Prune stopped containers and dangling images often.
  • Prune unused images when disk is low.
  • Prune volumes only if you are absolutely sure you don't need the data.

20. Best Practices (Cheat Sheet)

  • ✔ Use small base images (alpine variants) when possible
  • ✔ Always include a .dockerignore
  • ✔ Order Dockerfile instructions for optimal caching (copy deps first)
  • ✔ Use exec form for CMD/ENTRYPOINT
  • ✔ Don’t run as root — USER after setup
  • ✔ Define resource limits in production
  • ✔ Add health checks
  • ✔ Use multi‑stage builds for compiled languages
  • ✔ Never store secrets in images or environment variables — use secrets management
  • ✔ Tag your images with specific versions (not :latest)
  • ✔ Regularly prune unused resources

About This Article

This article is based on my personal notes from my portfolio site, where I document what I'm learning as I go. You can find the original notes here: Docker Handbook Notes.


Top comments (0)