Launchpad: Container Foundations with Docker Compose
Run this only after the four Launchpad chapters. It expects Apollo11 commit
7b693c9bae0a789dc9db8e0628c478fd0dd53e88; verify it in lab setup.
Build the airline’s first containers, then follow what happens when they start, connect, and fail. For the explanation before the experiment, start with the Launchpad chapters. You can return to this lab whenever you’re ready.
Already read them? Jump to the investigations.
Before Kubernetes can be useful, it helps to feel the problem it is going to
solve. Start with Apollo Airlines on one laptop. The application is already
distributed: booking needs other services and databases, but Docker Compose
still gives us one machine, one network boundary, and one operator at a
terminal.
In this chapter you will not yet “learn Kubernetes commands.” You will learn what a running application is made of: a process, an image filesystem, a network identity, dependencies, and data. Those facts remain true when the process later lives in a Pod. Kubernetes adds a way to declare and coordinate them; it does not make them disappear.
Optional conceptual refresher
The chapters linked above are the primary explanation. Expand this refresher if you want the older all-in-one account beside the lab.
🎯 Learning Goals
By the end of this stage, you will be able to:
- Explain how containers differ from virtual machines using Linux namespaces and cgroups.
- Understand Dockerfile image layering, cache reuse, and non-root security boundaries.
- Distinguish internal container-to-container DNS from host port publishing.
- Explain why frontend browser code has different security and networking boundaries than backend microservices.
- Contrast
/healthz(vitality) with/readyz(dependency-aware readiness). - Observe failure cascading across a microservice dependency graph.
- Articulate why Docker Compose is insufficient for production clustering and why Kubernetes is needed.
📦 First question: what are we actually running?
What Is a Container? (Containers vs. Virtual Machines)
A browser can open Apollo Airlines, but the browser is not talking to “a container” in the abstract. It is ultimately talking to a normal process. The useful question is what makes that process feel separate from the host and from the other nine processes. A common misconception is that a container is a lightweight virtual machine. It is not.
In a Virtual Machine (VM):
- A hypervisor (such as KVM, VMware, or Hyper-V) slices physical hardware into virtualized CPU, memory, disks, and network cards.
- Each VM runs an entire guest operating system, with its own independent Linux or Windows kernel, systemd init system, device drivers, and background daemons.
- Booting takes tens of seconds to minutes, and memory overhead is measured in gigabytes per VM.
In a Container:
- There is no guest operating system and no hypervisor.
- A container is simply an ordinary Linux process running directly on the host Linux kernel, constrained by two fundamental Linux kernel features:
- Linux Namespaces (Isolation): What the process can see.
pidnamespace: Isolates process IDs. Inside the container, your application process thinks it is PID 1, completely blind to host processes.netnamespace: Gives the container its own virtual network interface (eth0), routing table, and private IP address.mntnamespace: Isolates filesystem mount points, so the container only sees its own image rootfs.ipcnamespace: Isolates Inter-Process Communication (shared memory, message queues).utsnamespace: Allows the container to have its own hostname.usernamespace: Maps container user IDs to unprivileged host user IDs.
- Control Groups (cgroups) (Resource Limits): What the process can use.
- Sets hard and soft ceilings on CPU shares, memory consumption, disk I/O, and maximum process count (pids).
- When a container exceeds its memory cgroup limit, the Linux kernel triggers an OOM (Out-Of-Memory) killer and terminates the process.
- Linux Namespaces (Isolation): What the process can see.
┌────────────────────────────────────────────────────────┐
│ VIRTUAL MACHINE │
│ ┌──────────────────────────────────────────────────┐ │
│ │ Application Code & Binaries │ │
│ ├──────────────────────────────────────────────────┤ │
│ │ Guest Operating System (Kernel, Drivers, Systemd)│ │
│ └──────────────────────────────────────────────────┘ │
│ ┌──────────────────────────────────────────────────┐ │
│ │ Hypervisor (Hardware Virtualization Layer) │ │
│ └──────────────────────────────────────────────────┘ │
│ ┌──────────────────────────────────────────────────┐ │
│ │ Host OS Kernel & Physical Hardware │ │
│ └──────────────────────────────────────────────────┘ │
└────────────────────────────────────────────────────────┘
┌────────────────────────────────────────────────────────┐
│ CONTAINER │
│ ┌──────────────────────────────────────────────────┐ │
│ │ Application Code, Dependencies & Root Filesystem │ │
│ ├──────────────────────────────────────────────────┤ │
│ │ Isolated via Linux Namespaces & cgroups │ │
│ └──────────────────────────────────────────────────┘ │
│ ┌──────────────────────────────────────────────────┐ │
│ │ Host Linux Kernel (Shared Directly) │ │
│ └──────────────────────────────────────────────────┘ │
│ ┌──────────────────────────────────────────────────┐ │
│ │ Physical / Virtual Host Hardware │ │
│ └──────────────────────────────────────────────────┘ │
└────────────────────────────────────────────────────────┘
Because containers share the host kernel, they usually start with less overhead than a full VM. The important practical consequence for this guide is simpler: a container is replaceable process state. If it needs durable data, stable network reachability, or supervision, those needs must be provided explicitly. You will watch all three needs surface in Apollo Airlines.
🏗️ Dockerfiles: Building Secure, Layered Images
Every service in Apollo Airlines has a Dockerfile. Consider the Go-based booking service Dockerfile:
Source: stages/launchpad/code/booking/Dockerfile
# Stage 1: Build binary using official Go toolchain
FROM golang:1.22-alpine AS builder
WORKDIR /app
COPY go.mod go.sum ./
RUN go mod download
COPY . .
RUN CGO_ENABLED=0 GOOS=linux go build -ldflags="-w -s" -o booking .
# Stage 2: Minimal runtime image
FROM alpine:3.19
RUN adduser -D -u 1000 appuser
WORKDIR /app
COPY --from=builder --chown=appuser:appuser /app/booking .
USER appuser
EXPOSE 8082
ENTRYPOINT ["/app/booking"]
Read this Dockerfile as a story of two environments
The first image is a workshop: it contains a compiler and source code so Docker
can produce booking. The second image is the thing that will actually run.
That separation answers two different questions: how do we build it? and
what must be present when it serves a request?
- Multi-Stage Builds:
- The build stage uses
golang:1.22-alpinecontaining the Go compiler, SDK, and Git (~300 MB). - The final stage copies only the compiled static binary into a clean
alpine:3.19base (~15 MB). - Result: Minimal attack surface, zero compiler tools in production, fast download times.
- The build stage uses
- Layer Caching Optimization:
COPY go.mod go.sum ./is executed beforeCOPY . ..- Docker caches image layers. If you edit Go source code in
main.go, Docker reuses the cached layer fromgo mod download. It only re-runs dependency downloads when dependencies change.
- Non-root execution (
USER appuser):- The runtime process has UID 1000 inside the container rather than UID 0. That reduces what a compromised process can do inside that container.
- It is one layer of defense, not a promise that a container escape grants or prevents host access. Launchpad later adds a read-only root filesystem and dropped Linux capabilities around the same process.
📜 Docker Compose: Multi-Container Coordination
Running ten containers one by one would make the relationships easy to lose: which database belongs to which service, which port is public, and which data must outlive a container. Docker Compose records those relationships in one file. It is our first example of configuration that describes a desired local arrangement rather than a sequence of shell commands.
Let's examine how the booking service and its database are defined:
Source: stages/launchpad/docker-compose.yml; exact booking, booking-db,
network, and volume excerpts are assembled below, with unrelated services
omitted.
services:
booking:
build:
context: ./code/booking
dockerfile: Dockerfile
restart: always
ports:
- "8082:8082"
environment:
DATABASE_URL: postgresql://${POSTGRES_USER:?Copy .env.example to .env}:${POSTGRES_PASSWORD:?Copy .env.example to .env}@booking-db:5432/booking
FLIGHT_SERVICE_URL: http://flight:8081
IDENTITY_SERVICE_URL: http://identity:8080
NOTIFICATION_SERVICE_URL: http://notification:8084
JWT_SECRET: ${JWT_SECRET:?Copy .env.example to .env}
PORT: "8082"
read_only: true
tmpfs:
- /tmp
security_opt:
- no-new-privileges:true
cap_drop:
- ALL
healthcheck:
test: ["CMD", "wget", "-q", "-O", "-", "http://127.0.0.1:8082/readyz"]
interval: 5s
timeout: 3s
retries: 10
depends_on:
booking-db:
condition: service_healthy
flight:
condition: service_healthy
identity:
condition: service_healthy
notification:
condition: service_healthy
networks:
- apollo-airlines
booking-db:
image: postgres:15-alpine
healthcheck:
test: ["CMD-SHELL", "pg_isready -U postgres -d booking"]
interval: 5s
timeout: 5s
retries: 10
environment:
POSTGRES_USER: ${POSTGRES_USER:?Copy .env.example to .env}
POSTGRES_PASSWORD: ${POSTGRES_PASSWORD:?Copy .env.example to .env}
POSTGRES_DB: booking
volumes:
- type: volume
source: booking-db-data
target: /var/lib/postgresql/data
- type: bind
source: ./code/booking/init.sql
target: /docker-entrypoint-initdb.d/init.sql
networks:
- apollo-airlines
# ...other Apollo Airlines services and named volumes are omitted...
networks:
apollo-airlines:
driver: bridge
volumes:
booking-db-data:
Follow the relationships, not the YAML order
The booking section does not run “before” booking-db because it appears
above it. Instead, it declares several relationships Docker must honor when it
creates containers. Read the fields with one request in mind: what does booking
need in order to turn an HTTP request into a reservation?
networks: [apollo-airlines]: Docker creates an isolated software bridge network. Inside this network, Docker runs an internal DNS resolver at127.0.0.11. Any container can resolve peer containers by their service name (e.g.bookingresolvesbooking-db,flight,identity, andnotification).ports: ["8082:8082"]: Host port forwarding (host_port:container_port). It binds port8082on your laptop's network interface and forwards incoming packets throughiptables/ Docker proxy into the container's private port8082.
booking calls http://flight:8081 using internal Docker DNS. It must never call http://localhost:8081, because localhost inside a container resolves to that container's own network namespace!
read_only: trueandtmpfs: [/tmp]: The container root filesystem is mounted as read-only. Even if an attacker compromises the process, they cannot overwrite system binaries or install malware on the filesystem. Any temporary file creation (such as buffering or PID files) is restricted to an in-memorytmpfsmounted at/tmp.cap_drop: [ALL]andno-new-privileges:true: Drops all default Linux kernel capabilities (such asCAP_NET_RAW,CAP_SYS_ADMIN), and prevents child processes from elevating privileges using setuid binaries.depends_on: { condition: service_healthy }: Controls Compose startup ordering: it waits for the listed dependencies to report healthy before startingbooking. It does not keep checking those dependencies for the lifetime ofbooking. Exercise 3 makes that limitation visible: a later database failure can make a running process unready.
🌐 Network Boundaries: Browser vs. Backend Microservices
Notice how the frontend service is configured in docker-compose.yml:
Source: stages/launchpad/docker-compose.yml (abridged frontend service)
frontend:
build:
context: ./code/frontend
dockerfile: Dockerfile
args:
VITE_IDENTITY_URL: http://localhost:8080
VITE_FLIGHT_URL: http://localhost:8081
VITE_BOOKING_URL: http://localhost:8082
VITE_SEARCH_URL: http://localhost:8083
ports:
- "3000:3000"
Why does the frontend use localhost while backend services use service names?
┌────────────────────────────────────────────────────────────────────────┐
│ USER LAPTOP / WORKSTATION │
│ │
│ ┌─────────────────────────┐ │
│ │ WEB BROWSER │ │
│ │ (Runs JavaScript on │ ──── calls http://localhost:8082 ────┐ │
│ │ the user's host OS) │ │ │
│ └─────────────────────────┘ │ │
│ │ │ │
│ downloads HTML/JS │ │
│ from http://localhost:3000 │ │
│ ▼ │ │
│ ┌─────────────────────────────────────────────────────────────┐ │ │
│ │ DOCKER BRIDGE NETWORK: apollo-airlines │ │ │
│ │ │ │ │
│ │ ┌──────────────┐ ┌──────────────┐ │ │ │
│ │ │ frontend │ │ booking │ ◄────────────────┘ │ │
│ │ │ (NGINX serves│ │ (Go / Gin │ │ │
│ │ │ static files│ │ API server) │ │ │
│ │ └──────────────┘ └──────────────┘ │ │
│ │ │ │ │
│ │ calls http://flight:8081 │ │
│ │ via Docker internal DNS │ │
│ │ ▼ │ │
│ │ ┌──────────────┐ │ │
│ │ │ flight │ │ │
│ │ │ (Go / Gin) │ │ │
│ │ └──────────────┘ │ │
│ └─────────────────────────────────────────────────────────────┘ │ │
└────────────────────────────────────────────────────────────────────────┘
- Frontend JavaScript runs inside your browser, which executes on your laptop's host operating system. Your browser cannot resolve Docker's private bridge network DNS (
http://booking). It must access APIs via published host ports (http://localhost:8082). - Backend microservices run inside Docker containers. When
bookingwants to talk toflight, it sends packets directly across theapollo-airlinesbridge network using Docker's internal DNS resolver:http://flight:8081.
Vite environment variables starting with VITE_* are compiled directly into the frontend static JavaScript bundle at build time. Anyone who opens the browser's Developer Tools can inspect them. Never put passwords, database connection strings, or signing keys into VITE_* variables!
🩺 A process can be alive and still be unable to help
Now imagine that flight-db stops after flight has started. The flight
process may still accept TCP connections and answer a simple “are you alive?”
request, yet it cannot answer a customer asking for flights. Treating both
states as one boolean causes bad recovery decisions. Apollo Airlines exposes
separate endpoints so an operator can tell the difference.
Sources: the service implementations under stages/launchpad/code/ and
stages/launchpad/docker-compose.yml.
/healthz(Vitality / Liveness):- "Is the container process alive, responsive, and not deadlocked?"
- Checks only internal server state (event loop, thread pool).
- If
/healthzfails, the process is stuck or crashed and must be restarted.
/readyz(Readiness / Dependency Gating):- "Can this service fulfill incoming user transactions right now?"
- Actively checks connections to critical downstream dependencies (e.g. database ping, Redis ping).
- If
flight-dbis down,flight's/readyzfails (returning HTTP 503). - Crucial distinction: Failing
/readyzdoes not mean the container should be restarted! Restartingflightwill not fix a brokenflight-db. Instead, it tells the load balancer: "Stop sending user traffic to me until my database recovers."
/metrics(Telemetry):- Exposes Prometheus-compatible text metrics (HTTP request counts, latency histograms).
🧪 Investigations
Each investigation asks one question. Read that question before running the commands; make a prediction first. Dynamic details such as container IDs, IPs, and timing will differ on your machine. The relationship being tested should not.
Exercise 1: Build and Launch Apollo Airlines
Question: When Compose says the stack is up, which evidence tells us that the application processes and their declared health checks have actually settled?
- Objective: Build all images, launch the multi-container network, and verify running services.
- Starting Point: Terminal inside the Apollo11 repository.
- Instructions:
cd stages/launchpad
# 1. Create the local environment file from the committed template contract
cp .env.example .env
# 2. Build and launch all 10 containers in detached background mode
docker compose up --build -d
# 3. Check container status
docker compose ps
- Expected Result:
All 10 containers show
Up (healthy). Ports3000,8080,8081,8082,8083,8084are mapped to0.0.0.0. - Verification Command:
curl -s http://localhost:8080/healthz
curl -s http://localhost:8081/readyz
curl -s http://localhost:8082/readyz
Expected output for each:
{"status":"ok"} or {"status":"ready"} with HTTP status code 200.
- Troubleshooting Hints:
If a container exits immediately with
variable not set, ensure you copied.env.exampleto.env. Compose uses bash-style parameter expansion (${VAR:?error}) to prevent running with empty credentials. - Concept reinforced: An image is the build artifact; a container is one runtime instance wired to ports, environment, networks, and storage.
Exercise 2: Inspecting Bridge Networking and DNS
Prediction: booking can resolve flight because both containers belong
to Docker's bridge network, while your host shell cannot use that same short
name. Test that difference rather than taking it on faith.
- Objective: Prove how containers resolve peer services and inspect Docker's network namespace.
- Starting Point: Apollo Airlines containers running from Exercise 1.
- Instructions:
# 1. Inspect the Docker network to see container IP assignments
docker network inspect launchpad_apollo-airlines | grep -E "(Name|IPv4Address)"
# 2. Exec into the booking container and resolve peer service DNS names
docker compose exec booking getent hosts flight identity notification booking-db
# 3. Test an internal HTTP call between containers
docker compose exec booking wget -qO- http://flight:8081/healthz
- Expected Result:
getent hostsprints private bridge IPs (e.g.172.x.x.x) for each service name.wgettohttp://flight:8081/healthzreturns{"status":"ok"}. - Verification command:
docker compose exec booking getent hosts booking-dbmust return an address attached to the inspected Compose network. - Troubleshooting hints: Compose derives the default network prefix from
the directory/project name. If
launchpad_apollo-airlinesis absent, rundocker network lsand inspect the network reported bydocker compose ps. - What Concept This Reinforces: Service discovery in Docker Compose is handled entirely by embedded DNS on the bridge network. No external discovery service or Consul agent is needed.
Exercise 3: Dependency Cascading & Readiness Propagation
Prediction: stopping flight-db should not necessarily kill the flight
container. Instead, the useful signal is that flight cannot perform its job,
and services that depend on it may report the same problem.
- Objective: Break a database dependency and observe how readiness fails without crashing the application process.
- Starting Point: Healthy running cluster.
- Instructions:
# 1. Stop only the flight-db container
docker compose stop flight-db
# 2. Check the health status of flight, search, and booking
curl -i http://localhost:8081/readyz
curl -i http://localhost:8083/readyz
curl -i http://localhost:8082/readyz
# 3. Notice that the liveness / healthz endpoint is STILL OK
curl -i http://localhost:8081/healthz
- Expected Result:
/readyzonflightreturnsHTTP/1.1 503 Service Unavailablewith database connection error./readyzonbookingandsearchalso fail or report degraded status because their required upstream flight dependency is unready./healthzonflightreturnsHTTP/1.1 200 OKbecause the Gin web server process is still healthy and running!
- Recovery:
# 4. Restart flight-db
docker compose start flight-db
# 5. Poll readiness until recovery completes
curl --retry 10 --retry-all-errors --fail -i http://localhost:8081/readyz
-
Verification command: Repeat all three
/readyzcalls and confirm they return to HTTP 200 afterflight-dbis healthy. -
Troubleshooting hints: If readiness stays down, inspect
docker compose psanddocker compose logs flight-db flight search booking; database recovery and downstream retry loops can take several probe intervals. -
What Concept This Reinforces: Readiness is separate from liveness. Killing a service because its database went offline causes a "thundering herd" of restart loops. Proper readiness allows the service to pause traffic ingestion and automatically recover the moment the dependency is restored.
Exercise 4: Testing Volume Persistence vs. Container Deletion
Prediction: a named volume belongs to Docker, not to the short-lived
identity-db container. Removing containers without -v therefore differs
from deleting the volume itself.
- Objective: Prove the difference between container memory/disk and Docker named volumes.
- Starting Point: Database initialized and seeded.
- Instructions:
# 1. Query users from identity-db
docker compose exec identity-db psql -U postgres -d identity -c "SELECT email FROM users LIMIT 1;"
# 2. Stop and remove the containers (WITHOUT removing volumes)
docker compose down
# 3. Start containers back up
docker compose up -d
# 4. Check if the database data survived
docker compose exec identity-db psql -U postgres -d identity -c "SELECT email FROM users LIMIT 1;"
- Expected Result:
The user record is preserved! The named volume
launchpad_identity-db-datawas untouched. - Verification command:
docker volume inspect launchpad_identity-db-datashould succeed before and afterdocker compose down. - Troubleshooting hints: If the volume name differs, inspect
docker volume ls; a custom Compose project name changes the generated prefix. - Concept reinforced: Removing containers does not remove named volumes unless cleanup explicitly requests volume deletion.
docker compose down -v destroys the stage's attached named volumes and the
database records inside them. The normal cleanup command below deliberately
omits -v. Run the destructive form only when you intend to reset all
Launchpad data and accept that it cannot be recovered without a backup.
Exercise 5: Security Context & Read-Only Root Filesystem
Prediction: the booking process needs a place for temporary writes but
does not need to change its application binary. The result should show that
these are different filesystem locations with different rules.
- Objective: Verify that application containers cannot write to their root filesystem.
- Starting Point: Running containers.
- Instructions:
# 1. Try to create a file in the root directory /app of booking
docker compose exec booking touch /app/hacked.txt
# 2. Try to create a file in the allowed /tmp directory
docker compose exec booking touch /tmp/valid-scratch.txt && echo "Success in /tmp"
- Expected Result:
Command 1 fails with
touch: /app/hacked.txt: Read-only file system. Command 2 succeeds because/tmpis mounted as a writabletmpfs. - Verification command:
docker compose exec booking ls -l /tmp/valid-scratch.txtshows the allowed scratch file. - Troubleshooting hints: If writing
/appsucceeds, inspect the resolved model withdocker compose configand confirmread_only: truebelongs to thebookingservice. - What Concept This Reinforces: Immutable, read-only root filesystems protect workloads against accidental file pollution and attackers attempting to drop binaries or modify configuration files at runtime.
🛑 The question Compose leaves open
You have seen Docker Compose run all 10 services cleanly on one computer. So why do we need Kubernetes?
| Capability | Docker Compose | Kubernetes |
|---|---|---|
| Multi-Node Scheduling | Single machine only. If the laptop or host VM dies, everything dies. | Schedules workloads across dozens to thousands of physical or cloud worker nodes. |
| Desired State Reconciliation | Imperative: starts containers on request. Does not continuously enforce desired state if an external actor deletes a container. | Declarative: continuously watches observed state and reconciles it with desired state. |
| Zero-Downtime Rolling Updates | Replaces containers by stopping the old one and starting the new one, creating brief outages. | Orchestrates rolling updates, surge pods, readiness gates, and traffic shifting. |
| Self-Healing & Eviction | Restarts crashed containers locally, but cannot reschedule workloads away from a failing physical server. | Kubelet restarts failed containers; controller-manager reschedules pods if a node stops reporting heartbeats. |
| Declarative Storage Orchestration | Local host directories or Docker volumes tied to one machine. | Dynamic volume provisioning, cloud disk attachment (AWS EBS, GCP PD), and StatefulSets. |
| Advanced Traffic Routing | Basic port forwarding. | Service abstractions, virtual cluster IPs, Ingress controllers, and Gateway API. |
🏁 What You Learned
- How Linux namespaces (
pid,net,mnt) isolate container processes and cgroups restrict CPU and memory. - Why multi-stage Docker builds produce lean, secure, non-root container images.
- How Docker Compose provides internal DNS resolution over a private bridge network.
- Why browser clients talk to
localhostpublished ports, while backend microservices talk to internal DNS names. - The fundamental operational difference between
/healthz(liveness) and/readyz(readiness). - How Docker named volumes outlive container lifecycles.
✈️ Before Continuing: Checkpoint
Before moving to the next stage, verify you can answer these questions:
- If you run
kill -9 1inside a container, does Docker restart it? Why? - If
flight-dbis stopped, why doesflight's/readyzfail while/healthzsucceeds? - Why can't the React frontend call
http://booking:8082directly from the user's browser? - What happens to database contents when running
docker compose downvsdocker compose down -v?
When you're ready, shut down Launchpad and step into Kubernetes:
# Clean up Launchpad containers before creating your cluster
cd stages/launchpad
docker compose down
👉 Continue to Ignition: Your First Kubernetes Cluster