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.
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
- What is Containerization?
- The Docker Ecosystem
- Installing Docker
- Core Concepts: Images and Containers
- Your First Container
- Working with Images
- Running Containers
- Writing a Dockerfile
- Building Your Own Images
- Persisting Data with Volumes
- Container Networking
- Environment Variables and Configuration
- Q&A — Beginner
- Q&A — Intermediate
- Exams: P1 through P5
- 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:
- The Docker CLI sends an API request to the Docker Engine
- The Docker Engine checks if the nginx image exists locally
- If not, it downloads the image from Docker Hub
- It creates a container from the image
- It starts the container
- 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 serverpostgres— PostgreSQL databasenode— Node.js runtimepython— Python runtimeubuntu— Ubuntu Linux base imageredis— 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 startto 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:
- The Docker CLI receives your command
- It asks the Docker Engine: “Do you have an image called
hello-worldlocally?” - The Engine says no — so it reaches out to Docker Hub and downloads it
- The Engine creates a new container from that image
- The Engine starts the container — the process defined inside the image runs
- That process prints a message and exits
- 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-worldmeans 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,CMDare 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 ofARGbeforeFROM, 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.04orFROM 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 byRUN npm installinside the image.git— your git history does not belong in a container image.envfiles — 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 atdocker runtimeENTRYPOINT— 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 --productiontook 38.4 seconds — the most expensive stepexporting 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:
- A specific version number (
myapp:1.0,myapp:1.0.3) - A floating tag like
latestthat 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
ARGin the Dockerfile - Pass with
--build-argindocker 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
ENVin the Dockerfile for defaults - Pass with
-eor--env-fileindocker 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-slimas the base image - Sets
/appas the working directory - Copies
requirements.txtand runspip install - Copies the rest of the code
- Runs
python main.pyon 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 install → COPY . .
c) COPY package*.json ./ → RUN npm install → COPY . .
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:
dbfrompostgres:15with passworddbpassapifrommyapi:1.0with env varDB_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).
-
Wrong base image.
ubuntu:22.04is ~78MB and requires installing Node.js manually.node:18-alpine(~130MB but self-contained) ornode:18-slimis a far better choice — smaller, maintained, and includes Node without the apt install. -
Two separate RUN commands for apt.
RUN apt-get updateandRUN apt-get install -y nodejs npmare 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 includerm -rf /var/lib/apt/lists/*to clear the cache. -
No WORKDIR. Working inside the container root or using absolute paths everywhere is error-prone. Set
WORKDIR /app. -
Wrong layer cache order.
COPY . /appcopies all code beforenpm 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. -
Secret in ENV.
ENV SECRET_KEY=abc123verysecretbakes a secret into the image. Anyone who pulls the image can read it withdocker inspectordocker history. Secrets must be passed at runtime, not hardcoded in the Dockerfile. -
CMD in shell form.
CMD node /app/server.jsruns through/bin/sh -c. Node.js will not receive OS signals (SIGTERM) properly, sodocker stopwill not trigger a graceful shutdown. Use exec form:CMD ["node", "/app/server.js"]or (with WORKDIR)CMD ["node", "server.js"]. -
No .dockerignore. Without it,
COPY . /appwill includenode_modules(potentially hundreds of MB),.git, log files, and any.envfiles 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.