Docker 08 — Capstone: Containerize SplitEase
This is where the whole track stops being seven separate lessons and becomes one skill. You are going to take the real SplitEase API — the Spring Boot app you built in the Spring Boot and SQL tracks — and ship it as a sealed, one-command stack: app plus Postgres plus Redis, data that survives a restart, an image small enough to be proud of, pushed to a registry. No new theory to memorize. Just build it, break it on purpose, and fix it from the logs — because reading a broken container’s logs and knowing what to do is the part of this job no prompt does for you.
The Goal
By the end of this module you can:
- Write a correct multi-stage Dockerfile and
.dockerignorefor SplitEase from scratch - Compose the full stack — app, Postgres with a named volume and healthcheck, Redis — and bring it up with one command
- Verify the running stack with a real
curland prove the database survivesdocker compose down && up - Push the tagged image to GHCR or Docker Hub so CI/CD and Cloud can pull it
- Debug the five classic ways a container breaks — from
docker logsanddocker exec, not from guessing
The Lesson
Keep this short — the lesson today is the build itself. But first, see the whole journey in one picture so you know which earlier module owns each piece you are about to assemble.
The journey, recapped
flowchart LR
A["Dockerfile 02 03"] --> B["Image"]
B --> C["Run 04"]
C --> D["Data plus Network 05"]
D --> E["Compose 06"]
E --> F["Registry 07"]
Read it left to right and notice every box is a module you already did:
| Step | Module | What you learned there |
|---|---|---|
| Write the recipe | 02 Images & Layers, 03 Dockerfile Deep Dive | Layers, the build cache, multi-stage, non-root, .dockerignore |
| Run the box | 04 Running Containers | run, logs, exec, ports, env vars, restart policies |
| Keep data, wire it up | 05 Data & Networking | Named volumes so data survives, service DNS so containers find each other |
| One command | 06 Docker Compose | depends_on, healthchecks, the whole stack in one file |
| Ship it | 07 Images In Production | Tags, registries, the handoff to CI/CD and Cloud |
The capstone is just doing all of that in one sitting, on the real app. Open a terminal in your splitease-api repo and go.
Build This
Do it now, in order. Each step has a check so you know it worked before moving on. If a step breaks, jump to Debug The Container below — these are the exact failures you will hit.
1. Write the multi-stage Dockerfile
In the root of splitease-api, create a file named Dockerfile (no extension). This is the Docker 03 pattern: a fat build stage that has Maven and the full JDK, and a thin runtime stage that has only the JRE and your JAR. The build tools never ship.
# syntax=docker/dockerfile:1
# ---- Build stage: has Maven + full JDK, never shipped ----
FROM eclipse-temurin:21-jdk AS build
WORKDIR /app
# Copy ONLY the build descriptor first so dependency download is cached
COPY .mvn/ .mvn/
COPY mvnw pom.xml ./
RUN ./mvnw dependency:go-offline -B
# Now copy source. Editing code invalidates only from here down.
COPY src/ src/
RUN ./mvnw clean package -DskipTests -B
# ---- Runtime stage: only JRE + the JAR, small + non-root ----
FROM eclipse-temurin:21-jre
WORKDIR /app
# Create and switch to a non-root user (never run as root)
RUN useradd --system --no-create-home spring
USER spring
# Copy just the built JAR from the build stage
COPY --from=build /app/target/*.jar app.jar
EXPOSE 8080
ENTRYPOINT ["java", "-jar", "app.jar"]
Why the copy order matters: pom.xml and the dependency download come before COPY src/. Dependencies change rarely; your code changes constantly. Putting deps first means a normal code edit reuses the cached dependency layer instead of re-downloading the internet — the layer-ordering lesson from Docker 02. Get this backwards and every build is slow (that is drill e below).
Now the .dockerignore, right next to the Dockerfile. It stops junk and secrets from being copied into the image and from busting the cache:
target/
.git/
.gitignore
.idea/
*.iml
.env
*.log
docker-compose.yml
Dockerfile
README.md
target/ is the big one — without it you copy your local build output into the image, bloating it and invalidating the cache on every build.
2. Build it and check the size
docker build -t splitease-api:local .
docker images splitease-api:local
Expected: a SIZE column around 200–280 MB (Temurin JRE plus your JAR). If you accidentally shipped the jdk runtime stage instead of jre, you will see 450 MB+ — that is your signal the multi-stage split didn’t take.
3. Write docker-compose.yml
Same directory. This is the Docker 06 stack: your app, Postgres on a named volume with a healthcheck, and Redis. The app waits for Postgres to be healthy, not just started.
services:
app:
build: .
ports:
- "8080:8080"
environment:
SPRING_DATASOURCE_URL: jdbc:postgresql://db:5432/splitease
SPRING_DATASOURCE_USERNAME: splitease
SPRING_DATASOURCE_PASSWORD: splitease
SPRING_DATA_REDIS_HOST: redis
depends_on:
db:
condition: service_healthy
redis:
condition: service_started
db:
image: postgres:16
environment:
POSTGRES_DB: splitease
POSTGRES_USER: splitease
POSTGRES_PASSWORD: splitease
volumes:
- splitease_pgdata:/var/lib/postgresql/data
healthcheck:
test: ["CMD-SHELL", "pg_isready -U splitease -d splitease"]
interval: 5s
timeout: 3s
retries: 5
redis:
image: redis:7
volumes:
splitease_pgdata:
Two things to burn into memory. First, the database host is db, not localhost — inside the Compose network, db is the service’s DNS name (the Docker 05 lesson). Using localhost is drill c below and it bites everyone once. Second, splitease_pgdata is a named volume: Postgres writes its data there, outside the container, so it survives the container being destroyed. Forget it and you get drill d.
That visualizer shows exactly why the condition: service_healthy matters: without the healthcheck, the app starts the instant Postgres’s container is up — but Postgres inside is still initializing and refusing connections, so the app crashes on boot. The healthcheck makes the app wait until Postgres actually answers.
4. Bring it up and verify
docker compose up --build
Watch the logs. You should see Postgres report healthy, then the app boot and log Started SplitEaseApplication. In a second terminal, hit a real endpoint:
curl http://localhost:8080/actuator/health
Expected: {"status":"UP"}. That single response means the app is running, reached Postgres over the network, and the volume mounted. Now register a real user so there is data to lose:
curl -X POST http://localhost:8080/api/v1/auth/register \
-H "Content-Type: application/json" \
-d '{"email":"darshan@test.com","password":"secret123","name":"Darshan"}'
Now prove persistence — the whole point of volumes:
docker compose down
docker compose up -d
# wait for the app to boot, then try to log in as the user you registered
curl -X POST http://localhost:8080/api/v1/auth/login \
-H "Content-Type: application/json" \
-d '{"email":"darshan@test.com","password":"secret123"}'
Expected: the login succeeds and returns a token. The user survived a full teardown because it lived in splitease_pgdata, not in the container. If login fails with “user not found,” your volume is wrong — drill d.
5. Tag and push to a registry
This is the Docker 07 handoff. GHCR (GitHub Container Registry) is free for public images and you already have a GitHub account, so use it. Create a Personal Access Token (classic) with write:packages scope, then:
# Log in (USERNAME is your GitHub username)
echo $GHCR_TOKEN | docker login ghcr.io -u YOUR_GITHUB_USERNAME --password-stdin
# Tag the image with the registry path + a real version, not just latest
docker tag splitease-api:local ghcr.io/YOUR_GITHUB_USERNAME/splitease-api:0.1.0
# Push
docker push ghcr.io/YOUR_GITHUB_USERNAME/splitease-api:0.1.0
Expected: the push uploads each layer and prints a digest like sha256:.... That digest is the immutable fingerprint of exactly what you shipped — CI/CD and Cloud pull by it. (Docker Hub works identically: docker login, then tag as YOUR_DOCKERHUB_USERNAME/splitease-api:0.1.0.)
Definition of Done
You are not done because it “ran once.” You are done when every box is ticked:
- Image is a sane size — under ~300 MB; the build tools and JDK are not in the final image (multi-stage worked)
- Runs as a non-root user —
docker exec <container> whoamiprintsspring, notroot - Whole stack starts with one command —
docker compose upbrings up app + Postgres + Redis, no manual steps - App reaches the DB by service name —
db, neverlocalhost; the app waits on Postgres being healthy - Data survives a restart — a user registered before
docker compose downcan still log in afterup - Image is pushed to a registry — it exists at
ghcr.io/<you>/splitease-api:0.1.0and you candocker pullit on another machine - README documents it — one section that says
docker compose upand lists the endpoints, so a teammate needs zero hand-holding
Hit all seven and you have done what a real backend engineer does on day one of a new service.
Debug The Container
This is the part that matters most, so slow down here. You will hit every one of these. Each drill gives you the symptom, the exact error you will see, how to diagnose it, and the fix. Reproduce them on purpose — a bug you have created and fixed yourself is one you will never be stuck on in production.
Keep that lifecycle in your head while debugging: created → running → stopped → removed. A container that “won’t start” has usually run and immediately stopped — which is drill (a). The log is still there until you rm it.
(a) The container exits immediately
Symptom: docker compose up shows the app container start and then vanish. docker ps shows nothing; docker ps -a shows it Exited (1).
The error you will see — only after you go look for it:
docker compose logs app
# ...
# APPLICATION FAILED TO START
# Caused by: org.postgresql.util.PSQLException: FATAL: password authentication failed
Diagnose: the container didn’t “fail to start” — it started, the Java process crashed, and the container died with it. A container lives only as long as its foreground process (PID 1). When the JVM exits, the container exits. The logs are the autopsy.
Fix: read the actual exception. Here it is a wrong DB password — line up SPRING_DATASOURCE_PASSWORD in the app service with POSTGRES_PASSWORD in the db service. Rule for life: a container that exits immediately is not a Docker bug, it is your app crashing. Always docker logs first.
(b) “port is already allocated”
Symptom: docker compose up dies instantly on the app service.
The error you will see:
Error response from daemon: driver failed programming external connectivity on endpoint:
Bind for 0.0.0.0:8080 failed: port is already allocated
Diagnose: something else already owns host port 8080 — usually a local mvn spring-boot:run you forgot to stop, or a previous Compose run still up. Find it:
docker ps # is an old container holding 8080?
# Windows: see what owns the port
netstat -ano | findstr :8080
Fix: stop the other process. If it is an old container, docker compose down. If it is your local Spring app, kill it. Or, if you genuinely need both, change the host side of the mapping: "8081:8080" — host 8081 maps to container 8080, then curl localhost:8081. The left number is your machine, the right is inside the container.
(c) App can’t reach the database — “connection refused”
Symptom: the app boots but every DB call fails, or it crashes on startup with a connection error.
The error you will see:
org.postgresql.util.PSQLException: Connection to localhost:5432 refused.
Check that the hostname and port are correct and that the postmaster is accepting TCP/IP connections.
Diagnose: the giveaway is the word localhost. Inside the app container, localhost means the app container itself — and there is no Postgres in there. Postgres is a different container. There are two flavors of this bug:
- Wrong host: your config points at
localhost:5432instead of the service namedb:5432. Containers find each other by service name over the Compose network (the Docker 05 DNS lesson). - Startup race: the host is correct (
db) but the app started before Postgres finished initializing, so the very first connection is refused.
Fix: for the wrong host, set SPRING_DATASOURCE_URL to jdbc:postgresql://db:5432/splitease — use the service name. For the race, that is exactly what the depends_on: condition: service_healthy plus the Postgres healthcheck in your compose file prevent: the app does not start until pg_isready says Postgres is actually accepting connections. If you skipped the healthcheck, add it back.
(d) Data gone after docker compose down
Symptom: you registered a user, ran docker compose down, brought it back up, and the user is gone. Login returns “user not found.”
The error you will see (from your own curl):
{"status":404,"error":"Not Found","message":"No user with that email"}
Diagnose: two common causes. Either (1) you never gave Postgres a named volume, so its data lived in the container’s writable layer and down destroyed it along with the container — or (2) you ran docker compose down -v. That -v flag means “also delete the named volumes,” and it wipes splitease_pgdata deliberately. People reach for -v to “clean up” and nuke their data.
Fix: make sure the db service has volumes: - splitease_pgdata:/var/lib/postgresql/data and the volume is declared under the top-level volumes: key (it is, in the compose file above). Then use plain docker compose down to stop the stack — it keeps the volume. Save down -v for when you genuinely want a clean database. Verify a volume exists with docker volume ls.
(e) The build is slow every single time
Symptom: every docker build re-downloads all your Maven dependencies, even when you only changed one line of Java. Each build takes minutes.
The error you will see — not an error, a smell, in the build output:
=> [build 4/6] RUN ./mvnw dependency:go-offline -B 118.4s ← runs every time, never CACHED
Diagnose: look for CACHED in the build output. If the dependency step is not cached after a code-only change, your layer order is wrong. The classic mistake is copying everything at once:
COPY . . # ← bad: any file change busts this layer...
RUN ./mvnw clean package -DskipTests # ← ...so this re-runs and re-downloads every time
Any edit to any file invalidates that single COPY . . layer and everything below it, including the dependency download. This is the Docker 02 cache lesson in its most painful form.
Fix: copy the pom.xml and download dependencies before copying src/, exactly as the Dockerfile in step 1 does. Dependencies become their own cached layer that only rebuilds when pom.xml changes — a code edit reuses it and the build drops from minutes to seconds. And make sure target/ is in .dockerignore, or your local build output keeps busting the cache too.
Where to Practice
| What | Where | Time |
|---|---|---|
| Build and break this stack on your own machine | Your splitease-api repo | 90 min |
| Run the whole drill in a throwaway browser VM (no local Docker needed) | labs.play-with-docker.com | 45 min |
| Push your image and confirm it pulls elsewhere | ghcr.io (docs at docs.github.com) or hub.docker.com | 20 min |
| Re-read the multi-stage + compose references when stuck | docs.docker.com (multi-stage builds, Compose reference) | as needed |
| Extra guided Docker labs (free tier) | KodeKloud free Docker labs | 30 min |
How to Practice
Build the whole stack once with the files above and confirm all seven Definition-of-Done boxes. Then deliberately break it five times — one drill per failure — by editing the compose file or Dockerfile to cause exactly that bug, watching the error appear, and fixing it from docker logs and docker exec alone (no Googling the first time). The retention comes from causing the failure: once you have typed localhost and watched “connection refused” yourself, you will never wonder what that error means again. Do the five drills again from memory a week later — that day-7 re-test is what moves it from “I saw it” to “I own it.”
Check Yourself
Why use a multi-stage Dockerfile instead of a single stage?
The build stage needs Maven and the full JDK to compile the JAR — hundreds of MB of tools your app never uses at runtime. A multi-stage build does the compiling in a fat stage, then copies only the finished JAR into a thin JRE-only runtime stage. The build tools never ship, so the final image is far smaller and has a smaller attack surface.
Inside the Compose network, what hostname does the app use to reach Postgres, and why not localhost?
The service name from the compose file — db. Compose gives each service a DNS name on a shared network. localhost inside the app container means the app container itself, where there is no database, so it gives “connection refused.”
What exactly does a named volume do, and what happens to data without one?
A named volume stores the container’s data outside the container, on the Docker host, so it survives the container being stopped, removed, or recreated. Without it, Postgres writes into the container’s writable layer, which is destroyed when the container is removed — so the data vanishes on the next docker compose down.
Your app container exits immediately. Is that a Docker bug? What do you do first?
Almost never a Docker bug. A container lives only as long as its foreground process (PID 1). If the Java process crashes, the container exits with it. First move, always: docker logs <container> (or docker compose logs app) and read the real exception — the cause is in there.
What does the `-v` in `docker compose down -v` do, and why is it dangerous?
It removes the named volumes along with the containers — a deliberate wipe of your Postgres data. Plain docker compose down keeps the volume. Reaching for -v to “clean up” is how people accidentally delete their database.
Why does copying `pom.xml` before `src/` make builds dramatically faster?
Docker caches layers top to bottom and invalidates everything below the first changed line. Dependencies change rarely; code changes constantly. By downloading dependencies in a layer that only depends on pom.xml, a normal code edit leaves that layer cached and reuses it, so the build skips the dependency download entirely.
What does the Postgres healthcheck plus `depends_on: condition: service_healthy` prevent?
The startup race. Without it, the app starts the instant the Postgres container is up — but Postgres inside is still initializing and refusing connections, so the app’s first DB call fails. The healthcheck (pg_isready) makes Compose wait until Postgres actually accepts connections before starting the app.
How do you confirm your container is running as a non-root user?
docker exec <container> whoami — it should print spring, not root. Running as a non-root user is a basic security default: if the app is compromised, the attacker is not root inside the container.
Still Unclear?
Paste any of these to Claude to go deeper:
- “Here is my SplitEase Dockerfile and docker-compose.yml. Walk through what each layer and each service does, and point out anything that would make the image bigger or slower than it needs to be.”
- “My SplitEase app container exits immediately with this log: [paste the full
docker compose logs appoutput]. Walk me through diagnosing it step by step instead of just giving the answer.” - “Show me how to add a multi-stage
.dockerignore-aware build to my GitHub Actions CI/CD pipeline that builds this image and pushes it to GHCR on every push to main.”
Why AI Can’t Do This For You
AI can write you a Dockerfile in three seconds. What it cannot do is stand at your terminal at 11pm when the container is in a crash loop, read the half-truncated log, notice the password mismatch buried in a stack trace, and decide whether to fix the env var or the healthcheck. Debugging a broken container from docker logs and docker exec is pure judgment under uncertainty — connecting a symptom to a cause across the app, the network, and the volume — and that judgment only comes from having broken these things yourself and fixed them. The five drills above are not busywork; they are you building the instinct no prompt will ever hand you.
Module done? Add it to today’s tracker