Chapter 6.1: Containers (Docker)

Part 1: The "Why" - VMs vs. Containers

Before Docker, if you wanted to run a web application, a database, and a caching server, you had two bad options:

  1. Run all three on one physical server.
    • Problem: "The Matrix from Hell." What if the web app needs Python 3.8 but the caching server needs Python 3.10? What if they both try to use Port 80? You have constant conflicts.
  2. Buy three separate physical servers.
    • Problem: Extremely expensive and wasteful. Your web server might use 10% of its CPU, and your database 5%. You are wasting 90% of your resources.

The First Solution: Virtual Machines (VMs)

VMs were the first good solution. A **Hypervisor** (like VMware or VirtualBox) lets you run multiple, complete, *isolated* **Guest Operating Systems** (like 3 separate Ubuntu servers) on a single **Host Operating System** (your physical machine).

)]
  • Pros: Full isolation. No conflicts. Secure.
  • Cons: **Extremely heavy.** Each VM is a *full* OS, which can be 5-10 GB. They are slow to boot (minutes) and waste a *ton* of resources just running 3 identical copies of the Ubuntu kernel.

The Modern Solution: Containers (Docker)

In 2013, Docker changed everything. A **Container** is a lightweight, standalone, executable package that includes everything needed to run a piece of software: the code, its libraries, and its settings.

The magic is that containers **virtualize the OS** itself. All containers on a server *share* the same Host OS kernel. They don't need to boot a "Guest OS." They are just *isolated processes*.

)]
  • Pros:
    • Lightweight: A container is tiny (megabytes, not gigabytes).
    • Fast: A container can boot in *milliseconds*, not minutes.
    • Portable:** A Docker container built on your Windows laptop will run *exactly the same way* on a Linux server in AWS. This is the core principle: **"Build once, run anywhere."**
  • Cons: Less isolation than a VM (all containers share the same kernel).

For a DevOps engineer, Docker solves the #1 problem: **"It works on my machine!"** With Docker, if it works on your machine, it works *everywhere*.

Read More about What a Container Is →

Part 2: Installing Docker

You need to install the Docker "engine" to build and run containers.

Option 1: Docker Desktop (Windows & macOS)

This is the all-in-one, easy-to-use application for your development machine. It includes the Docker Engine, the Docker CLI (command-line tool), and a helpful GUI (Graphical User Interface).

  1. Go to the Docker Desktop website.
  2. Download the installer for your OS (Windows or Mac).
  3. Run the installer. (On Windows, it may require you to enable **WSL 2 (Windows Subsystem for Linux)**, which it can often do for you. This is what allows Windows to run Linux containers.)
  4. After installation, open your terminal (PowerShell or Terminal) and run:
$ docker --version
Docker version 25.0.3, build 4debf41

Option 2: Docker Engine (Linux Server)

On a production Linux server, you don't install Docker Desktop. You install the lightweight **Docker Engine**.

# On Ubuntu/Debian:

# 1. Update apt and install prerequisites
$ sudo apt-get update
$ sudo apt-get install -y ca-certificates curl gnupg

# 2. Add Docker's official GPG key
$ 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

# 3. Set up the Docker repository
$ echo \
  "deb [arch=$(dpkg --print-architecture) signed-by=/etc/apt/keyrings/docker.gpg] https://download.docker.com/linux/ubuntu \
  $(lsb_release -cs) stable" | sudo tee /etc/apt/sources.list.d/docker.list > /dev/null
  
# 4. Install Docker Engine
$ sudo apt-get update
$ sudo apt-get install -y docker-ce docker-ce-cli containerd.io docker-buildx-plugin docker-compose-plugin

# 5. (CRITICAL) Add your user to the 'docker' group
# This lets you run 'docker' commands without 'sudo'
$ sudo usermod -aG docker $USER

# 6. Log out and log back in for the group changes to apply.
# Then verify it works:
$ docker ps

Part 3: The 3 Core Docker Concepts

This is the holy trinity of Docker. You *must* understand the difference between these three.

1. The `Dockerfile` (The "Blueprint")

A `Dockerfile` is a plain text file with **instructions** on *how to build* a Docker Image. It's the "recipe." You write commands like "Start from Ubuntu," "Install Python," "Copy my app code," and "Run my app."

# This is a Dockerfile
FROM python:3.10-slim
WORKDIR /app
COPY requirements.txt .
RUN pip install -r requirements.txt
COPY . .
CMD ["python", "app.py"]

2. The Image (The "Snapshot" / The ".exe")

A Docker **Image** is what you get when you **build** your `Dockerfile`. It's a read-only, compressed snapshot that contains your application, its libraries, and the OS filesystem (e.g., the Ubuntu filesystem). You store this image on a **Registry** (like Docker Hub or GHCR).

# This command reads the Dockerfile and builds an Image
$ docker build -t my-python-app .

3. The Container (The "Running Instance")

A **Container** is what you get when you **run** your Image. It's a live, running process. You can start, stop, and delete containers. You can run *multiple* containers from the *same* image (e.g., run 10 copies of your `my-python-app` image, all isolated from each other).

# This command runs the 'my-python-app' image as a new container
$ docker run my-python-app

Analogy:** The `Dockerfile` is the architectural blueprint. The `Image` is the finished, shrink-wrapped house in a box. The `Container` is the actual house you've placed on a street, with electricity and water turned on, and people living inside.

Part 4: Deep Dive - Writing a `Dockerfile`

Let's build a container for a simple Node.js web app. Here are the most important instructions you'll use.

Dockerfile
# --- Stage 1: The 'Build' Stage ---
# Use an official Node.js image as the "base"
FROM node:18-alpine AS builder

# Set the working directory *inside* the image
WORKDIR /usr/src/app

# Copy the package.json and package-lock.json first
# This takes advantage of Docker's layer caching
COPY package*.json ./

# Run the install command
RUN npm install

# Copy the rest of the application code
COPY . .

# Run the 'build' script (e.g., for a React app)
RUN npm run build

# --- Stage 2: The 'Production' Stage ---
# Start fresh from a lightweight Nginx server image
FROM nginx:1.25-alpine

# Copy *only* the built files from the 'builder' stage
COPY --from=builder /usr/src/app/build /usr/share/nginx/html

# Tell Docker the container listens on port 80
EXPOSE 80

# The command to run when the container starts
CMD ["nginx", "-g", "daemon off;"]

Instruction Deep Dive:

  • FROM node:18-alpine:** This is the most important. It says, "Start with an OS that *already* has Node.js 18 and Alpine Linux installed." This is your "base image."
  • WORKDIR /usr/src/app:** Sets the *default directory* for all future commands (COPY, RUN) inside the container.
  • COPY package*.json ./:** Copies files from your *local machine* (the "build context") into the *container's* working directory (/usr/src/app).
  • RUN npm install:** **Runs a shell command** inside the container *at build time*. This creates a new "layer" in the image.
  • Multi-Stage Build (AS builder, --from=builder): This is an advanced, critical optimization. We use a big Node.js image (the "builder") to install npm and build our app. Then, we start fresh with a *tiny* Nginx image and *only* copy the final build folder. The final image is tiny (e.g., 20MB) and doesn't contain all the npm build tools, making it more secure.
  • EXPOSE 80:** This is just *documentation*. It tells the user that the app inside *listens* on port 80. It does **not** actually open the port.
  • CMD ["nginx", "-g", "daemon off;"]:** The **default command** to run *when the container starts*. You can only have one CMD. This is the "program" the container is meant to run.

CMD vs. ENTRYPOINT

This is a common confusion.

  • CMD:** The *default command* to run. You can easily override it from the command line.
  • ENTRYPOINT:** The *main executable* of the container. This is harder to override.

Best Practice:** Use ENTRYPOINT to set the main command and CMD to set the *default argument* for that command.

ENTRYPOINT ["ping"]
CMD ["google.com"]

# 'docker run my-image' -> runs 'ping google.com'
# 'docker run my-image facebook.com' -> runs 'ping facebook.com'

`.dockerignore`

Just like .gitignore. When you run COPY . ., you don't want to copy your node_modules or .git folder into the image. You create a .dockerignore file:

node_modules
.git
.env
Dockerfile

Part 5: Building & Running Containers (The Commands)

docker build

This command reads the Dockerfile and builds your image.

# Build an image from the Dockerfile in the current directory (.)
# '-t' (tag) gives the image a name (my-app) and tag (v1)
$ docker build -t my-app:v1 .

docker images

Lists all the images you have built or downloaded to your local machine.

$ docker images

REPOSITORY     TAG       IMAGE ID       CREATED        SIZE
my-app         v1        d1a2b3c4e5f6   5 seconds ago  22.1MB
node           18-alpine a1b2c3d4e5f6   2 weeks ago    182MB
nginx          1.25-alpine f6g7h8i9j0k1   3 weeks ago    21.9MB

docker run (The Big One)

This is the most important command. It creates and starts a new container from an image.

# Run the 'my-app:v1' image
# '-d' (detached) - Run in the background
# '-p 8080:80' (port) - Map port 8080 on *my laptop* to port 80 *inside* the container
# '--name' - Give the container a custom name
$ docker run -d -p 8080:80 --name my-web-server my-app:v1

ab1234567890abcdef...

You can now open your browser to http://localhost:8080 and you will see your Nginx server running!

Other `docker run` flags:

  • -it (Interactive TTY): Runs the container in the *foreground* and attaches your terminal to it.
    # Start a new Ubuntu container and get a Bash shell inside it
    $ docker run -it ubuntu:latest bash
    
    root@ab12345:/# ls
    bin  boot  dev  etc  home  ...
    
    
  • -e (Environment Variable): Sets an environment variable inside the container.
    $ docker run -d -e "MY_VAR=hello" my-app:v1
    

docker ps (Manage Containers)

This is your "task manager" for running containers.

# Show *running* containers
$ docker ps

CONTAINER ID   IMAGE       COMMAND                  CREATED         STATUS         PORTS                  NAMES
ab1234567890   my-app:v1   "nginx -g 'daemon of…"   5 minutes ago   Up 5 minutes   0.0.0.0:8080->80/tcp   my-web-server


# Show *all* containers (including stopped ones)
$ docker ps -a

docker stop / start / rm

# Stop the running container (using its name or ID)
$ docker stop my-web-server

# Start it again
$ docker start my-web-server

# Permanently delete the container
$ docker rm my-web-server

docker logs (Debugging)

This lets you see the console output (Logcat) from a *running* container.

# Show all logs for the container
$ docker logs my-web-server

# Follow the logs in real-time (like 'tail -f')
$ docker logs -f my-web-server

docker exec (Run commands inside)

This is your "SSH" into a *running* container.

# Get a Bash shell *inside* the already-running 'my-web-server'
$ docker exec -it my-web-server bash

root@ab12345:/usr/share/nginx/html# ls
index.html  50x.html

Part 6: Docker Storage & Networking

Docker Volumes (Persistent Storage)

CRITICAL CONCEPT:** A container's filesystem is **ephemeral**. When you run docker rm my-web-server, the container is deleted, and any data *inside* it (like user uploads or a database) is **gone forever**.
To solve this, you use a **Volume**. A Volume is a way to "mount" a folder from your *Host* machine (your laptop) *into* the container. The data lives on your laptop, but the container *thinks* it's just a regular folder.

Let's run a **PostgreSQL database** in a container. We must mount a volume to protect the database files.

# 1. Create a named volume (managed by Docker)
$ docker volume create postgres-db-data

# 2. Run the container and mount the volume
$ docker run -d \
  -p 5432:5432 \
  -e "POSTGRES_PASSWORD=mysecretpassword" \
  --name my-postgres-db \
  -v postgres-db-data:/var/lib/postgresql/data \
  postgres:15

# Breakdown:
# '-v' is the volume flag
# 'postgres-db-data' is the name of the volume on our HOST
# '/var/lib/postgresql/data' is the path *INSIDE* the container where Postgres saves its data

Now, when the container writes to /var/lib/postgresql/data, it's *actually* writing to the postgres-db-data volume on your host. You can now docker stop and docker rm this container, start a new one, mount the *same volume*, and all your data will still be there.

Docker Networking

How do containers talk to each other? By default, Docker creates a "bridge" network. All containers on this network can find each other *by name*.

Instead of hard-coding an IP (172.17.0.2), your "web-app" container can just connect to the host named "db".

Part 7: Docker Compose (Multi-Container Apps)

You are *not* going to run 10 docker run ... commands with complex volume and network flags. That's what scripts are for.
**Docker Compose** is a tool that uses a single YAML file (docker-compose.yml) to define and run a *multi-container application*.

Let's define a full stack app: a Node.js API (api) and a Redis database (db).

docker-compose.yml
# Define the file version
version: '3.8'

# Define all the 'services' (containers) we need
services:
  
  # 1. Our API service
  api:
    # Build the Dockerfile in the current directory
    build: .
    ports:
      - "8080:8080" # Map host port 8080 to container 8080
    volumes:
      - ./app:/usr/src/app # Mount our code for live-reloading
    environment:
      - REDIS_HOST=db # Tell our app the DB is at hostname 'db'
    # This service depends on 'db' starting first
    depends_on:
      - db

  # 2. Our Database service
  db:
    # Pull this image directly from Docker Hub
    image: redis:latest
    ports:
      - "6379:6379"
    volumes:
      - redis-data:/data

# Define the named volume
volumes:
  redis-data:

Now, you just run **one** command to start this entire 2-container application:

# Start all services in detached mode
$ docker-compose up -d

[+] Running 2/2
 ⠿ Container my-project-db-1   Started
 ⠿ Container my-project-api-1  Started

And one command to stop and delete everything:

$ docker-compose down

[+] Running 2/2
 ⠿ Container my-project-api-1  Removed
 ⠿ Container my-project-db-1   Removed

Part 8: Container Registries (Sharing Images)

Your image is built (my-app:v1). It's on your laptop. How do you get it to your production server in AWS?

You use a **Container Registry**. This is a "GitHub for Docker Images."

  • Docker Hub:** The main public registry (like GitHub.com).
  • GitHub Container Registry (GHCR):** Hosts images directly on your GitHub repo.
  • AWS ECR / Google GCR / Azure ACR: Private registries hosted by cloud providers.

The workflow is simple: `tag`, `login`, `push`, `pull`.

# 1. (Tag) Give your image a "full name" that includes the registry
$ docker tag my-app:v1 ghcr.io/msmaxpro/my-app:v1

# 2. (Login) Log in to the registry
$ docker login ghcr.io -u MSMAXPRO -p $GITHUB_TOKEN

# 3. (Push) Upload your image
$ docker push ghcr.io/msmaxpro/my-app:v1

# 4. (Pull) On your production server, log in and pull the image
$ docker pull ghcr.io/msmaxpro/my-app:v1

# 5. (Run) Run the container from the newly pulled image
$ docker run -d -p 80:80 ghcr.io/msmaxpro/my-app:v1
Read the Official Docker Documentation →

© 2025 CodeWithMSMAXPRO. All rights reserved.