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.
$ 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.
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.
# ---- 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.
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.
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
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.
# 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.
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
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.
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.
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.
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
Containerize a small app with persistent, networked storage
Put images, a multi-stage build, volumes, and container networking together into one working setup.
Requirements:
- 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. - Add a
.dockerignorethat excludesnode_modules(or your language's equivalent),.git, and any.envfile. - Build the image with
docker build -t myapp:1.0 .and confirm its size withdocker images myapp. - Create a user-defined network with
docker network create app-net, run apostgres:16-alpinecontainer 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. - Confirm your app container can reach the database by hostname (not IP) with
docker exec -it <app-container> shand a tool likenc -zv db 5432or your app's own DB connection log. - 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.
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?
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?
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?
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?
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.