Week 3: Docker & Containerization Fundamentals

Week 2 gave you the networking vocabulary — ports, the request/response model — that a container needs to talk to the outside world; this week gives you the container itself. You'll learn what a container image and its layers actually are, how that differs from a full virtual machine, and how to write, run, and network a container by hand. The multi-stage Dockerfile you write this week is exactly what Week 4's docker-compose.yml wires together into a multi-service app, what Week 5 pushes to a registry, and what a Kubernetes Pod in Week 11 ultimately runs.

Module 3 of 22 Week 3 of 26 ~3–4 Hours Hands-on Exercise Included

By the end of this week, you'll be able to

  • Explain images, layers, and what isolation a container actually provides compared to a VM
  • Write a lean, multi-stage Dockerfile for a real application
  • Persist data with volumes, connect containers over a Docker network, and manage the container lifecycle from the CLI

1. Images, Layers & Containers vs. Virtual Machines

A container image is a read-only, portable bundle of everything an application needs to run: the application code, its runtime, and its dependencies, down to the base operating system files it expects to find. A container is a running instance of that image, plus a thin writable layer on top for anything the process creates or modifies at runtime.

Images are built as a stack of layers, one per instruction in the Dockerfile that produced them. Docker caches each layer by content hash, so if a layer's inputs haven't changed since the last build, Docker reuses the cached layer instead of rebuilding it — this is why instruction order in a Dockerfile directly affects build speed.

terminal
$ docker history node:20-alpine
IMAGE          CREATED         CREATED BY                                      SIZE
9c8f2e1a4b3d   3 weeks ago     CMD ["node"]                                     0B
<missing>      3 weeks ago     COPY docker-entrypoint.sh /usr/local/bin/       388B
<missing>      3 weeks ago     RUN apk add --no-cache libstdc++              2.44MB
<missing>      3 weeks ago     ENV NODE_VERSION=20.15.0                        0B
<missing>      5 weeks ago     /bin/sh -c #(nop) ADD alpine-minirootfs...    7.34MB

A virtual machine virtualizes hardware: a hypervisor runs an entire guest operating system, kernel included, on top of the host. A container does not virtualize hardware or run its own kernel — it's a regular process on the host, isolated from other processes using Linux kernel features: namespaces (each container gets its own view of process IDs, network interfaces, mounts, and hostnames) and cgroups (which cap how much CPU and memory it can use). That's why a container starts in milliseconds and a VM takes tens of seconds — a container isn't booting anything, it's just a process the kernel is hiding from its neighbors.

Isolation, not a security boundary by itself

Because containers share the host kernel, a kernel-level exploit or a container running as root with excessive privileges can, in principle, affect the host. Namespaces and cgroups give you process and resource isolation, not the hard security boundary a VM's separate kernel provides — this is exactly why Week 14 covers running containers as a non-root user and dropping Linux capabilities you don't need.

2. Writing a Lean, Multi-Stage Dockerfile

A naive Dockerfile copies your whole project into the final image, including build tools, dev dependencies, and source files the running app never touches — bloating the image and widening its attack surface. A multi-stage build fixes this: one stage compiles or builds the app with a full toolchain, and a second, separate stage copies only the compiled output into a minimal runtime base, discarding everything else.

Dockerfile — Node.js app, multi-stage
# ---- Stage 1: build ----
FROM node:20-alpine AS build
WORKDIR /app
COPY package.json package-lock.json ./
RUN npm ci
COPY . .
RUN npm run build

# ---- Stage 2: runtime ----
FROM node:20-alpine
WORKDIR /app
ENV NODE_ENV=production
COPY package.json package-lock.json ./
RUN npm ci --omit=dev
COPY --from=build /app/dist ./dist
USER node
EXPOSE 3000
HEALTHCHECK --interval=30s --timeout=3s CMD wget -qO- http://localhost:3000/health || exit 1
CMD ["node", "dist/server.js"]

The runtime stage never sees the build stage's devDependencies, test files, or source TypeScript — only the compiled dist/ output that COPY --from=build pulls across. USER node drops root privileges for the running process, and HEALTHCHECK lets Docker (and later, an orchestrator) know whether the container is actually serving traffic, not just running.

.dockerignore
node_modules
dist
.git
.env
*.log
npm-debug.log
Dockerfile

.dockerignore works like .gitignore: it stops the listed paths from ever being sent to the build context, which keeps COPY . . from accidentally shipping your local node_modules, .env secrets, or .git history into the image.

terminal
docker build -t myapp:1.0 .
docker images myapp          # inspect the resulting size
docker build --no-cache -t myapp:1.0 .   # force a clean rebuild, ignoring layer cache
Copy dependency manifests before source code

Notice COPY package.json package-lock.json ./ happens before COPY . . and before npm ci. As long as your dependency files haven't changed, Docker reuses the cached npm ci layer even when your source code changes on every commit — turning a multi-minute dependency install into a cache hit on almost every CI build in Week 6.

3. Volumes & Container Networking

A container's writable layer disappears the moment the container is removed — fine for stateless app code, disastrous for a database. Docker volumes solve this by storing data outside the container's own filesystem, in a location that survives container removal.

terminal
# Named volume -- Docker manages where this lives on disk
docker volume create pgdata
docker run -d --name db -v pgdata:/var/lib/postgresql/data postgres:16-alpine

# Bind mount -- maps a specific host path into the container
docker run -d --name web -v "$(pwd)/src:/app/src" -p 3000:3000 myapp:1.0

The two are suited to different jobs. A named volume (pgdata above) is managed entirely by Docker, portable across environments, and the right choice for persistent application data like a database's files — you never need to know or care exactly where it lives on the host disk. A bind mount maps a specific, host-chosen path into the container, which is ideal for local development (edit source on your host, see the change immediately inside the running container) but ties the setup to that host's exact filesystem layout, which makes it a poor fit for production data.

By default, every container gets its own private network namespace and cannot be reached by anything on the host without an explicit port mapping (-p host:container). Containers can also be attached to a user-defined bridge network, which gives them DNS-based discovery of each other by container name — the exact mechanism Week 4's Compose services rely on to reach each other without hardcoded IPs.

terminal
docker network create app-net
docker run -d --name db --network app-net postgres:16-alpine
docker run -d --name web --network app-net -p 3000:3000 myapp:1.0
# from inside "web", the hostname "db" resolves to the db container's address --
# no hardcoded IP, no manual /etc/hosts entry
-p 3000:3000 reads left-to-right as host:container

It's a common early mixup: the first number is the port on your host machine, the second is the port the process is actually listening on inside the container. -p 8080:3000 lets you visit localhost:8080 on your machine to reach an app that's still listening on port 3000 inside the container — the mapping, not the app, decides your host-facing port.

4. The Container Lifecycle & Docker CLI

A container moves through a small set of states, and the CLI has one verb per transition. Knowing these individually — not just reaching for docker run every time — matters once you're debugging a container that exists but won't stay running.

terminal
docker create --name web -p 3000:3000 myapp:1.0   # create, but don't start
docker start web                                    # start a created/stopped container
docker run -d --name web -p 3000:3000 myapp:1.0     # create + start in one step (-d = detached)
docker stop web                                     # graceful shutdown (SIGTERM, then SIGKILL after a timeout)
docker rm web                                       # remove a stopped container
docker rm -f web                                    # force stop + remove in one step

docker run is really create + start fused together, which is convenient but hides a useful distinction: a container can exist (and be inspected, or have its logs read) without currently running.

terminal — everyday inspection commands
docker ps                    # running containers
docker ps -a                 # all containers, including stopped ones
docker logs -f web           # stream logs (Ctrl+C to stop following)
docker exec -it web sh       # open an interactive shell inside a running container
docker inspect web           # full JSON detail: network, mounts, env, restart policy
docker stats                 # live CPU/memory usage per container

docker exec is your primary debugging tool for "why is this container behaving oddly" — it opens a shell in the container's existing namespace, so you see exactly the filesystem, environment variables, and network view the running process sees, rather than guessing from the outside.

docker system prune reclaims disk space fast

Stopped containers, unused images, and dangling layers accumulate quickly during local development. docker system prune (add -a to also remove unused images, not just dangling ones) removes everything not currently referenced by a running container — run it whenever docker ps -a shows a graveyard of exited containers you don't recognize.

5. Hands-on Exercise

Hands-on

Containerize a small app with persistent, networked storage

Put images, a multi-stage build, volumes, and container networking together into one working setup.

Requirements:

  1. Take any small app you have (or a minimal Express/Flask "hello world" you write for this exercise) and write a multi-stage Dockerfile: one stage installs dependencies and builds, a second, smaller stage runs the app as a non-root user.
  2. Add a .dockerignore that excludes node_modules (or your language's equivalent), .git, and any .env file.
  3. Build the image with docker build -t myapp:1.0 . and confirm its size with docker images myapp.
  4. Create a user-defined network with docker network create app-net, run a postgres:16-alpine container on it with a named volume for its data directory, and run your app container on the same network with a port mapping to your host.
  5. Confirm your app container can reach the database by hostname (not IP) with docker exec -it <app-container> sh and a tool like nc -zv db 5432 or your app's own DB connection log.
  6. Stop and remove both containers, then start fresh ones attached to the same named volume, and confirm the database data is still there — proving the volume, not the container, is what persisted it.
Hint

If your app container can't resolve the database's hostname, double-check both containers were started with the same --network app-net flag — containers on the default bridge network or on different user-defined networks can't see each other by name, only containers sharing the same user-defined network get that DNS resolution.

6. Knowledge Check

Four quick questions. Expand each to check your answer.

Q1

Why does a container start in milliseconds while a virtual machine takes tens of seconds to boot?

A container is just a regular process on the host, isolated with Linux namespaces and cgroups — starting it means starting a process, not booting an operating system. A VM boots an entire separate guest kernel under a hypervisor, which involves real hardware-level initialization the host's already-running kernel doesn't need to repeat for a container.

Q2

Why does copying package.json before the rest of the source code speed up repeated Docker builds?

Docker caches each layer and only rebuilds a layer (and everything after it) if its inputs changed. If package.json/package-lock.json are copied and npm ci run before the general COPY . ., then editing application source code alone doesn't invalidate the dependency-install layer, so Docker reuses the cached result instead of reinstalling every dependency on every single build.

Q3

Your app container connects to a database container by hostname, but only works when both are on the same user-defined Docker network. Why?

Docker's embedded DNS server only resolves container names to addresses for containers attached to the same user-defined bridge network — it's a feature of that network, not a global lookup across every container on the host. Containers on the default bridge network, or on two different user-defined networks, have no name-based route to each other and would need manual IP addressing (or wouldn't be able to reach each other at all) instead.

Q4

You stop and remove a Postgres container that was using a named volume, then start a brand-new container attached to that same volume. Why is the data still there?

A named volume's storage is managed by Docker independently of any single container's lifecycle — removing a container only removes its writable layer, never a volume attached to it. Because the new container mounts the same named volume at the same path, it sees the exact files the old container left behind, which is the entire reason databases should write to a volume rather than the container's own filesystem.