Post

Docker Part-1 — Hands-On Guide

A complete beginner's guide to Docker and containerization — concepts, commands, Dockerfiles, volumes, networking, Q&A, exams, and a capstone project.

Docker Part-1 — Hands-On Guide

This guide assumes you have never used Docker or any container technology before. Every concept is explained from scratch before any commands are introduced. Read each section in order the first time through.


Table of Contents

  1. What is Containerization?
  2. The Docker Ecosystem
  3. Installing Docker
  4. Core Concepts: Images and Containers
  5. Your First Container
  6. Working with Images
  7. Running Containers
  8. Writing a Dockerfile
  9. Building Your Own Images
  10. Persisting Data with Volumes
  11. Container Networking
  12. Environment Variables and Configuration
  13. Q&A — Beginner
  14. Q&A — Intermediate
  15. Exams: P1 through P5
  16. Capstone Project

Chapter 1: What is Containerization?

1.1 The Problem Containerization Solves

Imagine you are a developer. You write an application on your laptop — a web server, maybe, that reads from a database. You spend two days getting it to run perfectly. Then you hand it to a colleague and they say: “It doesn’t work on my machine.”

You investigate. It turns out your colleague has a different version of Python installed. Or a different version of a library. Or they are on Windows and you are on macOS. The code is identical. The environment is different. The application breaks.

This is one of the oldest problems in software. It even has a name: the “works on my machine” problem.

Now scale this up. You write an application, you want to run it on a server in the cloud. The server has different versions of everything. You have to spend time configuring the server to look exactly like your laptop. This is tedious, error-prone, and has to be repeated for every server you add.

Containerization solves this by packaging not just your code, but the entire environment your code needs to run — together, in one unit called a container. That unit runs identically on your laptop, your colleague’s laptop, a server in London, or a server in Singapore. The environment travels with the code.


1.2 What is a Container?

A container is a lightweight, isolated process that runs on your computer. It has its own:

  • Filesystem — its own files, separate from your machine’s files
  • Network — its own network interface and IP address
  • Processes — its own running programs, isolated from your machine’s programs
  • Environment — its own environment variables, user accounts, and configuration

Despite all this isolation, a container is not a separate computer. It runs directly on your machine’s operating system kernel. It shares the kernel with your host machine and all other containers. This is what makes containers so lightweight and fast.

Think of it this way: your operating system is like a restaurant kitchen. A container is like a separate cooking station within that kitchen — it has its own tools, ingredients, and workspace, but it shares the same building, plumbing, and electricity. A virtual machine, by contrast, is like building an entirely separate restaurant just to cook one dish.


1.3 Containers vs Virtual Machines

Before containers became popular, the standard way to isolate applications was with Virtual Machines (VMs). Understanding the difference between the two will help you understand why containers became so dominant.

Virtual Machine architecture:

1
2
3
4
5
6
7
8
9
10
11
12
┌─────────────────────────────────────────────┐
│                  Your Apps                   │
├──────────────┬──────────────┬────────────────┤
│  Guest OS    │  Guest OS    │   Guest OS     │
│  (Ubuntu)    │  (CentOS)    │   (Windows)    │
├──────────────┴──────────────┴────────────────┤
│              Hypervisor (VMware, VirtualBox)  │
├─────────────────────────────────────────────┤
│              Host Operating System           │
├─────────────────────────────────────────────┤
│              Physical Hardware               │
└─────────────────────────────────────────────┘

Each VM includes a full operating system — kernel, system libraries, everything. That is why VMs are typically several gigabytes in size and take minutes to start.

Container architecture:

1
2
3
4
5
6
7
8
9
10
┌────────────┬────────────┬────────────────────┐
│  Container │  Container │     Container      │
│  (App A)   │  (App B)   │     (App C)        │
├────────────┴────────────┴────────────────────┤
│              Container Runtime (Docker)       │
├─────────────────────────────────────────────┤
│              Host Operating System           │
├─────────────────────────────────────────────┤
│              Physical Hardware               │
└─────────────────────────────────────────────┘

Containers share the host OS kernel. They do not each carry their own operating system. This is why containers are:

Property Virtual Machine Container
Size Gigabytes Megabytes
Startup time Minutes Seconds (often milliseconds)
Memory usage High (full OS per VM) Low (shared kernel)
Isolation Strong (full OS boundary) Process-level (namespace isolation)
Portability Good Excellent

1.4 How Containers Achieve Isolation

Containers are made possible by two Linux kernel features: namespaces and cgroups. You do not need to understand these in depth to use Docker, but knowing they exist will help you understand what containers actually are.

Namespaces give each container its own view of the system:

  • PID namespace — the container sees its own process tree. The first process in the container has PID 1, even though on the host machine it has a different PID.
  • Network namespace — the container has its own network stack, IP addresses, and routing tables.
  • Mount namespace — the container has its own filesystem view. It cannot see the host’s files unless you explicitly share them.
  • UTS namespace — the container has its own hostname.
  • User namespace — the container can have its own user IDs.

cgroups (control groups) limit how much of the host’s resources a container can use — how much CPU, how much RAM, how much network bandwidth. This prevents one container from consuming all the resources on a machine and starving the others.

When you run a Docker container, Docker sets up these namespaces and cgroups automatically. From the perspective of the process running inside the container, it looks like it has an entire computer to itself.


1.5 When Should You Use Containers?

Containers are useful in a wide range of situations:

Development: Package your application and all its dependencies so every developer on your team runs the exact same environment. No more “install this version of Node, set up this database, configure this environment variable” onboarding guides.

Testing: Spin up a clean, fresh environment for every test run. Tear it down afterward. This guarantees tests don’t affect each other.

Microservices: Run each service of your application in its own container. Each service can use different languages, frameworks, and library versions. You can scale individual services independently.

CI/CD pipelines: Automated build and deployment pipelines use containers to ensure the build environment is identical every time.

Running third-party software: Instead of installing Postgres, Redis, or Nginx directly on your machine (and dealing with version conflicts), run each one in a container. Remove the container when you’re done — your machine stays clean.


Chapter 2: The Docker Ecosystem

2.1 What is Docker?

Docker is the most widely used container platform in the world. When most people say “containers,” they mean Docker containers. Docker consists of several components:

  • Docker Engine — the background service (called a daemon) that does the actual work of creating and managing containers. It runs silently on your machine.
  • Docker CLI — the command-line tool you type commands into. When you run docker run, the CLI sends a request to the Docker Engine, which does the work.
  • Docker Desktop — a graphical application for macOS and Windows that bundles Docker Engine and Docker CLI together, along with a dashboard for managing containers visually.

The relationship between these components:

1
2
3
4
5
6
7
You (typing commands)
        │
        ▼
  Docker CLI  ──── sends API requests ────▶  Docker Engine (daemon)
                                                      │
                                                      ▼
                                              Creates/manages containers

When you run docker run nginx, here is what actually happens:

  1. The Docker CLI sends an API request to the Docker Engine
  2. The Docker Engine checks if the nginx image exists locally
  3. If not, it downloads the image from Docker Hub
  4. It creates a container from the image
  5. It starts the container
  6. The CLI shows you the output

2.2 Docker Hub

Docker Hub (hub.docker.com) is a public registry — a large library of container images that anyone can download and use. Think of it like the App Store, but for container images.

Official Images are images maintained directly by Docker or the software’s original creators. They are vetted, regularly updated, and considered safe to use. Examples:

  • nginx — the Nginx web server
  • postgres — PostgreSQL database
  • node — Node.js runtime
  • python — Python runtime
  • ubuntu — Ubuntu Linux base image
  • redis — Redis in-memory database

Community images are published by anyone. They follow the naming convention username/imagename, for example bitnami/wordpress or jenkins/jenkins.

When you pull an image without specifying a registry, Docker looks for it on Docker Hub by default.


2.3 Podman

Podman is an alternative container engine to Docker. It was created by Red Hat and is the default container tool on Red Hat Enterprise Linux (RHEL), Fedora, and CentOS.

The key difference: Podman has no daemon.

Docker requires a background service (the Docker Engine daemon) running constantly on your machine. Podman does not. Each container you run is a direct child process of your shell, not a child of some background service.

This has security implications. Because Docker’s daemon runs as root, any user who can talk to the Docker socket has effectively has root access on the host machine. Podman is rootless by default — containers run as your own user, not as root.

From a practical standpoint, Podman’s commands are identical to Docker’s. You can substitute podman for docker in almost every command in this guide and it will work. This was a deliberate design decision to ease migration.

1
2
3
4
5
# Docker
docker run -d -p 8080:80 nginx

# Podman (identical)
podman run -d -p 8080:80 nginx

In this guide we use Docker, but know that Podman is a valid and often preferred alternative in enterprise environments.


2.4 Colima

Colima is a free, open-source container runtime that works on both macOS and Linux. It solves a particular problem: Docker Desktop requires a license for commercial use in organizations with more than 250 employees or more than $10M in revenue. For many companies, this is a significant cost.

Colima provides a free alternative — on macOS and Linux alike — without needing Docker Desktop at all.

How it works on macOS: macOS is not Linux. Docker Engine is fundamentally a Linux technology (it uses Linux namespaces and cgroups). On macOS, Docker Desktop runs a small Linux virtual machine in the background — your containers actually run inside that VM. Colima does the same thing using Lima (an open-source VM manager), and it is free.

How it works on Linux: On Linux, Docker Engine can run natively. Colima on Linux still uses Lima under the hood to create a managed VM, but this gives you an isolated, reproducible Docker environment that is separate from any system-level Docker installation. This is useful on Linux machines where you want to avoid running the Docker daemon as root at the system level, or where you want Colima’s simpler lifecycle management (colima start / colima stop).

When you start Colima, it starts the runtime and sets up a Docker socket. Your existing docker commands then talk to that socket exactly as normal.

1
2
3
4
5
6
7
8
9
# Install Colima (macOS via Homebrew, Linux via Homebrew or direct install)
brew install colima docker

# Start the container runtime
colima start

# Now use Docker normally
docker ps
docker run hello-world

Which should you use? If your organization allows Docker Desktop and you are on macOS, it is the simplest setup. If you prefer open-source, need to avoid the license, or are on Linux and want a lightweight managed runtime, use Colima. Both work identically for everything in this guide.


Chapter 3: Installing Docker

3.1 Choosing Your Installation Method

Operating System Recommended Method
macOS (personal / license OK) Docker Desktop
macOS (no Docker Desktop license) Colima
Windows Docker Desktop
Ubuntu / Debian Linux Docker Engine (via apt) or Colima
RHEL / Fedora / CentOS Podman (built in), Docker Engine, or Colima

3.2 Installing Docker Desktop (macOS)

What you are installing: A graphical macOS application that bundles Docker Engine, Docker CLI, and a visual dashboard.

Step 1 — Download

Go to https://docs.docker.com/desktop/install/mac-install/ and download the installer for your chip type:

  • If your Mac has an Intel processor → download the Intel chip version
  • If your Mac has an M1/M2/M3/M4 chip → download the Apple Silicon version

If you’re unsure which chip you have: click the Apple menu (top-left) → About This Mac. Look for “Chip” or “Processor.”

Step 2 — Install

Open the downloaded .dmg file. Drag Docker to your Applications folder. Open Docker from your Applications folder. macOS will ask for your password — Docker Desktop needs administrator access to set up the networking components.

Step 3 — Wait for Docker to start

The Docker whale icon will appear in your menu bar. Wait for it to stop animating — when the icon is steady, Docker is ready.

Step 4 — Verify

Open your Terminal application and run:

1
docker --version

You should see something like:

1
Docker version 26.1.4, build 5650f9b

Then run:

1
docker run hello-world

You should see a message that begins with “Hello from Docker!” — this confirms everything is working.


3.3 Installing via Colima (macOS and Linux, no Docker Desktop)

What you are installing: Colima (a Linux VM manager) and the Docker CLI (the command-line tool that talks to Docker).

Step 1 — Install Homebrew (if you don’t have it)

Homebrew is a package manager for macOS. Open Terminal and run:

1
/bin/bash -c "$(curl -fsSL https://raw.githubusercontent.com/Homebrew/install/HEAD/install.sh)"

Follow the on-screen instructions.

Step 2 — Install Colima and Docker CLI

1
brew install colima docker

This installs both the Colima VM manager and the docker command-line tool. Note: this does NOT install Docker Engine or Docker Desktop — it just installs the CLI that will talk to Colima’s Docker socket.

Step 3 — Start Colima

1
colima start

The first time you run this, Colima downloads a small Linux VM image. This may take a few minutes. Subsequent starts are much faster (usually 10–15 seconds).

You will see output similar to:

1
2
3
4
5
6
INFO[0000] starting colima
INFO[0000] runtime: docker
INFO[0010] starting ...                                  context=vm
INFO[0030] provisioning ...                              context=docker
INFO[0031] starting ...                                  context=docker
INFO[0032] done

Step 4 — Verify

1
docker run hello-world

You should see the “Hello from Docker!” message.

Step 5 — Stopping and starting Colima

When you’re done working:

1
colima stop

When you want to start again:

1
colima start

Tip: Add colima start to your shell startup file if you want Docker available immediately every time you open a terminal.


3.4 Installing Docker Engine on Ubuntu/Debian Linux

Step 1 — Remove any old Docker versions

1
sudo apt-get remove docker docker-engine docker.io containerd runc

Step 2 — Set up the Docker repository

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
sudo apt-get update

sudo apt-get install -y ca-certificates curl gnupg

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

sudo chmod a+r /etc/apt/keyrings/docker.gpg

echo \
  "deb [arch=$(dpkg --print-architecture) signed-by=/etc/apt/keyrings/docker.gpg] \
  https://download.docker.com/linux/ubuntu \
  $(. /etc/os-release && echo "$VERSION_CODENAME") stable" | \
  sudo tee /etc/apt/sources.list.d/docker.list > /dev/null

Step 3 — Install Docker Engine

1
2
sudo apt-get update
sudo apt-get install -y docker-ce docker-ce-cli containerd.io docker-buildx-plugin docker-compose-plugin

Step 4 — Allow your user to run Docker without sudo

By default, Docker requires root. This adds your user to the docker group so you can run docker commands without sudo:

1
sudo usermod -aG docker $USER

Log out and log back in for this to take effect.

Step 5 — Verify

1
docker run hello-world

Chapter 4: Core Concepts — Images and Containers

4.1 The Most Important Distinction in Docker

Before you run a single command, you must understand the difference between an image and a container. This confusion trips up almost every beginner, and it causes commands to feel random and confusing until it clicks.

Here is the short version:

  • An image is a static, read-only blueprint. It is a file (actually a set of files) sitting on your disk. It does nothing on its own.
  • A container is a running process that was created from an image. It is alive, consuming CPU and memory, doing something.

4.2 Understanding Images

An image is like a recipe and a set of ingredients, pre-measured and laid out on the counter. The recipe describes exactly how to set up an environment — which operating system files to include, which programs to install, which files to copy in, which command to run on startup.

An image contains:

  • A base filesystem — usually a stripped-down Linux filesystem with only the essentials
  • Application files — your code, or the code of whatever software the image is for (Nginx, Postgres, etc.)
  • Metadata — information like which command to run when a container starts, which network ports the application uses, what environment variables are set

An image is immutable — once built, it never changes. If you want a different version, you build a new image.


4.3 Image Layers

Images are not stored as single flat files. They are stored as a stack of layers, where each layer represents a change on top of the previous one.

Consider an image for a Node.js application. It might have these layers:

1
2
3
4
Layer 4 (top):    COPY ./src /app/src          ← your application code
Layer 3:          RUN npm install               ← your dependencies
Layer 2:          RUN apt-get install -y curl   ← system packages
Layer 1 (base):   FROM node:18-alpine           ← Node.js on Alpine Linux

Each layer is a diff — it records only what changed from the layer below it. This design has two major benefits:

1. Layers are shared. If you have ten different images that all start FROM node:18-alpine, they all share that base layer on disk. Docker does not store ten copies — it stores one. This saves significant disk space.

2. Layers are cached during builds. When you rebuild an image, Docker checks each layer. If the instruction that produces a layer hasn’t changed, Docker reuses the cached layer instead of running the instruction again. This makes rebuilds very fast.


4.4 Image Tags

Every image has a name and a tag. The tag identifies a specific version of the image. They are written together with a colon:

1
imagename:tag

Examples:

1
2
3
4
5
6
nginx:latest        ← the latest version of nginx (this is the default if you omit the tag)
nginx:1.25          ← a specific version of nginx
node:18             ← Node.js version 18
node:18-alpine      ← Node.js version 18, on Alpine Linux (a very small base OS)
postgres:15.3       ← PostgreSQL version 15.3
ubuntu:22.04        ← Ubuntu version 22.04

The latest tag does not necessarily mean the most recent release — it just means “whatever the image maintainer tagged as latest.” In practice it usually is the most recent stable version, but it is not guaranteed. For any serious project, always pin to a specific version tag (nginx:1.25, not nginx:latest) so your environment stays consistent.

Alpine variants are worth calling out specifically. Alpine Linux is an extremely small Linux distribution (~5MB). Many official images offer Alpine-based variants. An nginx:alpine image is around 40MB compared to the standard nginx image at around 180MB. For production use, smaller images mean faster downloads and a smaller attack surface.


4.5 Understanding Containers

A container is what you get when you tell Docker to run an image. Docker takes the image’s filesystem, adds a thin writable layer on top (so the container can write files without modifying the image), sets up the network namespace, and starts the process defined by the image.

The container’s writable layer is the key thing to understand: the container writes on top of the image, not into it. The image itself is never modified. This is why you can run ten containers from the same image simultaneously — they all read from the same image layers, and each has its own writable layer on top.

1
2
3
4
5
6
7
8
9
10
11
Container 1:  [ Writable layer (container 1's changes) ]
              [ Layer 4: COPY ./src      ] ← shared, read-only
              [ Layer 3: RUN npm install ] ← shared, read-only
              [ Layer 2: System packages ] ← shared, read-only
              [ Layer 1: node:18-alpine  ] ← shared, read-only

Container 2:  [ Writable layer (container 2's changes) ]
              [ Layer 4: COPY ./src      ] ← same shared layers
              [ Layer 3: RUN npm install ]
              [ Layer 2: System packages ]
              [ Layer 1: node:18-alpine  ]

4.6 Container Lifecycle

A container goes through several states during its life:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
              docker run
                  │
                  ▼
            [ Created ]  ←── docker create (create without starting)
                  │
                  │ docker start
                  ▼
            [ Running ]  ←──────────────────────┐
                  │                              │
                  │ process exits naturally      │ docker restart
                  │ or: docker stop              │
                  ▼                              │
            [ Stopped / Exited ] ───────────────►┘
                  │
                  │ docker rm
                  ▼
            [ Deleted ]

Key points:

  • A stopped container still exists. All its data in the writable layer is still there. It can be restarted.
  • A deleted container is gone forever, along with all data in its writable layer.
  • The image is unaffected by anything that happens to containers created from it.

This is why data management (covered in Chapter 10 on Volumes) is so important. If you store data inside a container’s writable layer and delete the container, that data is gone.


4.7 The Analogy Summarized

Programming Concept Docker Equivalent
Class definition Image
Object instance Container
Constructor docker run
new MyClass() × 10 10 containers from one image

Or, if cooking analogies work better for you:

Cooking Concept Docker Equivalent
Recipe Dockerfile
Dish made from recipe Image
A plate of that dish Container
Multiple plates Multiple containers from one image

Chapter 5: Your First Container

5.1 The Concept — What Happens When You Run a Container

Before touching the terminal, understand what is about to happen when you type docker run hello-world:

  1. The Docker CLI receives your command
  2. It asks the Docker Engine: “Do you have an image called hello-world locally?”
  3. The Engine says no — so it reaches out to Docker Hub and downloads it
  4. The Engine creates a new container from that image
  5. The Engine starts the container — the process defined inside the image runs
  6. That process prints a message and exits
  7. The container stops because its process finished

The container is now stopped but still exists on your machine. The image is also cached locally. Nothing has been cleaned up automatically.


5.2 Running hello-world

The Command

1
docker run hello-world

Breaking Down the Command

Part Meaning
docker The Docker CLI tool
run Subcommand: create and start a new container
hello-world The name of the image to use

Running It — What You Will See

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
Unable to find image 'hello-world:latest' locally
latest: Pulling from library/hello-world
719385e32844: Pull complete
Digest: sha256:a13ec89cdf897b3e551bd9f89d499db6ff3a7f44c5b9eb8bca40da20d4...
Status: Downloaded newer image for hello-world:latest

Hello from Docker!
This message shows that your installation appears to be working correctly.

To generate this message, Docker took the following steps:
 1. The Docker client contacted the Docker daemon.
 2. The Docker daemon pulled the "hello-world" image from the Docker Hub.
 3. The Docker daemon created a new container from that image which runs the
    executable that produces the output you are currently reading.
 4. The Docker daemon streamed that output to the Docker client, which sent it
    to your terminal.

Reading the Output Line by Line

  • “Unable to find image ‘hello-world:latest’ locally” — Docker checked its local cache and found nothing. Expected the first time.
  • “Pulling from library/hello-world” — downloading from Docker Hub. The path library/hello-world means it is an official image.
  • “719385e32844: Pull complete” — one image layer downloaded and verified.
  • “Hello from Docker!” — output from the process that ran inside the container.

Run the same command a second time — the “Pulling” lines do not appear. Docker uses the cached image.


5.3 Running an Interactive Container

The hello-world example just prints and exits. More often you will want to get a shell inside a container and interact with it.

The Concept

By default a container’s input and output are not connected to your terminal. To get an interactive session you need two things: keep stdin open so you can type (-i), and allocate a pseudo-terminal so the shell shows a proper prompt (-t). These are almost always combined as -it.

The Command

1
docker run -it ubuntu bash

Breaking Down the Command

Part Meaning
-i Keep stdin open — lets you type into the container
-t Allocate a pseudo-terminal — makes output look like a proper shell
ubuntu Use the official Ubuntu image
bash Run bash inside the container instead of the image’s default command

Running It — What You Will See

1
2
3
4
Unable to find image 'ubuntu:latest' locally
...
Status: Downloaded newer image for ubuntu:latest
root@a3f7b2c91d4e:/#

That prompt is a bash shell inside the Ubuntu container. The a3f7b2c91d4e is the container ID. You are now in a completely isolated Linux environment.

Try these inside the container:

1
2
3
4
5
6
7
8
9
10
11
12
13
# What OS is this?
cat /etc/os-release
# NAME="Ubuntu"

# What processes are running? (only bash and ps — nothing else)
ps aux

# Create a file
echo "I am inside a container" > /tmp/test.txt
cat /tmp/test.txt

# Exit the container
exit

After exit, you are back on your host machine. The container is stopped. The file /tmp/test.txt exists only in that container’s writable layer — if you run docker run -it ubuntu bash again, it creates a brand new container and the file will not be there. This is a critical behaviour to remember.


5.4 Running a Background Service

Real-world containers usually run long-lived services (web servers, databases) that you want running in the background.

The Command

1
docker run -d -p 8080:80 --name my-nginx nginx

Breaking Down the Command

Part Meaning
-d Detached mode — run in the background, return your terminal immediately
-p 8080:80 Map host port 8080 to container port 80
--name my-nginx Give the container a human-readable name
nginx Use the official Nginx web server image

Understanding Port Mapping

Nginx listens on port 80 inside the container. But the container has its own isolated network — that port 80 is not reachable from your machine. The -p 8080:80 flag creates a tunnel: traffic arriving on port 8080 of your host is forwarded to port 80 inside the container.

1
Your Browser → localhost:8080 → Docker → Container:80 → Nginx

Format is always: -p HOST_PORT:CONTAINER_PORT

Running It — What You Will See

1
7c9b3d4a8f2e1b5c6d3a9b8f2e1c4d5a

Just a container ID — your prompt returns immediately. Nginx is running in the background. Open http://localhost:8080 in your browser and you will see the Nginx welcome page.


Chapter 6: Working with Images

6.1 The Concept — Your Local Image Cache

When Docker downloads an image, it stores it in a local cache. Every subsequent container you create from that image uses the cache — no re-downloading. docker images shows you what is in that cache.


6.2 Listing Images

The Command

1
docker images

Running It — What You Will See

1
2
3
4
5
REPOSITORY    TAG         IMAGE ID       CREATED        SIZE
nginx         latest      a72860cb95fd   2 weeks ago    187MB
ubuntu        latest      35a88802559d   3 weeks ago    78.1MB
hello-world   latest      d2c94e258dcb   14 months ago  13.3kB
node          18-alpine   1abef8d6b9e1   5 days ago     127MB

Reading the Columns

Column Meaning
REPOSITORY Image name
TAG Version label
IMAGE ID Short unique identifier (full ID is 64 hex characters)
CREATED When the image was built by its maintainer — not when you pulled it
SIZE Disk space used

Useful Variations

1
2
3
4
5
6
7
8
# Show only image IDs (useful for scripting)
docker images -q

# Filter by name
docker images nginx

# Custom output format
docker images --format "{{.Repository}}:{{.Tag}}  {{.Size}}"

6.3 Pulling an Image

The Concept

docker pull downloads an image into your local cache without creating a container. docker run pulls automatically if the image is not local, but docker pull lets you do it explicitly — useful for pre-loading images or updating to a newer version.

The Command

1
docker pull nginx:1.25

Breaking Down the Command

Part Meaning
docker pull Download image from registry
nginx Image name
:1.25 Specific version tag to pull

Omitting the tag pulls :latest.

Running It — What You Will See

1
2
3
4
5
6
7
1.25: Pulling from library/nginx
578acb154839: Pull complete
e398db710407: Pull complete
85c41ebe6d66: Pull complete
Digest: sha256:593dac25b7733ffb7afe1a726...
Status: Downloaded newer image for nginx:1.25
docker.io/library/nginx:1.25

Each Pull complete line is one image layer. If you later pull nginx:1.26 and it shares some layers with 1.25, those layers are not downloaded again — Docker reuses them.


6.4 Inspecting an Image

The Concept

Before running an image, you can inspect it to understand what it does: which ports it exposes, what command it runs by default, what environment variables it sets.

The Command

1
docker inspect nginx:1.25

Running It — Key Parts of the Output

1
2
3
4
5
6
7
8
9
10
{
  "Config": {
    "ExposedPorts": { "80/tcp": {} },
    "Env": [
      "PATH=/usr/local/sbin:/usr/local/bin:/usr/sbin:/usr/bin:/sbin:/bin",
      "NGINX_VERSION=1.25.3"
    ],
    "Cmd": ["nginx", "-g", "daemon off;"]
  }
}

This tells you: the image exposes port 80, sets an NGINX_VERSION variable, and the default startup command is nginx -g "daemon off;".


6.5 Searching Docker Hub

The Command

1
docker search postgres

Running It — What You Will See

1
2
3
NAME                         DESCRIPTION                            STARS    OFFICIAL
postgres                     The PostgreSQL object-relational …     12847    [OK]
bitnami/postgresql           Bitnami container image for Postgr…    99

[OK] in the OFFICIAL column means Docker or the software vendor maintains this image. For common software, always prefer official images — they are well maintained, regularly patched, and documented.


6.6 Removing Images

The Concept

Images accumulate on disk. Remove ones you no longer need. Docker will refuse to remove an image if any container (even a stopped one) still references it — remove those containers first.

The Command

1
docker rmi nginx:1.25

Breaking Down the Command

Part Meaning
docker rmi Remove image (“rm image”)
nginx:1.25 Image to remove, by name:tag or by image ID

Running It — What You Will See

1
2
3
Untagged: nginx:1.25
Deleted: sha256:a72860cb95fd...
Deleted: sha256:578acb154839...

Each Deleted line is a layer removed from disk. If another image shares a layer, that layer shows as Untagged instead of Deleted — Docker keeps shared layers intact.

If a Container is Still Using It

1
2
Error response from daemon: conflict: unable to remove repository reference "nginx:1.25"
(must force) — container 7c9b3d4a8f2e is using its referenced image a72860cb95fd

Remove the container first, then the image:

1
2
docker rm 7c9b3d4a8f2e
docker rmi nginx:1.25

Bulk Cleanup

1
2
# Remove all images not used by any container
docker image prune -a

Chapter 7: Running Containers — Full Command Reference

7.1 The Concept — What docker run Does Each Time

Every docker run call creates a brand new container from the image. It never reuses a previous container. If you want to bring back a stopped container, use docker start, not docker run.

This chapter covers the most important flags for docker run and the supporting commands for managing containers after they are running.


7.2 Naming Containers with –name

The Concept

Docker assigns each container a random human-readable name like wizardly_curie if you do not specify one. These are fine for experiments but painful to type in commands. Give your containers meaningful names.

The Command

1
docker run -d --name my-nginx nginx

Breaking Down the Flag

Flag Meaning
--name my-nginx Assign the name my-nginx to this container

Names must be unique — if a container with that name already exists (even stopped), Docker errors. Remove the old one first.

Running It

1
2
3
4
5
6
7
8
9
10
docker run -d --name my-nginx nginx

docker ps
# CONTAINER ID   IMAGE   PORTS   NAMES
# 7c9b3d4a8f2e   nginx   80/tcp  my-nginx

# Now use the name in any command
docker stop my-nginx
docker logs my-nginx
docker exec -it my-nginx bash

7.3 Port Mapping with -p

The Concept

A container’s network is isolated. A service running on port 80 inside the container is not reachable from your host machine unless you create an explicit mapping. Think of the container as an office behind a private switchboard — you need to tell the switchboard to route incoming calls on a specific line to that office.

Format: -p HOST_PORT:CONTAINER_PORT

The Command

1
docker run -d -p 8080:80 --name web nginx

Running It

1
2
3
4
5
6
7
docker run -d -p 8080:80 --name web nginx

# Verify the mapping
docker port web
# 80/tcp -> 0.0.0.0:8080

# Open http://localhost:8080 in browser → Nginx welcome page

Common Variations

1
2
3
4
5
6
7
8
9
# Map multiple ports
docker run -d -p 8080:80 -p 8443:443 nginx

# Restrict to localhost only (other machines on your network cannot reach it)
docker run -d -p 127.0.0.1:8080:80 nginx

# Let Docker pick a free host port automatically
docker run -d -p 80 nginx
docker port <container>    # find out which port was assigned

7.4 Detached Mode with -d

The Concept

Without -d, docker run attaches your terminal to the container — you see its output but cannot type other commands. With -d, the container runs in the background and your terminal is immediately free.

Running Without -d (Foreground)

1
docker run nginx

Your terminal shows Nginx’s logs and is blocked. Press Ctrl+C to stop the container and reclaim your terminal.

Running With -d (Detached)

1
2
docker run -d nginx
# 7c9b3d4a8f2e  ← container ID, then prompt returns

The container is running silently in the background. See its output with docker logs.


7.5 Auto-Remove with –rm

The Concept

When you run a one-off command (run a script, check something, test quickly), you usually don’t want the stopped container to linger on disk. --rm tells Docker to delete the container automatically the moment it stops.

The Command

1
docker run --rm ubuntu echo "Hello from inside Ubuntu"

Running It

1
2
3
4
5
docker run --rm ubuntu echo "Hello from inside Ubuntu"
# Hello from inside Ubuntu

docker ps -a
# (no trace of the container — it was removed on exit)

Use --rm any time you run a container just to execute a command and get a result. This keeps your container list clean.


7.6 Listing Containers with docker ps

The Command

1
2
docker ps        # running containers only
docker ps -a     # all containers including stopped

Running It — What You Will See

1
2
CONTAINER ID   IMAGE   COMMAND    CREATED          STATUS          PORTS                  NAMES
7c9b3d4a8f2e   nginx   "nginx…"   3 minutes ago    Up 3 minutes    0.0.0.0:8080->80/tcp   my-nginx

Reading Each Column

Column Meaning
CONTAINER ID Short container identifier
IMAGE Image the container was created from
COMMAND The process running inside
CREATED How long ago it was created
STATUS Up X minutes = running; Exited (0) 5m ago = stopped cleanly
PORTS Port mappings — 0.0.0.0:8080->80/tcp means host:8080 → container:80
NAMES Container name

The exit code in Exited (0) is meaningful: 0 means clean exit, anything else means the process errored. Exited (1) means your app crashed.


7.7 Stopping Containers

The Concept

docker stop sends SIGTERM to the main process, giving it time to shut down gracefully (flush buffers, close DB connections, finish in-flight requests). After 10 seconds Docker sends SIGKILL, which terminates it immediately. Always use docker stop — never docker kill — for databases and apps with in-flight state.

The Command

1
docker stop my-nginx

Running It

1
2
3
4
5
docker stop my-nginx
# my-nginx    ← Docker echoes the name to confirm

docker ps -a
# STATUS: Exited (0) 2 seconds ago

The container still exists — it is just stopped. All data in its writable layer is intact. You can restart it.

docker kill (Immediate Termination)

1
docker kill my-nginx

Sends SIGKILL immediately. The process has zero time to clean up. Only use this when the container is unresponsive and docker stop is not working.


7.8 Starting Stopped Containers

The Concept

docker start restarts a container that already exists in a stopped state. It does not create a new container — it restores the same one, with all the files in its writable layer exactly as they were when it stopped.

The Command

1
docker start my-nginx

Running It

1
2
3
4
docker start my-nginx

docker ps
# STATUS: Up 1 second    PORTS: 0.0.0.0:8080->80/tcp

Visit http://localhost:8080 — Nginx is back.

Restarting (Stop + Start in One Command)

1
docker restart my-nginx

Useful when a service has gotten into a bad state.


7.9 Removing Containers

The Concept

Once you are done with a container and will not need it again, remove it. This frees disk space and keeps docker ps -a readable. A container must be stopped before you can remove it unless you force it.

The Command

1
docker rm my-nginx

Running It

1
2
3
4
5
6
docker stop my-nginx
docker rm my-nginx
# my-nginx

docker ps -a
# (my-nginx is gone)

Force Removing a Running Container

1
docker rm -f my-nginx

Stops and removes in one step. No graceful shutdown — avoid on databases.

Removing All Stopped Containers

1
2
3
4
5
docker container prune

# WARNING! This will remove all stopped containers.
# Are you sure you want to continue? [y/N] y
# Total reclaimed space: 45.2MB

7.10 Viewing Logs

The Concept

When a container runs in detached mode (-d), you do not see its output. docker logs retrieves everything the container has printed to stdout and stderr. This is your first tool when something is not working.

The Command

1
docker logs my-nginx

Running It — What You Will See

1
2
3
4
/docker-entrypoint.sh: Configuration complete; ready for start up
2024/01/15 10:30:00 [notice] 1#1: nginx/1.25.3
2024/01/15 10:30:00 [notice] 1#1: start worker processes
172.17.0.1 - - [15/Jan/2024:10:30:05 +0000] "GET / HTTP/1.1" 200 615

That last line is an access log entry: an HTTP GET to / that returned 200.

Following Logs in Real Time

1
docker logs -f my-nginx

Keeps the connection open and streams new lines as they arrive. Press Ctrl+C to stop following (the container keeps running).

Other Useful Variations

1
2
3
4
5
6
7
8
9
10
11
12
13
14
# Last 20 lines only
docker logs --tail 20 my-nginx

# With timestamps
docker logs -t my-nginx

# Since a specific time
docker logs --since "2024-01-15T10:00:00" my-nginx

# Since 30 minutes ago
docker logs --since 30m my-nginx

# Combine: follow, last 10 lines, with timestamps
docker logs -f --tail 10 -t my-nginx

7.11 Executing Commands Inside a Running Container

The Concept

docker exec runs a command inside an already-running container. This is how you inspect the container’s filesystem, check configuration, run diagnostic tools, or debug a problem — without stopping or restarting the container.

This is different from docker run: run creates a new container; exec runs something inside an existing one.

The Command

1
docker exec -it my-nginx bash

Breaking Down the Command

Part Meaning
docker exec Execute a command in a running container
-i Keep stdin open (so you can type)
-t Allocate a pseudo-terminal (so it looks like a proper shell)
my-nginx The running container to target
bash The command to run inside it

Running It — What You Will See

1
root@7c9b3d4a8f2e:/#

You are now in a shell inside the running Nginx container. The container keeps serving web traffic in the background while you explore.

1
2
3
4
5
6
7
8
9
10
11
# Check the nginx config
cat /etc/nginx/nginx.conf

# See what files are in the web root
ls /usr/share/nginx/html/

# Check running processes
ps aux

# Exit back to your host (container keeps running)
exit

Running a Single Command Without Entering a Shell

1
2
3
4
5
6
7
8
9
# Check the nginx version
docker exec my-nginx nginx -v
# nginx version: nginx/1.25.3

# List files in a directory
docker exec my-nginx ls /etc/nginx/conf.d/

# Check all environment variables set in the container
docker exec my-nginx env

When bash Is Not Available

Alpine-based images do not include bash. Use sh:

1
docker exec -it my-alpine-container sh

7.12 Copying Files

The Concept

docker cp copies files between your host machine and a container’s filesystem. Works on both running and stopped containers.

The Commands

1
2
3
4
5
# Host → Container
docker cp ./my-config.conf my-nginx:/etc/nginx/conf.d/my-config.conf

# Container → Host
docker cp my-nginx:/var/log/nginx/error.log ./nginx-error.log

Running It

1
2
3
4
5
6
7
8
9
# Copy a custom HTML page into Nginx
echo "<h1>Hello from my custom page</h1>" > index.html
docker cp index.html my-nginx:/usr/share/nginx/html/index.html

# Now http://localhost:8080 shows your custom page

# Pull the error log out for inspection
docker cp my-nginx:/var/log/nginx/error.log ./error.log
cat ./error.log

7.13 Inspecting a Container

The Concept

docker inspect produces a detailed JSON document about a container — its full configuration, network settings, mounted volumes, environment variables, and more. When behaviour is unexpected, docker inspect is where you look.

The Command

1
docker inspect my-nginx

Key Sections of the Output

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
{
  "State": {
    "Status": "running",
    "Running": true,
    "Pid": 1234
  },
  "NetworkSettings": {
    "Ports": { "80/tcp": [{"HostPort": "8080"}] },
    "Networks": {
      "bridge": { "IPAddress": "172.17.0.2" }
    }
  },
  "Config": {
    "Env": ["PATH=...", "NGINX_VERSION=1.25.3"],
    "Image": "nginx"
  },
  "Mounts": []
}

Extracting a Single Field

1
2
3
4
5
6
7
# Get the container's internal IP address
docker inspect --format '{{.NetworkSettings.Networks.bridge.IPAddress}}' my-nginx
# 172.17.0.2

# Get the current status
docker inspect --format '{{.State.Status}}' my-nginx
# running

7.14 Monitoring Resource Usage

The Command

1
docker stats

Running It — What You Will See

1
2
CONTAINER ID   NAME       CPU %   MEM USAGE / LIMIT     MEM %   NET I/O         PIDS
7c9b3d4a8f2e   my-nginx   0.00%   5.953MiB / 7.77GiB   0.07%   1.5kB / 3.1kB   9

Updates live every second. Press Ctrl+C to exit.

1
2
3
4
5
# Single snapshot, no live refresh
docker stats --no-stream

# Only specific containers
docker stats my-nginx my-db

7.15 System Cleanup

Check Disk Usage

1
docker system df
1
2
3
4
5
TYPE            TOTAL   ACTIVE   SIZE      RECLAIMABLE
Images          12      3        2.156GB   1.834GB (85%)
Containers      5       2        125.3MB   100.2MB (80%)
Local Volumes   3       1        453.8MB   220.1MB (48%)
Build Cache     45      0        1.2GB     1.2GB

Prune Everything Unused

1
docker system prune

Removes: all stopped containers, all unused networks, all dangling images, all build cache. Does NOT remove named volumes or running containers.

1
2
3
4
5
# Also remove unused volumes (deletes data — be careful)
docker system prune --volumes

# Skip the confirmation prompt
docker system prune -f

Chapter 8: Writing a Dockerfile

8.1 The Concept — What a Dockerfile Is

A Dockerfile is a plain text file named exactly Dockerfile (no extension). It contains a sequence of instructions that tell Docker how to build an image. Each instruction adds a layer to the image.

When you run docker build, Docker reads the Dockerfile top to bottom, executes each instruction, and produces an image you can then run as a container.

Think of a Dockerfile as an automated version of the steps you would take to set up a server by hand. Instead of typing commands on a server one by one, you write them in a file and Docker runs them for you, reproducibly, every time.


8.2 A Minimal Dockerfile

Here is the smallest useful Dockerfile — a Node.js application:

1
2
3
4
5
6
7
FROM node:18-alpine
WORKDIR /app
COPY package*.json ./
RUN npm install
COPY . .
EXPOSE 3000
CMD ["node", "server.js"]

Each line is one instruction. We will go through every instruction in detail. First, let’s understand the structure:

  • Instructions that start with FROM, WORKDIR, COPY, RUN, EXPOSE, CMD are built-in Dockerfile keywords
  • Everything after the keyword is the argument to that instruction
  • Comments start with #
  • The file must start with FROM (with the exception of ARG before FROM, covered later)

8.3 FROM — Choosing Your Base Image

The Concept

Every image is built on top of an existing image called the base image. FROM specifies which base image to use. The base image provides the operating system environment and any pre-installed software your application will run inside.

You almost never start from a completely empty system. Instead you choose a base that already has what you need:

  • If you’re building a Node.js app, start FROM node:18 — it already has Node installed
  • If you’re building a Python app, start FROM python:3.11
  • If you need just a minimal Linux system, start FROM ubuntu:22.04 or FROM alpine:3.18
  • If you need absolutely nothing (uncommon), use FROM scratch

The Instruction

1
FROM node:18-alpine

Breaking It Down

Part Meaning
FROM The instruction keyword
node The image name (from Docker Hub)
:18-alpine Tag — Node.js version 18 on Alpine Linux

Choosing Between Variants

Most official images offer several variants. For Node.js:

Tag Base OS Size Use When
node:18 Debian ~950MB Maximum compatibility, debugging ease
node:18-slim Debian (minimal) ~240MB Good balance for most apps
node:18-alpine Alpine Linux ~130MB Smallest size, production builds

Alpine is very popular for production because of its small size. The trade-off: Alpine uses musl libc instead of glibc, which occasionally causes compatibility issues with native modules. If a native Node module breaks on Alpine, switch to -slim.


8.4 WORKDIR — Setting the Working Directory

The Concept

WORKDIR sets the current working directory for all subsequent instructions (RUN, COPY, CMD, ENTRYPOINT). It also sets the directory you land in when you docker exec -it container bash.

If the directory does not exist, Docker creates it.

Without WORKDIR, you would be working in the root directory (/) of the container. This is messy — your files mix with system files. Always set a WORKDIR.

The Instruction

1
WORKDIR /app

Breaking It Down

Part Meaning
WORKDIR The instruction keyword
/app The directory to create and switch to

What This Affects

1
2
3
4
5
WORKDIR /app

COPY . .         # copies files from build context into /app (the WORKDIR)
RUN npm install  # runs "npm install" from /app
CMD ["node", "server.js"]  # runs "node server.js" from /app

If you docker exec -it my-container bash, you land in /app automatically.


8.5 COPY — Copying Files into the Image

The Concept

COPY copies files and directories from the build context (the directory on your host machine where you run docker build) into the image’s filesystem.

This is how your code gets into the image.

The Instruction

1
2
COPY package*.json ./
COPY . .

Breaking It Down

Part Meaning
COPY The instruction keyword
package*.json Source path(s) — relative to the build context directory on your host
./ Destination path — relative to WORKDIR inside the image

package*.json is a glob pattern matching package.json and package-lock.json.

./ means “the current WORKDIR”, which we set to /app. So files end up in /app/.

Why COPY Is Split Into Two Lines

This is the most important layer caching optimization for Node.js apps:

1
2
3
4
5
6
# Step 1: Copy only dependency files
COPY package*.json ./
# Step 2: Install dependencies
RUN npm install
# Step 3: Copy the rest of the code
COPY . .

Why? Docker caches each layer. If package.json hasn’t changed, Docker reuses the cached npm install layer. You only wait for npm install to run when your dependencies actually change.

If you wrote COPY . . first, then every code change (even one line in server.js) would invalidate the cache and trigger a full npm install. With the split approach, code changes only re-run the COPY . . step — which is fast.

Rule of thumb: Copy things that change least first, things that change most last.

Copying Specific Files vs Entire Directory

1
2
3
4
5
6
7
8
9
10
11
# Copy a single file
COPY server.js /app/server.js

# Copy multiple files using glob
COPY *.json ./

# Copy entire directory
COPY src/ ./src/

# Copy everything in the build context
COPY . .

8.6 The .dockerignore File

The Concept

When you run docker build, Docker sends the entire contents of the build context directory to the Docker Engine. If your project directory contains node_modules (which can be hundreds of megabytes), that gets sent even if you never copy it into the image.

.dockerignore tells Docker which files and directories to exclude from the build context. This makes builds faster and prevents unwanted files from ending up in your image.

Creating a .dockerignore File

Place it in the same directory as your Dockerfile. Example for a Node.js project:

1
2
3
4
5
6
7
8
9
10
node_modules
.git
.gitignore
*.log
.env
.env.*
dist
coverage
.DS_Store
README.md

Items to always exclude:

  • node_modules / vendor / other dependency directories — these get installed fresh by RUN npm install inside the image
  • .git — your git history does not belong in a container image
  • .env files — never put secrets inside an image
  • Build output directories like dist, build — often regenerated inside the image
  • Log files, OS files (.DS_Store), test coverage reports

8.7 RUN — Executing Commands During Build

The Concept

RUN executes a shell command inside the image during the build process. The result becomes a new layer in the image. Use RUN to install software, create directories, set permissions, compile code — anything you would normally type in a terminal to set up an environment.

The Instruction

1
RUN npm install --production

Breaking It Down

Part Meaning
RUN Execute this command during image build
npm install --production The command to run

--production tells npm to skip devDependencies (testing frameworks, linters, etc.) — you don’t want those in a production image.

Combining Multiple Commands

Each RUN instruction is a separate layer. More layers = slightly larger image and slower builds. Combine related commands into a single RUN using &&:

1
2
3
4
5
6
7
8
9
# Bad — creates 3 separate layers
RUN apt-get update
RUN apt-get install -y curl wget
RUN rm -rf /var/lib/apt/lists/*

# Good — creates 1 layer with the same result
RUN apt-get update && \
    apt-get install -y curl wget && \
    rm -rf /var/lib/apt/lists/*

The rm -rf /var/lib/apt/lists/* deletes apt’s cache within the same layer — if it were a separate RUN instruction, the cache would already be baked into the previous layer and you’d get no size benefit.

Shell Form vs Exec Form

1
2
3
4
5
# Shell form — command runs inside /bin/sh -c
RUN npm install

# Exec form — runs directly, no shell
RUN ["npm", "install"]

For RUN, shell form is fine and more readable. The exec form matters more for CMD and ENTRYPOINT.


8.8 ENV — Setting Environment Variables

The Concept

ENV sets environment variables that are available both during the build and inside running containers. Use it for configuration that the application reads at runtime.

The Instruction

1
2
ENV NODE_ENV=production
ENV PORT=3000

Breaking It Down

Part Meaning
ENV Set an environment variable
NODE_ENV=production Variable name and value

Multiple Variables

1
2
3
4
# Each on its own line (cleaner)
ENV NODE_ENV=production
ENV PORT=3000
ENV LOG_LEVEL=info

ENV Defaults Are Overridable at Runtime

Values set in the Dockerfile are defaults. Anyone running the container can override them:

1
docker run -e NODE_ENV=development myapp

Inside the container, NODE_ENV will be development, not production.


8.9 EXPOSE — Documenting the Port

The Concept

EXPOSE documents which port(s) the application inside the container listens on. It is documentation only — it does not publish the port or make it accessible from outside the container. You still need -p in docker run to actually map the port.

Think of EXPOSE as a note in the Dockerfile saying “hey, this app listens on port 3000.” It helps users of your image know which port to map.

The Instruction

1
EXPOSE 3000

What It Does and Does Not Do

1
EXPOSE 3000   # documents that the app uses port 3000
1
2
docker run myapp                        # port 3000 is still not accessible from host
docker run -p 8080:3000 myapp          # NOW port 3000 is accessible via host:8080

Despite only being documentation, EXPOSE is worth including — it shows up in docker inspect and docker ps, and is used by Docker Compose and Kubernetes to infer which port to map.


8.10 CMD — The Default Startup Command

The Concept

CMD specifies the default command to run when a container starts. It is the process that Docker will run as PID 1 (the main process) inside the container. When this process exits, the container stops.

CMD is a default — it can be overridden by passing a command at the end of docker run.

The Instruction

1
CMD ["node", "server.js"]

Breaking It Down — Exec Form vs Shell Form

1
2
3
4
5
# Exec form (recommended) — runs the command directly
CMD ["node", "server.js"]

# Shell form — runs through /bin/sh -c
CMD node server.js

Always prefer exec form for CMD. Here is why:

In shell form, your command runs as a child of /bin/sh, not as PID 1. When Docker sends a stop signal to the container, it sends it to PID 1. If PID 1 is /bin/sh (the shell), your actual application may not receive the signal and will be killed abruptly. With exec form, your application is PID 1 and receives signals directly.

CMD Is Overridable

1
2
3
4
5
6
7
8
# Uses the default CMD ["node", "server.js"]
docker run myapp

# Override CMD — runs bash instead
docker run -it myapp bash

# Override CMD — runs a one-off script
docker run myapp node scripts/migrate.js

8.11 ENTRYPOINT — The Fixed Starting Command

The Concept

ENTRYPOINT is similar to CMD but it is not overridable by default — it defines the executable that always runs. CMD then provides default arguments to that executable.

The distinction:

  • CMD — the entire command is overridable at docker run time
  • ENTRYPOINT — the executable is fixed; only the arguments can change

When to Use ENTRYPOINT

Use ENTRYPOINT when you want your container to behave like a specific command-line tool:

1
2
ENTRYPOINT ["python", "app.py"]
CMD ["--port", "3000"]
1
2
3
docker run myapp                     # runs: python app.py --port 3000
docker run myapp --port 8080         # runs: python app.py --port 8080
docker run myapp --debug             # runs: python app.py --debug

Compare with CMD-only:

1
CMD ["python", "app.py", "--port", "3000"]
1
2
docker run myapp                     # runs: python app.py --port 3000
docker run myapp --port 8080         # runs: --port 8080  (CMD is completely replaced!)

The Practical Combination

For most web applications, CMD alone is sufficient. Use ENTRYPOINT + CMD when your container is meant to be used like a CLI tool where users pass arguments.


8.12 ARG — Build-Time Variables

The Concept

ARG defines variables that can be passed into the Docker build process. Unlike ENV, ARG variables are only available during the build — they do not exist in the running container and do not appear in docker inspect.

Use ARG for build-time configuration: version numbers, build flags, conditional logic.

The Instruction

1
2
3
4
ARG APP_VERSION=1.0.0
ARG NODE_ENV=production

RUN echo "Building version $APP_VERSION"

Passing ARG Values at Build Time

1
docker build --build-arg APP_VERSION=2.0.0 -t myapp:2.0.0 .

If you don’t pass --build-arg, the default value in ARG is used.

ARG vs ENV

  ARG ENV
Available during build Yes Yes
Available in running container No Yes
Visible in docker inspect No Yes
Overridable at docker run No (build-time only) Yes (-e)
Use for Version numbers, build flags App config, connection strings

8.13 VOLUME — Declaring Mount Points

The Concept

VOLUME declares that a specific path inside the container is intended to hold persistent data. When a container is started without an explicit volume mount at that path, Docker automatically creates an anonymous volume there.

This is primarily documentation — like EXPOSE, it tells users where the application stores data that should be persisted. We cover volumes fully in Chapter 10.

The Instruction

1
VOLUME ["/var/lib/postgresql/data"]

This tells Docker (and users of your image) that /var/lib/postgresql/data is where the database stores its files and should be mounted as a volume.


8.14 A Complete, Well-Structured Dockerfile

Here is a production-quality Dockerfile for a Node.js application, incorporating all the concepts above with explanatory comments:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
# ── Base image ─────────────────────────────────────────
# Use the LTS Alpine variant for a small, secure base
FROM node:18-alpine

# ── Working directory ───────────────────────────────────
# All subsequent paths are relative to /app
WORKDIR /app

# ── Dependencies ────────────────────────────────────────
# Copy only package files first so Docker caches this layer.
# npm install only re-runs when package.json changes.
COPY package*.json ./
RUN npm install --production && \
    npm cache clean --force

# ── Application code ────────────────────────────────────
# Copy source after installing deps (changes more frequently).
COPY . .

# ── Runtime configuration ───────────────────────────────
ENV NODE_ENV=production
ENV PORT=3000

# ── Documentation ───────────────────────────────────────
EXPOSE 3000

# ── Startup command ─────────────────────────────────────
# Exec form ensures node receives OS signals (SIGTERM, etc.)
CMD ["node", "server.js"]

8.15 Multi-Stage Builds

The Concept

A multi-stage build uses multiple FROM instructions in one Dockerfile. Each FROM starts a new stage. You can copy files from one stage into another. Only the final stage becomes the image you run.

This solves a common problem: building an application requires tools (compilers, bundlers, test runners) that you do not want in the final image. Multi-stage builds let you use a heavy build environment, then copy only the compiled output into a clean, minimal final image.

Example: Node.js App with TypeScript

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
# ── Stage 1: Build ──────────────────────────────────────
# Give this stage the name "builder" so we can reference it later
FROM node:18-alpine AS builder

WORKDIR /app

# Install all dependencies (including devDependencies like TypeScript)
COPY package*.json ./
RUN npm install

# Copy source and compile TypeScript to JavaScript
COPY tsconfig.json ./
COPY src/ ./src/
RUN npm run build
# This produces compiled JS files in /app/dist/

# ── Stage 2: Production Image ───────────────────────────
# Fresh start — none of the builder's files carry over
FROM node:18-alpine

WORKDIR /app

# Install only production dependencies
COPY package*.json ./
RUN npm install --production

# Copy ONLY the compiled output from the builder stage
COPY --from=builder /app/dist ./dist

ENV NODE_ENV=production
EXPOSE 3000
CMD ["node", "dist/server.js"]

The final image contains: the production Node.js runtime, production npm packages, and the compiled JS in dist/. It does NOT contain: TypeScript, the original .ts source files, devDependencies, the TypeScript compiler.

Example: Go Application

Go produces a single statically-linked binary, making multi-stage builds especially effective:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
# Stage 1: Compile
FROM golang:1.21 AS builder

WORKDIR /app
COPY go.mod go.sum ./
RUN go mod download

COPY . .
RUN CGO_ENABLED=0 go build -o server .

# Stage 2: Run (just the binary, nothing else)
FROM alpine:3.18

COPY --from=builder /app/server /usr/local/bin/server
CMD ["server"]

The Go toolchain image is ~800MB. The final Alpine image with just the binary is ~15MB.


Chapter 9: Building Your Own Images

9.1 The Concept — docker build

docker build reads a Dockerfile and executes each instruction to produce an image. It sends the files in your project directory to the Docker Engine (this is called the build context), then runs the build inside the engine.


9.2 The Basic Build Command

The Command

1
docker build -t myapp:1.0 .

Breaking Down the Command

Part Meaning
docker build Build an image from a Dockerfile
-t myapp:1.0 Tag the resulting image with the name myapp and version 1.0
. The build context — the current directory, sent to the Docker Engine

The . at the end is required and easy to forget. It tells Docker where to find the Dockerfile and what to send as the build context.


9.3 Running a Build — Step by Step

Given this project structure:

1
2
3
4
5
6
myapp/
├── Dockerfile
├── .dockerignore
├── package.json
├── package-lock.json
└── server.js

And this Dockerfile:

1
2
3
4
5
6
7
FROM node:18-alpine
WORKDIR /app
COPY package*.json ./
RUN npm install --production
COPY . .
EXPOSE 3000
CMD ["node", "server.js"]

Run:

1
2
cd myapp
docker build -t myapp:1.0 .

What You Will See

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
[+] Building 45.2s (9/9) FINISHED
 => [internal] load build definition from Dockerfile         0.0s
 => => transferring dockerfile: 185B                         0.0s
 => [internal] load .dockerignore                            0.0s
 => [internal] load metadata for docker.io/library/node:18-alpine   2.1s
 => [1/5] FROM docker.io/library/node:18-alpine             0.0s
 => => resolve docker.io/library/node:18-alpine              0.0s
 => [2/5] WORKDIR /app                                       0.1s
 => [3/5] COPY package*.json ./                              0.1s
 => [4/5] RUN npm install --production                      38.4s
 => [5/5] COPY . .                                           0.1s
 => exporting to image                                       4.4s
 => => exporting layers                                      4.3s
 => => writing image sha256:abc123...                        0.0s
 => => naming to docker.io/library/myapp:1.0                0.0s

Reading the Output

  • [1/5] through [5/5] — each number is a Dockerfile instruction being executed
  • The time on the right shows how long each step took
  • FROM docker.io/library/node:18-alpine — pulling the base image (or using cache)
  • RUN npm install --production took 38.4 seconds — the most expensive step
  • exporting to image — Docker packages all layers into the final image

Now rebuild with no changes:

1
docker build -t myapp:1.0 .
1
2
3
4
5
6
7
8
[+] Building 2.1s (9/9) FINISHED
 => [internal] load build definition from Dockerfile         0.0s
 => [1/5] FROM docker.io/library/node:18-alpine             0.0s
 => CACHED [2/5] WORKDIR /app                               0.0s
 => CACHED [3/5] COPY package*.json ./                      0.0s
 => CACHED [4/5] RUN npm install --production               0.0s
 => CACHED [5/5] COPY . .                                   0.0s
 => exporting to image                                       2.0s

Every step says CACHED — the build took 2 seconds instead of 45. Now change one line in server.js and rebuild:

1
2
 => CACHED [4/5] RUN npm install --production               0.0s  ← still cached
 => [5/5] COPY . .                                           0.1s  ← re-runs (code changed)

Only the COPY . . step re-ran. npm install is still cached because package.json did not change. This is the layer caching system in action.


9.4 Other Build Options

1
2
3
4
5
6
7
8
9
10
11
12
13
14
# Use a specific Dockerfile (not named "Dockerfile")
docker build -f Dockerfile.production -t myapp:prod .

# Build context in a different directory
docker build -t myapp:1.0 ./src

# Pass build arguments
docker build --build-arg APP_VERSION=2.0 -t myapp:2.0 .

# Build without using any cache (forces all steps to re-run)
docker build --no-cache -t myapp:fresh .

# See more detailed output
docker build --progress=plain -t myapp:1.0 .

9.5 Tagging and Versioning Images

The Concept

An image can have multiple tags. You typically tag an image with:

  1. A specific version number (myapp:1.0, myapp:1.0.3)
  2. A floating tag like latest that points to the current version

Tagging at Build Time

1
docker build -t myapp:1.0 .

Adding More Tags After Building

1
2
3
4
5
6
7
8
# Tag as "latest" as well
docker tag myapp:1.0 myapp:latest

# Tag for pushing to Docker Hub
docker tag myapp:1.0 myusername/myapp:1.0

# Tag for a private registry
docker tag myapp:1.0 registry.mycompany.com/myapp:1.0

Pushing to Docker Hub

1
2
3
4
5
6
7
8
9
10
11
# Log in to Docker Hub
docker login
# Username: myusername
# Password: ••••••••
# Login Succeeded

# Push the image
docker push myusername/myapp:1.0

# Push with the latest tag too
docker push myusername/myapp:latest

Chapter 10: Persisting Data with Volumes

10.1 The Concept — Why Containers Lose Data

Every container has a writable layer on top of the image layers. Anything written inside the container goes into this writable layer. When you remove the container with docker rm, that writable layer is deleted permanently.

This means if you run a database in a container and then remove the container, all your data is gone.

Volumes solve this by storing data outside the container’s writable layer — in a location managed by Docker or directly on your host filesystem. The data survives container removal, container restarts, and even Docker daemon restarts.


10.2 Three Types of Storage

Docker has three ways to mount storage into a container:

1
2
3
4
5
6
7
┌─────────────────────────────────────────────────┐
│                   Container                      │
│                                                 │
│  /app/data ──── Named Volume (managed by Docker) │
│  /app/src  ──── Bind Mount (path on host)        │
│  /tmp      ──── tmpfs (in memory)                │
└─────────────────────────────────────────────────┘
Type Data lives Managed by Best for
Named Volume Docker’s storage area Docker Databases, persistent app data
Bind Mount Specific path on host You Development (live code reload)
tmpfs RAM only OS Temporary, sensitive data

10.3 Named Volumes

The Concept

A named volume is a storage area managed entirely by Docker. You give it a name, Docker decides where on disk to store it (usually /var/lib/docker/volumes/ on Linux). The volume persists independently of any container — you can attach it to multiple containers, and deleting a container does not delete the volume.

Named volumes are the recommended approach for any data you care about in production.

Creating and Using a Named Volume

The Command
1
docker run -v pgdata:/var/lib/postgresql/data postgres:15
Breaking Down the Flag
Part Meaning
-v Mount a volume (short for --volume)
pgdata The name of the volume (created automatically if it does not exist)
: Separator
/var/lib/postgresql/data The path inside the container where the volume is mounted
Running It
1
2
3
4
5
6
# Run Postgres with a named volume for data persistence
docker run -d \
  --name my-postgres \
  -e POSTGRES_PASSWORD=mysecretpassword \
  -v pgdata:/var/lib/postgresql/data \
  postgres:15

Now stop and remove the container:

1
2
docker stop my-postgres
docker rm my-postgres

Run a new Postgres container with the same volume:

1
2
3
4
5
docker run -d \
  --name my-postgres-2 \
  -e POSTGRES_PASSWORD=mysecretpassword \
  -v pgdata:/var/lib/postgresql/data \
  postgres:15

All your database data is still there. The volume outlived the container.


10.4 Volume Management Commands

List All Volumes

1
docker volume ls
1
2
3
4
DRIVER    VOLUME NAME
local     pgdata
local     myapp_uploads
local     redis_data

Inspect a Volume

1
docker volume inspect pgdata
1
2
3
4
5
6
7
8
[
    {
        "Name": "pgdata",
        "Driver": "local",
        "Mountpoint": "/var/lib/docker/volumes/pgdata/_data",
        "Scope": "local"
    }
]

The Mountpoint is where Docker actually stores the data on your host machine. On macOS and Windows, this path is inside the Linux VM that Docker runs — you cannot browse there directly in Finder/Explorer, but the data is there.

Create a Volume Explicitly

1
docker volume create mydata

You can also let Docker create volumes automatically (as in the docker run -v example above) — Docker creates the volume if it does not exist.

Remove a Volume

1
docker volume rm pgdata

Docker will refuse to remove a volume that is in use by any container (running or stopped).

Remove All Unused Volumes

1
docker volume prune

Warning: this permanently deletes data. Make sure the volumes you are removing are truly unused and no longer needed.


10.5 Bind Mounts

The Concept

A bind mount maps a specific directory from your host machine into the container. Changes in the host directory immediately appear inside the container, and vice versa. There is no copy involved — both sides see the exact same files.

This is the foundation of live reload in development: run your code editor on the host, mount your source directory into a container running the app, and the container sees every file change instantly. No rebuild required.

The Command

1
docker run -v $(pwd):/app myapp

Breaking Down the Flag

Part Meaning
-v Mount
$(pwd) The current directory on your host (shell command that expands to the full path)
: Separator
/app Where to mount it inside the container

Running a Development Server with Live Reload

1
2
3
4
5
6
7
# In your project directory
docker run -d \
  --name dev-server \
  -p 3000:3000 \
  -v $(pwd):/app \
  node:18-alpine \
  sh -c "cd /app && npm install && npm run dev"

Now edit any file in your project on your host machine. The running container sees the change immediately. Your file editor stays on the host; the app runs in the container.

Read-Only Bind Mounts

Add :ro to make the mount read-only inside the container — the container can read the files but cannot modify them:

1
docker run -v $(pwd)/config:/app/config:ro myapp

Useful for mounting configuration files that should not be accidentally overwritten by the application.


10.6 Sharing Volumes Between Containers

A named volume can be mounted by multiple containers simultaneously. This is how you share files between services:

1
2
3
4
5
6
# Container 1 writes data to the volume
docker run -d --name writer -v shared:/data alpine \
  sh -c "while true; do date >> /data/log.txt; sleep 1; done"

# Container 2 reads from the same volume
docker run -it --rm -v shared:/data alpine tail -f /data/log.txt

Container 2 sees the file being written by Container 1 in real time.


Chapter 11: Container Networking

11.1 The Concept — Why Networking Matters

When you run multiple containers — a web server, an API, a database — they need to communicate. But each container has its own isolated network namespace. They cannot simply reach each other by localhost. Docker provides networking primitives to connect containers in a controlled way.


11.2 How Docker Networking Works

When Docker is installed, it creates a virtual network interface on your host machine called docker0. This acts as a bridge — a virtual switch that containers connect to.

Every container gets its own virtual network interface and an IP address on the Docker bridge network (typically in the 172.17.0.0/16 range). Containers can reach each other via these IPs, but IPs are assigned dynamically and you would have to look them up with docker inspect. This is inconvenient.

Docker’s solution: custom networks with DNS. When you create a custom bridge network, Docker provides a DNS server for that network. Containers on the network can reach each other by container name — Docker resolves the name to the right IP automatically.


11.3 Default Networks

Docker creates three networks automatically on installation:

1
docker network ls
1
2
3
4
NETWORK ID     NAME      DRIVER    SCOPE
abc123def456   bridge    bridge    local
def456ghi789   host      host      local
ghi789jkl012   none      null      local

bridge — the default. All containers you run without specifying a network connect to this. Containers here can reach each other by IP address but NOT by name (no DNS on the default bridge).

host — the container shares the host’s network stack entirely. A process listening on port 80 inside the container is directly accessible on port 80 of the host without any port mapping. Offers the best network performance but no isolation.

none — the container has no network interface at all. Completely isolated. Rarely used — for security-sensitive tasks.


11.4 Creating and Using Custom Networks

The Concept

Create a custom bridge network for your application. Containers on the same custom network can reach each other by name. Containers on different custom networks cannot reach each other (providing isolation between applications).

Creating a Network

The Command
1
docker network create my-app-network
Breaking Down the Command
Part Meaning
docker network create Create a new network
my-app-network The name of the network

By default this creates a bridge network. You can specify the driver with --driver if you need something different.

Running Containers on the Network

1
2
3
4
5
6
7
8
9
10
11
12
13
14
# Start a database on the network
docker run -d \
  --name my-database \
  --network my-app-network \
  -e POSTGRES_PASSWORD=secret \
  postgres:15

# Start an API on the same network
docker run -d \
  --name my-api \
  --network my-app-network \
  -p 3000:3000 \
  -e DB_HOST=my-database \
  myapi:1.0

Inside the my-api container, my-database resolves to the database container’s IP address. The API can connect to the database using my-database as the hostname.

Verifying Connectivity

1
2
3
4
5
6
7
8
9
10
# Get a shell in the API container
docker exec -it my-api sh

# Try to reach the database by name
ping my-database
# PING my-database (172.20.0.2): 56 data bytes
# 64 bytes from 172.20.0.2

# Connect to Postgres
psql -h my-database -U postgres

11.5 Network Management Commands

List Networks

1
docker network ls

Inspect a Network

1
docker network inspect my-app-network

Key things to look for in the output:

1
2
3
4
5
6
7
8
9
10
11
{
  "Name": "my-app-network",
  "Driver": "bridge",
  "IPAM": {
    "Config": [{"Subnet": "172.20.0.0/16"}]
  },
  "Containers": {
    "abc123": {"Name": "my-database", "IPv4Address": "172.20.0.2/16"},
    "def456": {"Name": "my-api",      "IPv4Address": "172.20.0.3/16"}
  }
}

This shows both containers are on the network and their assigned IP addresses.

Connect a Running Container to a Network

1
docker network connect my-app-network existing-container

A container can be on multiple networks simultaneously.

Disconnect a Container from a Network

1
docker network disconnect my-app-network existing-container

Remove a Network

1
docker network rm my-app-network

Docker refuses to remove a network that has active containers connected to it.


11.6 Port Mapping Deep Dive

We introduced port mapping in Chapter 5 and Chapter 7. Here is the complete picture.

The Concept

Port mapping (-p) creates a rule in Docker’s networking layer: incoming traffic on a specific port of the host machine is forwarded to a specific port of the container. Traffic in the other direction (container to host) works automatically without any port mapping.

Format and Variations

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
# Basic: host port 8080 → container port 80
docker run -p 8080:80 nginx

# Bind to a specific host interface (localhost only)
docker run -p 127.0.0.1:8080:80 nginx

# Bind to a specific IP address on the host
docker run -p 192.168.1.100:8080:80 nginx

# Map multiple ports
docker run -p 8080:80 -p 8443:443 nginx

# Let Docker pick a random host port
docker run -p 80 nginx
docker port <container>   # see which host port was assigned

# Map a range of ports
docker run -p 8000-8010:8000-8010 myapp

Checking Port Mappings

1
2
3
4
5
docker port my-nginx
# 80/tcp -> 0.0.0.0:8080

docker ps
# PORTS column: 0.0.0.0:8080->80/tcp

11.7 Container-to-Container Communication Summary

Scenario Can they communicate? How?
Two containers, default bridge Yes By IP address only
Two containers, custom bridge Yes By container name (DNS)
Two containers, different networks No Must connect to same network
Container to host machine Yes Via host’s gateway IP (usually 172.17.0.1)
Host machine to container Only if port is mapped Via localhost:HOST_PORT

Chapter 12: Environment Variables and Configuration

12.1 The Concept — Separating Config from Code

Applications need configuration: database hostnames, API keys, port numbers, feature flags. Hardcoding these into your application code is bad practice — they change between environments (development vs production), and secrets like passwords should never be in source code.

The industry standard approach (from the Twelve-Factor App methodology) is to store configuration in environment variables. Your container reads these variables at startup. Docker provides several ways to set them.


12.2 Setting Environment Variables at Runtime

The Concept

Pass individual environment variables when running a container with the -e flag. These override or add to any variables set in the Dockerfile with ENV.

The Command

1
docker run -e NODE_ENV=production -e PORT=3000 myapp

Breaking Down the Flag

Part Meaning
-e NODE_ENV=production Set the environment variable NODE_ENV to production inside the container

Running It

1
2
3
4
5
6
7
8
docker run -d \
  --name my-api \
  -e NODE_ENV=production \
  -e DB_HOST=my-database \
  -e DB_PORT=5432 \
  -e DB_PASSWORD=supersecret \
  -p 3000:3000 \
  myapi:1.0

Verifying Variables Are Set

1
2
3
4
5
6
7
8
9
10
docker exec my-api env
# PATH=/usr/local/sbin:/usr/local/bin:/usr/sbin:/usr/bin:/sbin:/bin
# NODE_ENV=production
# DB_HOST=my-database
# DB_PORT=5432
# DB_PASSWORD=supersecret

# Or check a specific variable
docker exec my-api printenv DB_HOST
# my-database

12.3 Using a .env File

The Concept

Typing many -e flags is tedious and error-prone. Put all your environment variables in a .env file and pass the whole file with --env-file. Docker reads each line and sets it as an environment variable.

Creating a .env File

1
2
3
4
5
6
7
8
9
# .env
NODE_ENV=production
PORT=3000
DB_HOST=my-database
DB_PORT=5432
DB_NAME=myappdb
DB_USER=appuser
DB_PASSWORD=supersecret
JWT_SECRET=myverysecretjwtsigningkey

The Command

1
docker run --env-file .env myapp

Running It

1
2
3
4
5
docker run -d \
  --name my-api \
  --env-file .env \
  -p 3000:3000 \
  myapi:1.0

The result is identical to passing each -e flag individually, but much cleaner.

IMPORTANT: Never Commit .env Files

Your .env file contains secrets. Add it to .gitignore immediately. Commit a .env.example file instead — it has all the same variable names but with placeholder values. This documents what variables are needed without exposing actual secrets.

1
2
3
4
5
6
7
8
9
# .env.example  (commit this to git)
NODE_ENV=development
PORT=3000
DB_HOST=localhost
DB_PORT=5432
DB_NAME=myappdb
DB_USER=appuser
DB_PASSWORD=CHANGE_ME
JWT_SECRET=CHANGE_ME

12.4 Build-Time vs Runtime Variables

This distinction trips up beginners. There are two times configuration can be injected:

Build time (when creating the image with docker build):

  • Use ARG in the Dockerfile
  • Pass with --build-arg in docker build
  • The value is baked into the image during build
  • NOT available in the running container
  • NOT stored in the image’s metadata

Runtime (when starting the container with docker run):

  • Use ENV in the Dockerfile for defaults
  • Pass with -e or --env-file in docker run
  • The value is available to the running application
  • Stored in the container’s configuration (visible in docker inspect)

Example Showing Both

1
2
3
4
5
6
7
# Build-time: the Node.js version used in the base image
ARG NODE_VERSION=18
FROM node:${NODE_VERSION}-alpine

# Runtime default: can be overridden when starting the container
ENV NODE_ENV=production
ENV PORT=3000
1
2
3
4
5
# Build with a specific Node version
docker build --build-arg NODE_VERSION=20 -t myapp .

# Run with overridden runtime variables
docker run -e NODE_ENV=development -e PORT=8080 myapp

12.5 Secrets — What Not to Do

Never put secrets in your Dockerfile with ENV:

1
2
3
# BAD — anyone who pulls your image can see this
ENV DB_PASSWORD=supersecret
ENV API_KEY=sk-abc123...

The ENV values are baked into the image and visible to anyone with docker inspect or docker history. If you push the image to Docker Hub (even a private repo), the secrets travel with it.

Instead, always pass secrets at runtime:

1
2
3
# Good — not stored in the image
docker run -e DB_PASSWORD=supersecret myapp
docker run --env-file .env myapp

For production environments with proper secret management (Vault, AWS Secrets Manager, Kubernetes Secrets), secrets are injected by the orchestration platform and never stored in files or image layers.


Chapter 13: Q&A — Beginner

Q: I typed docker run ubuntu and it immediately exited. Is something wrong?

A: Nothing is wrong. Ubuntu’s default command is bash. Bash requires a terminal to interact with — when there is no terminal attached, it exits immediately. Use docker run -it ubuntu bash to get an interactive session. The -i flag keeps stdin open and -t allocates a pseudo-terminal, giving bash a proper environment to run in.


Q: What is the difference between an image and a container? I keep confusing them.

A: An image is a static file — it sits on your disk and does nothing. A container is an image that has been started — it is a running process. The analogy that helps most people: an image is like a program installed on your computer (like Photoshop), and a container is a running instance of that program (Photoshop actually open and running). You can have ten windows of Photoshop open from the same installation, just like you can run ten containers from the same image.


Q: Every time I run docker run, do I get a new container?

A: Yes, always. docker run always creates a brand new, fresh container. If you want to restart an existing stopped container, use docker start <name>. If you run docker run again, you get a completely separate container with no memory of the previous one — unless you are using a volume to persist data.


Q: I deleted my container and my data is gone. How do I prevent this?

A: Use a volume. Anything written inside a container’s filesystem is lost when you remove the container. To persist data, mount a volume: docker run -v mydata:/path/inside/container myimage. The volume (mydata) exists independently of the container — deleting the container does not delete the volume. Always use volumes for database data, uploaded files, and anything else you need to keep.


Q: What does -d do and when should I NOT use it?

A: -d runs the container in detached (background) mode — your terminal prompt returns immediately. Use it for long-running services like web servers and databases that you want running in the background. Do NOT use it when: you want to see the output in your terminal in real time, you are debugging a container that might crash on startup (run without -d first to see the error), or you are running a quick one-off command. When debugging, run without -d first, watch the output, then add -d once you are confident it starts correctly.


Q: Port 8080 is already in use when I try to map a container to it. What do I do?

A: Either find what is using port 8080 on your host (lsof -i :8080 on Mac/Linux) and stop it, or choose a different host port. The host port can be anything not already in use: docker run -p 9090:80 nginx maps host port 9090 to container port 80. The container port (after the colon) must match what the application inside the container is actually listening on, but the host port (before the colon) is completely up to you.


Q: What is the difference between docker stop and docker kill?

A: docker stop sends SIGTERM to the container’s main process, giving it time to shut down gracefully — flush buffers, close database connections, finish writing files. It waits up to 10 seconds, then sends SIGKILL. docker kill sends SIGKILL immediately with no grace period. Always use docker stop. Use docker kill only when a container is completely unresponsive and docker stop has timed out.


Q: How do I see what is running inside a container without stopping it?

A: Several ways: docker logs my-container to see what it has printed. docker exec -it my-container bash (or sh on Alpine images) to get an interactive shell inside the running container. docker top my-container to see the list of processes. docker stats my-container to see real-time resource usage.


Q: Can two different containers use the same port inside their container?

A: Yes. Each container has its own isolated network namespace, so each can have its own port 80, port 3000, port 5432 — whatever it likes. The isolation means they do not conflict. The restriction is on the HOST port: you cannot map two different containers to the same host port. docker run -p 8080:80 nginx and docker run -p 8081:80 apache both have containers listening on port 80 internally, but they map to different host ports (8080 and 8081) so there is no conflict.


Q: What does docker system prune remove? Is it safe to run?

A: It removes stopped containers, unused networks, dangling images (untagged build leftovers), and build cache. It does NOT remove running containers, named volumes, or images that are used by any container (running or stopped). It is generally safe to run on a development machine. Before running, check docker ps -a to see if any stopped containers hold data you want to keep, and check docker volume ls — volumes are NOT pruned unless you add --volumes.


Q: My Dockerfile has EXPOSE 3000 but I cannot access the app in my browser. What is wrong?

A: EXPOSE in a Dockerfile is documentation only — it does not actually publish the port. You need to add -p when you run the container: docker run -p 8080:3000 myapp. The -p 8080:3000 maps host port 8080 to container port 3000, making the app accessible at http://localhost:8080. EXPOSE 3000 just signals to users of the image which port the app uses — Docker itself does not act on it without the -p flag.


Chapter 14: Q&A — Intermediate

Q: My container is connecting to the wrong database — it is hitting my host machine’s Postgres instead of the one in the container. Why?

A: Inside a container, localhost refers to the container itself, not to your host machine. If you have Postgres running on your host and you connect to localhost:5432 from inside a container, it will look for Postgres inside the container (and find nothing). To reach your host machine’s Postgres from inside a container, use the special hostname host.docker.internal on Mac and Windows (docker run -e DB_HOST=host.docker.internal myapp). On Linux, use the host’s docker bridge IP, typically 172.17.0.1. Better practice: run your database in a container too, on the same Docker network, and connect by container name.


Q: I changed my code but docker build uses the old cached version. How do I force a rebuild?

A: Docker caches layers. If your COPY . . instruction is near the bottom of the Dockerfile and the files you changed are covered by it, Docker should detect the change and rebuild from that layer forward. If it is still using cache unexpectedly: check that your .dockerignore is not accidentally excluding your changed file, verify you are building from the right directory, and as a last resort use --no-cache to disable caching entirely: docker build --no-cache -t myapp .. Note: --no-cache makes every build slow. Fix the root cause rather than using it habitually.


Q: Container A cannot reach Container B by name. Both are running. What should I check?

A: The most common cause is that both containers are on the default bridge network, which does not support DNS name resolution. Run both on a custom network: docker network create mynet, then add --network mynet to each docker run command. Once on the same custom network, containers reach each other by their --name. Verify: docker inspect <container> → look at the Networks section to confirm they are on the same network. Also confirm the target container’s name exactly — names are case-sensitive.


Q: My image is 1.8GB. How do I reduce its size?

A: Work through these in order. First, switch to an Alpine base: FROM node:18-alpine instead of FROM node:18 saves ~800MB alone. Second, use multi-stage builds — compile your app in a full image, then copy only the built artifacts into a minimal final image. Third, combine RUN commands to avoid extra layers and clean up in the same step (apt-get install && rm -rf /var/lib/apt/lists/*). Fourth, use --no-install-recommends with apt to skip optional packages. Fifth, check your .dockerignore is excluding node_modules, .git, test files, and documentation. Run docker history myimage to see which layer is contributing the most size.


Q: What is a dangling image and why do I have hundreds of them?

A: A dangling image is an image with no name or tag. They are created when you rebuild an image with the same tag — the old layers lose their tag (because the tag moved to the new build) but stay on disk. After enough rebuilds you accumulate many. They are completely safe to remove with docker image prune. This is normal during active development. Many developers run docker image prune as part of their regular cleanup routine. The docker system df command shows how much space they are consuming.


Q: The difference between CMD and ENTRYPOINT confuses me. Can you give a concrete example?

A: Think of ENTRYPOINT as the program and CMD as its default arguments. A container with ENTRYPOINT ["python"] and CMD ["app.py"] runs python app.py by default. Running docker run myimage other.py runs python other.py — the entrypoint stays but you replaced the CMD. Running docker run myimage uses both defaults: python app.py. Now compare with just CMD ["python", "app.py"] and no ENTRYPOINT: docker run myimage runs python app.py, but docker run myimage bash runs bash — the entire CMD is replaced. Use ENTRYPOINT when your container is meant to run as one specific tool. Use CMD alone when the container might be used to run different things.


Q: How do multi-stage builds actually save space?

A: When you have FROM golang:1.21 AS builder followed by FROM alpine:3.18, Docker runs the first stage, then starts a completely fresh second stage. The COPY --from=builder instruction copies specific files from the builder stage into the final image. Only the final FROM becomes the actual image you push and run. The builder stage’s filesystem — which includes the entire Go toolchain, intermediate build files, source code — is thrown away. The final image contains only what you explicitly copied. A Go app compiled this way is typically ~15MB (just the binary in Alpine) instead of ~800MB (binary + entire Go SDK).


Q: What does restart policy mean and which one should I use?

A: The restart policy tells Docker what to do when a container stops. no (default) — never restart. always — restart no matter what, including when Docker daemon restarts (survives a server reboot). on-failure — restart only if the container exits with a non-zero exit code (i.e., it crashed, not a clean shutdown). on-failure:3 — restart on failure but give up after 3 attempts. unless-stopped — like always but does not restart if you explicitly stopped it with docker stop. For production services you want to always be running, use unless-stopped. It survives reboots but respects your intentional stops.


Q: Why does my container show Exited (137) instead of Exited (0)?

A: Exit code 137 means the process was killed by signal 9 (SIGKILL). This happens when: Docker killed the container because it did not respond to SIGTERM within the grace period (the container’s shutdown handler is too slow or stuck), or the container exceeded its memory limit and the kernel OOM-killed it, or someone ran docker kill. Exit code 0 means clean, intentional exit. Exit codes 1–125 are application-level errors. Exit code 126 means the command could not be executed. Exit code 127 means the command was not found. Exit codes 128+ mean the process was killed by a signal (128 + signal number — SIGKILL is 9, so 128+9=137).


Q: Can I run Docker inside Docker (for CI/CD pipelines)?

A: Yes, this is called Docker-in-Docker or DinD. It has two approaches: true Docker-in-Docker where a full Docker daemon runs inside a container (possible but complex and has security implications), and Docker socket mounting where you mount the host’s Docker socket into your CI container (-v /var/run/docker.sock:/var/run/docker.sock). The socket approach is simpler and is what most CI systems use — the container talks to the host’s Docker daemon, so containers it creates are siblings on the host, not children. Be aware this gives the CI container full Docker access to the host, which is a significant security consideration.


Chapter 15: Exams

Exam P1 — Fundamental Concepts (Multiple Choice)

Instructions: Choose the best answer for each question.


1. What is the primary difference between a Docker image and a Docker container?

a) An image is larger than a container
b) An image is a static blueprint; a container is a running instance of that image
c) A container can only run one image at a time
d) Images are only stored on Docker Hub; containers are local

Answer: b
An image is an immutable, static file on disk. A container is created when you start an image — it is the running process.


2. You run docker run ubuntu and it exits immediately. What is the most likely reason?

a) Ubuntu is not supported by Docker
b) You do not have permissions to run Ubuntu
c) Ubuntu’s default command (bash) exited because there was no interactive terminal
d) Docker ran out of memory

Answer: c
Bash exits when it has no terminal to interact with. Use docker run -it ubuntu bash for an interactive session.


3. What does docker ps show by default?

a) All images downloaded on your machine
b) All containers that have ever been created
c) Only currently running containers
d) Containers that exited in the last 24 hours

Answer: c
docker ps shows running containers only. docker ps -a shows all containers including stopped ones.


4. Which command removes a container permanently?

a) docker stop my-container
b) docker pause my-container
c) docker kill my-container
d) docker rm my-container

Answer: d
docker rm removes the container. docker stop and docker kill stop it but leave it intact. docker pause suspends it.


5. What does -p 9090:80 mean in docker run -p 9090:80 nginx?

a) Nginx will listen on port 9090 inside the container
b) Traffic to host port 9090 is forwarded to port 80 inside the container
c) Traffic to host port 80 is forwarded to port 9090 inside the container
d) Both port 9090 and port 80 are exposed on the host

Answer: b
Format is HOST_PORT:CONTAINER_PORT. Port 9090 on your machine forwards to port 80 inside the container where Nginx listens.


6. What does EXPOSE 3000 in a Dockerfile actually do?

a) Publishes port 3000 so the app is immediately accessible from the host
b) Configures the application to listen on port 3000
c) Documents that the container’s application uses port 3000; does not publish it
d) Blocks all other ports except 3000

Answer: c
EXPOSE is documentation only. You still need -p 3000:3000 in docker run to make the port accessible.


7. You want your container to be deleted automatically when it exits. Which flag do you add to docker run?

a) --delete
b) --rm
c) --cleanup
d) --auto-remove

Answer: b
--rm tells Docker to remove the container automatically when its main process exits.


8. Which Dockerfile instruction sets the working directory for subsequent instructions?

a) DIR
b) CD
c) WORKDIR
d) SET_PATH

Answer: c
WORKDIR /app sets the current directory to /app for all following instructions and for interactive sessions.


9. What is a named volume in Docker?

a) A container with a memorable name
b) A storage area managed by Docker that persists data independently of containers
c) A network with a name
d) An image with a specific tag

Answer: b
A named volume persists independently of any container. Deleting the container that created it does not delete the volume.


10. How do you view the output (logs) of a container running in detached mode?

a) docker output my-container
b) docker print my-container
c) docker logs my-container
d) Detached containers do not produce output

Answer: c
docker logs my-container retrieves stdout and stderr. Add -f to follow in real time.


Exam P2 — Beginner Practical (Write the Commands)

Instructions: Write the exact Docker command(s) to accomplish each task.


1. Pull the python:3.11-slim image from Docker Hub.

1
docker pull python:3.11-slim

2. Run an Nginx container named web-server in detached mode, mapping host port 8080 to container port 80.

1
docker run -d --name web-server -p 8080:80 nginx

3. List all containers on your machine, including stopped ones.

1
docker ps -a

4. Get an interactive bash shell inside a running container named my-app.

1
docker exec -it my-app bash

5. Stop the container named web-server, then remove it.

1
2
docker stop web-server
docker rm web-server

Or combined:

1
docker rm -f web-server

6. View the last 30 lines of logs from a container named api-server, with timestamps.

1
docker logs --tail 30 -t api-server

7. Write a Dockerfile for a Python application that:

  • Uses python:3.11-slim as the base image
  • Sets /app as the working directory
  • Copies requirements.txt and runs pip install
  • Copies the rest of the code
  • Runs python main.py on startup
1
2
3
4
5
6
FROM python:3.11-slim
WORKDIR /app
COPY requirements.txt .
RUN pip install --no-cache-dir -r requirements.txt
COPY . .
CMD ["python", "main.py"]

8. Build the image from that Dockerfile and tag it myapi:1.0.

1
docker build -t myapi:1.0 .

9. Run myapi:1.0 with the environment variable APP_ENV set to production, on port 5000.

1
docker run -d --name myapi -e APP_ENV=production -p 5000:5000 myapi:1.0

10. Run a Postgres container named my-db with:

  • Password: securepass
  • Data stored in a named volume called pgdata
  • Container port 5432 mapped to host port 5432
1
2
3
4
5
6
docker run -d \
  --name my-db \
  -e POSTGRES_PASSWORD=securepass \
  -v pgdata:/var/lib/postgresql/data \
  -p 5432:5432 \
  postgres:15

Exam P3 — Intermediate Concepts (Multiple Choice)


1. In a Node.js Dockerfile, which instruction order gives the best layer caching for repeated builds?

a) COPY . .RUN npm install
b) RUN npm installCOPY . .
c) COPY package*.json ./RUN npm installCOPY . .
d) Order of instructions does not affect caching

Answer: c
Copying only package.json first and installing dependencies before copying the rest of the code ensures npm install is only re-run when package.json changes, not when application code changes.


2. What is the key benefit of a multi-stage Docker build?

a) It allows the container to run on multiple operating systems
b) It enables parallel processing within the container
c) It produces a smaller final image by excluding build tools from the production image
d) It makes the build run faster by caching more aggressively

Answer: c
Multi-stage builds allow you to use heavy build tools (compilers, bundlers) in an early stage, then copy only the compiled output into a minimal final image. The build tools never end up in the image you actually run.


3. Container A and Container B are both running. Container A tries to connect to Container B using its container name as the hostname, but gets a “name not found” error. What is the most likely cause?

a) Container B is not running an HTTP server
b) Both containers are on the default bridge network, which does not provide DNS resolution
c) Container A needs to EXPOSE the port it is trying to connect from
d) The containers are using different versions of Docker

Answer: b
The default bridge network does not have DNS. Only custom bridge networks allow containers to reach each other by name. Solution: docker network create mynet and add --network mynet to both docker run commands.


4. What is the difference between ENV and ARG in a Dockerfile?

a) ENV is for strings; ARG is for numbers
b) ARG values are available during build and at runtime; ENV is only at build time
c) ENV values persist into the running container; ARG values are only available during docker build
d) They are identical — either can be used in any situation

Answer: c
ENV sets variables available at build time AND in the running container. ARG sets variables only available during docker build — they do not exist when the container runs.


5. Which restart policy restarts a container if it crashes but does NOT restart it after a manual docker stop?

a) always
b) on-failure
c) unless-stopped
d) on-crash

Answer: c
unless-stopped restarts on crash and on Docker daemon restart (e.g., after reboot), but respects a manual docker stop. always would restart even after docker stop.


Exam P4 — Intermediate Practical


1. Write a multi-stage Dockerfile for a Go application that:

  • Compiles in golang:1.21
  • Runs the binary in alpine:3.18
  • The binary is built with go build -o server .
1
2
3
4
5
6
7
8
9
10
FROM golang:1.21 AS builder
WORKDIR /app
COPY go.mod go.sum ./
RUN go mod download
COPY . .
RUN CGO_ENABLED=0 go build -o server .

FROM alpine:3.18
COPY --from=builder /app/server /usr/local/bin/server
CMD ["server"]

2. Create a custom Docker network called app-net and run two containers on it:

  • db from postgres:15 with password dbpass
  • api from myapi:1.0 with env var DB_HOST=db
1
2
3
4
5
6
7
8
9
10
11
12
13
14
docker network create app-net

docker run -d \
  --name db \
  --network app-net \
  -e POSTGRES_PASSWORD=dbpass \
  postgres:15

docker run -d \
  --name api \
  --network app-net \
  -e DB_HOST=db \
  -p 3000:3000 \
  myapi:1.0

3. Your container is crashing on startup. Describe three specific steps you would take to debug it.

Step 1: Run the container in foreground mode (without -d) to see the error directly:

1
2
docker run myapp
# Error output appears in the terminal

Step 2: Check logs of a crashed container:

1
2
docker logs <container-id>
# Even stopped containers retain logs

Step 3: Override the entrypoint to get a shell and investigate manually:

1
2
3
4
docker run -it --entrypoint sh myapp
# Now you're inside the image — check if files are where they should be
ls /app
env

4. You have an image myapp:3.0 that you want to push to Docker Hub under your username rahul. Write the commands to tag it correctly and push it.

1
2
3
4
5
6
docker tag myapp:3.0 rahul/myapp:3.0
docker tag myapp:3.0 rahul/myapp:latest

docker login
docker push rahul/myapp:3.0
docker push rahul/myapp:latest

5. Write a .dockerignore file for a Node.js project.

1
2
3
4
5
6
7
8
9
10
11
12
node_modules
.git
.gitignore
.env
.env.*
*.log
dist
build
coverage
.DS_Store
npm-debug.log*
.nyc_output

Exam P5 — Scenario: Fix the Dockerfile

Scenario: A junior developer on your team wrote this Dockerfile. It works, but has multiple significant problems. Your job is to identify all the issues and rewrite it correctly.

1
2
3
4
5
6
7
8
9
10
11
12
13
FROM ubuntu:22.04

RUN apt-get update
RUN apt-get install -y nodejs npm

COPY . /app

RUN cd /app && npm install

ENV SECRET_KEY=abc123verysecret
ENV NODE_ENV production

CMD node /app/server.js

Part A: Identify all the problems (aim for at least 5).

  1. Wrong base image. ubuntu:22.04 is ~78MB and requires installing Node.js manually. node:18-alpine (~130MB but self-contained) or node:18-slim is a far better choice — smaller, maintained, and includes Node without the apt install.

  2. Two separate RUN commands for apt. RUN apt-get update and RUN apt-get install -y nodejs npm are separate layers. The apt cache baked into the update layer is stale by the time the install layer references it. They should be combined AND include rm -rf /var/lib/apt/lists/* to clear the cache.

  3. No WORKDIR. Working inside the container root or using absolute paths everywhere is error-prone. Set WORKDIR /app.

  4. Wrong layer cache order. COPY . /app copies all code before npm install. Every code change (even one line in server.js) invalidates the npm install cache. The package files should be copied and npm installed before copying the rest of the code.

  5. Secret in ENV. ENV SECRET_KEY=abc123verysecret bakes a secret into the image. Anyone who pulls the image can read it with docker inspect or docker history. Secrets must be passed at runtime, not hardcoded in the Dockerfile.

  6. CMD in shell form. CMD node /app/server.js runs through /bin/sh -c. Node.js will not receive OS signals (SIGTERM) properly, so docker stop will not trigger a graceful shutdown. Use exec form: CMD ["node", "/app/server.js"] or (with WORKDIR) CMD ["node", "server.js"].

  7. No .dockerignore. Without it, COPY . /app will include node_modules (potentially hundreds of MB), .git, log files, and any .env files on the host.


Part B: Rewrite the Dockerfile correctly.

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
FROM node:18-alpine

WORKDIR /app

# Install dependencies first for better layer caching.
# npm install only re-runs when package.json changes.
COPY package*.json ./
RUN npm install --production && npm cache clean --force

# Copy application code after installing dependencies.
COPY . .

# Runtime defaults — override with -e at docker run time.
# Do NOT hardcode secrets here.
ENV NODE_ENV=production

EXPOSE 3000

# Exec form ensures node receives SIGTERM for graceful shutdown.
CMD ["node", "server.js"]

And the .dockerignore:

1
2
3
4
5
node_modules
.git
.env
.env.*
*.log

Chapter 16: Capstone Project

Project: Containerize a Real Application End to End

This project takes you through the complete workflow: writing a Dockerfile, building an image, running a multi-container application with networking and persistent storage, and debugging along the way.


16.1 What You Will Build

A two-tier web application:

  • PostgreSQL database — stores data in a named volume
  • Node.js API — connects to the database, exposes REST endpoints
  • Both services run on a shared Docker network so the API can reach the database by name

16.2 Project Directory Structure

1
2
3
4
5
6
7
myapp/
├── Dockerfile
├── .dockerignore
├── package.json
├── package-lock.json
├── server.js
└── .env.example

16.3 Step 1 — Write the Application

Create server.js:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
const express = require('express');
const { Pool } = require('pg');

const app = express();
app.use(express.json());

const pool = new Pool({
  host: process.env.DB_HOST || 'localhost',
  port: process.env.DB_PORT || 5432,
  database: process.env.DB_NAME || 'myapp',
  user: process.env.DB_USER || 'postgres',
  password: process.env.DB_PASSWORD,
});

app.get('/health', (req, res) => {
  res.json({ status: 'ok' });
});

app.get('/users', async (req, res) => {
  const result = await pool.query('SELECT * FROM users ORDER BY id');
  res.json(result.rows);
});

app.post('/users', async (req, res) => {
  const { name, email } = req.body;
  const result = await pool.query(
    'INSERT INTO users (name, email) VALUES ($1, $2) RETURNING *',
    [name, email]
  );
  res.status(201).json(result.rows[0]);
});

const PORT = process.env.PORT || 3000;
app.listen(PORT, () => console.log(`API running on port ${PORT}`));

Create package.json:

1
2
3
4
5
6
7
8
{
  "name": "myapp",
  "version": "1.0.0",
  "dependencies": {
    "express": "^4.18.2",
    "pg": "^8.11.0"
  }
}

16.4 Step 2 — Write the Dockerfile

1
2
3
4
5
6
7
8
9
10
11
12
13
14
FROM node:18-alpine

WORKDIR /app

COPY package*.json ./
RUN npm install --production

COPY . .

ENV PORT=3000

EXPOSE 3000

CMD ["node", "server.js"]

16.5 Step 3 — Write the .dockerignore

1
2
3
4
node_modules
.git
.env
*.log

16.6 Step 4 — Create the .env File

1
2
3
4
5
6
# .env  (do not commit this — keep it local)
DB_HOST=my-database
DB_PORT=5432
DB_NAME=myapp
DB_USER=postgres
DB_PASSWORD=securepassword123

16.7 Step 5 — Build the API Image

1
docker build -t myapp-api:1.0 .

Watch the output. Verify each step completes. After the build:

1
2
docker images | grep myapp-api
# myapp-api   1.0   abc123def456   just now   127MB

16.8 Step 6 — Create the Network

1
docker network create myapp-net

16.9 Step 7 — Run the Database

1
2
3
4
5
6
7
docker run -d \
  --name my-database \
  --network myapp-net \
  -e POSTGRES_PASSWORD=securepassword123 \
  -e POSTGRES_DB=myapp \
  -v myapp-pgdata:/var/lib/postgresql/data \
  postgres:15

Wait ~5 seconds for Postgres to initialize, then verify:

1
2
docker logs my-database
# Should end with: database system is ready to accept connections

16.10 Step 8 — Create the Database Table

1
2
docker exec -it my-database psql -U postgres -d myapp -c \
  "CREATE TABLE IF NOT EXISTS users (id SERIAL PRIMARY KEY, name TEXT, email TEXT);"

16.11 Step 9 — Run the API

1
2
3
4
5
6
docker run -d \
  --name my-api \
  --network myapp-net \
  --env-file .env \
  -p 3000:3000 \
  myapp-api:1.0

Verify it is running:

1
2
3
4
5
docker ps
# Both my-database and my-api should show as Up

docker logs my-api
# API running on port 3000

16.12 Step 10 — Test the Application

1
2
3
4
5
6
7
8
9
10
11
12
13
# Health check
curl http://localhost:3000/health
# {"status":"ok"}

# Create a user
curl -X POST http://localhost:3000/users \
  -H "Content-Type: application/json" \
  -d '{"name": "Alice", "email": "[email protected]"}'
# {"id":1,"name":"Alice","email":"[email protected]"}

# List users
curl http://localhost:3000/users
# [{"id":1,"name":"Alice","email":"[email protected]"}]

16.13 Step 11 — Verify Data Persistence

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
# Remove the database container
docker stop my-database
docker rm my-database

# Run a new database container with the SAME named volume
docker run -d \
  --name my-database \
  --network myapp-net \
  -e POSTGRES_PASSWORD=securepassword123 \
  -e POSTGRES_DB=myapp \
  -v myapp-pgdata:/var/lib/postgresql/data \
  postgres:15

# Restart the API (to reconnect)
docker restart my-api

# Wait a few seconds, then check your data
curl http://localhost:3000/users
# [{"id":1,"name":"Alice","email":"[email protected]"}]

Alice is still there. The data survived container deletion because it was in the named volume myapp-pgdata, not in the container’s writable layer.


16.14 Step 12 — Cleanup

1
2
3
4
5
6
7
8
9
10
11
12
# Stop and remove containers
docker stop my-api my-database
docker rm my-api my-database

# Remove the network
docker network rm myapp-net

# Remove the image
docker rmi myapp-api:1.0

# Remove the volume (WARNING: deletes all database data)
docker volume rm myapp-pgdata

16.15 Stretch Goals

Work through these after completing the main project:

1. Add a health check to the Dockerfile

1
2
HEALTHCHECK --interval=30s --timeout=10s --retries=3 \
  CMD wget -qO- http://localhost:3000/health || exit 1

After adding this, rebuild and run. Then check: docker ps will show (healthy) or (unhealthy) in the STATUS column.

2. Use a restart policy

Add --restart unless-stopped to both docker run commands. Then stop and restart Docker Desktop or Colima. Both containers should automatically come back up.

3. Run the API without -d first to watch it start

1
2
3
4
5
6
docker run \
  --name my-api-debug \
  --network myapp-net \
  --env-file .env \
  -p 3001:3000 \
  myapp-api:1.0

Watch the startup logs in real time. Press Ctrl+C to stop.

4. Examine what is in the volume

1
2
3
4
5
6
7
# Run a temporary Alpine container with the volume mounted
docker run --rm -it -v myapp-pgdata:/data alpine sh

# Inside the container:
ls /data/
# PG_VERSION  base  global  pg_hba.conf  pg_ident.conf  ...
# These are the raw Postgres data files

5. Build with a build argument for the Node version

Modify the Dockerfile:

1
2
ARG NODE_VERSION=18
FROM node:${NODE_VERSION}-alpine

Build with different Node versions:

1
2
docker build --build-arg NODE_VERSION=20 -t myapp-api:node20 .
docker build --build-arg NODE_VERSION=18 -t myapp-api:node18 .

End of Part 1 — Docker Fundamentals
Part 2 covers Docker Compose for orchestrating multi-container applications.

This post is licensed under CC BY 4.0 by the author.