Career OS

Docker 03 — Writing a Real Dockerfile

You now know that an image is built from stacked layers (Docker 02). This is the module where you actually write the recipe — and the gap between a Dockerfile that “works” and one a senior engineer would approve is enormous: 600 MB versus 200 MB, a 90-second rebuild versus a 5-second one, a container running as root that one CVE turns into a server breach versus one that can’t. By the end you’ll write a small, fast, non-root, multi-stage Dockerfile for the SplitEase API and understand why every line is where it is.

The Goal

By the end of this module you can:

  • Explain every common Dockerfile instruction — what it does and the trap beginners fall into
  • Distinguish ENTRYPOINT from CMD without guessing, and pick the right one
  • Order instructions so the build cache makes your rebuilds near-instant
  • Write a multi-stage Dockerfile that ships only a JRE plus the JAR, not the whole JDK and Maven
  • Harden the image: a non-root USER and a .dockerignore that keeps junk out of the build
  • Compare a naive single-stage image to your multi-stage one and read the size difference

The Lesson

The instructions, one at a time

A Dockerfile is a plain text file named exactly Dockerfile (no extension). Docker reads it top to bottom and turns most instructions into a layer (Docker 02). Here is every instruction you’ll actually use, with the beginner trap called out for each.

FROM — the base you build on

FROM eclipse-temurin:21-jdk

Every Dockerfile starts with FROM. It names the base image you build on top of — here, an official OpenJDK 21 build from Eclipse Temurin.

The trap: FROM eclipse-temurin:latest. latest is not “the newest stable version” — it’s just whatever tag the publisher last pushed there, and it changes under you. A build that worked Monday can break Friday because latest moved to a new major version. Always pin a specific tag (21-jdk, 21-jre, or even a digest). Pinning is the difference between a reproducible build and a time bomb. This is the same “works on my machine” disease the whole track exists to kill — don’t reintroduce it in line 1.

WORKDIR — set the working directory

WORKDIR /app

Sets the directory that following COPY, RUN, and CMD instructions run inside. If /app doesn’t exist, Docker creates it. After this, COPY pom.xml . lands the file at /app/pom.xml.

The trap: using RUN cd /app instead. cd only affects that single RUN layer and is forgotten on the next instruction — each RUN starts fresh. WORKDIR is the one that sticks. Use it.

COPY vs ADD — get your files in

COPY pom.xml .
COPY src ./src

COPY copies files from your build context (your project folder) into the image. Simple and predictable.

ADD does the same plus two extra tricks: it can fetch a URL, and it auto-extracts local tar archives. Those tricks are exactly why you should prefer COPY. ADD surprises people — ADD app.tar.gz /app silently unpacks the tarball, which is rarely what a beginner expects. Rule of thumb the Docker docs themselves give: use COPY unless you specifically need ADD’s auto-extract. Clarity beats cleverness.

RUN — execute a command at build time

RUN apt-get update && apt-get install -y curl && rm -rf /var/lib/apt/lists/*

RUN executes a shell command while building the image and bakes the result into a new layer. Installing packages, downloading dependencies, compiling — all RUN.

The trap: one RUN per line. Each RUN is its own layer (Docker 02). Three separate RUN apt-get ... lines means three layers, and apt-get update in one layer can go stale against an install in another. Chain related commands with && into a single RUN — fewer layers, smaller image, and the whole step succeeds or fails together. The rm -rf /var/lib/apt/lists/* on the end deletes the package cache in the same layer, so it never bloats the image. (Delete it in a later layer and it’s too late — the bytes are already frozen in the earlier one.)

ENV and ARG — variables, but different lifetimes

ARG MAVEN_OPTS=-Xmx512m
ENV SPRING_PROFILES_ACTIVE=prod

Both set variables, but they live at different times:

When it existsSurvives into the running container?Typical use
ARGBuild time onlyNoA version number or build flag you pass with --build-arg
ENVBuild time and runtimeYesConfig the app reads at runtime, like SPRING_PROFILES_ACTIVE

The trap: putting a secret in ENV or ARG. Both end up baked into the image’s metadata and history — anyone who pulls the image can read them with docker history. Never put passwords, API keys, or DB credentials in a Dockerfile. Those come in at run time (env vars, secrets), which Docker 04 covers.

EXPOSE — documentation, not a door

EXPOSE 8080

EXPOSE declares which port the app inside listens on. SplitEase runs on 8080, so you document it here.

The trap — and it’s a big one: EXPOSE does not publish the port or make it reachable from your laptop. It’s pure documentation for whoever reads the Dockerfile or runs docker inspect. To actually reach the app you publish the port at run time with docker run -p 8080:8080 (Docker 04). Beginners add EXPOSE, run the container, get connection-refused, and burn an hour. Now you won’t.

USER — stop running as root

RUN useradd -r -u 1001 appuser
USER appuser

By default, everything in a container runs as root (uid 0). That’s a security problem: if an attacker exploits your app, they’re root inside the container, and a container breakout from root is far more dangerous than from an unprivileged user. USER switches to a non-root account for everything after it (and for the running container).

The trap: not setting it at all. “It’s just a container” is exactly the mindset that ships root-by-default images to production. Real backend jobs flag this in review. Create a user and switch to it before the final CMD / ENTRYPOINT.

ENTRYPOINT vs CMD — the classic confusion, settled

This trips up everyone. Both define what runs when the container starts, but they play different roles:

What it isOverridable at docker run?
ENTRYPOINTThe fixed command — the thing this image isHard to override (needs --entrypoint)
CMDThe default arguments, OR a default command if no ENTRYPOINTEasily overridden — just append args to docker run

The clean way to think about it: ENTRYPOINT is the executable, CMD is the default arguments to it. When both are present and written in exec form (a JSON array), Docker runs ENTRYPOINT + CMD joined together.

ENTRYPOINT ["java", "-jar", "/app/app.jar"]
CMD ["--spring.profiles.active=prod"]

Run it plain and the container executes java -jar /app/app.jar --spring.profiles.active=prod. Run docker run splitease-api --spring.profiles.active=dev and the CMD is replaced by your argument, so it becomes ... --spring.profiles.active=dev. The java -jar part (the ENTRYPOINT) stays fixed — exactly what you want.

For a single self-contained app like SplitEase, a plain ENTRYPOINT ["java","-jar","/app/app.jar"] is the cleanest choice.

The trap — shell form vs exec form. CMD java -jar app.jar (shell form, no brackets) runs your app as a child of /bin/sh, so your Java process is PID 1’s child, not PID 1 — and it stops receiving the stop signal Docker sends. Result: docker stop hangs for 10 seconds then kills the container, and your app never shuts down cleanly. Always use exec form — the JSON array with brackets — so your process is PID 1 and gets SIGTERM directly.

.dockerignore — keep junk out of the build context

When you run docker build ., Docker first tars up the entire current folder (the “build context”) and sends it to the daemon. If that folder has target/, node_modules/, and .git/, you’re shipping hundreds of megabytes the build doesn’t need — slow, and a security risk (your .git history or a stray .env can leak into the image).

A .dockerignore file (same folder as the Dockerfile) fixes it:

target/
node_modules/
.git/
.env
*.log
.idea/

It works just like .gitignore. Everything listed is excluded from the context, so the build is faster and your image stays clean. The trap: forgetting it entirely — beginners wonder why a “tiny” app has a 400 MB build context and a slow build. This file is the fix.

Production technique 1 — order layers so the cache works for you

This is the single highest-leverage Dockerfile skill, and Docker 02 introduced the build cache that makes it work. Recap: Docker caches each layer, and rebuilds from the first changed layer downward. So the order you put instructions in decides how much gets rebuilt when you change one line of code.

The expensive step in a Java build is downloading dependencies (Maven pulling your whole pom.xml worth of JARs — often 90+ seconds). You edit .java source files constantly; you edit pom.xml rarely. So the rule writes itself:

Copy pom.xml and download dependencies before you copy your source code.

That way, editing a .java file only invalidates the source layer and below — the giant dependency download stays a cache hit. Copy everything at once with COPY . . and a single code edit invalidates the dependency layer too, re-downloading the world on every build.

Run both sides of that visualizer. “Deps first” reuses the cached download and rebuilds in seconds. “Code first” throws away 90 seconds every single build. Same app, same one-line edit — the only difference is instruction order. In a real job you rebuild dozens of times a day; this is the difference between a fast feedback loop and staring at a progress bar.

Production technique 2 — multi-stage builds for a small, slim image

To build a Spring Boot JAR you need the full JDK and Maven — hundreds of megabytes of compiler and build tooling. To run the finished JAR you need none of that — just a JRE (the runtime) and the JAR itself.

A naive single-stage Dockerfile bakes the entire JDK + Maven + your source + the downloaded .m2 cache into the final image. You ship 600+ MB of which the app uses a fraction. Worse for security: more tools in the image means more attack surface.

A multi-stage build splits this into two stages in one Dockerfile:

  1. Build stage — a fat image with JDK + Maven. It compiles and packages the JAR. This stage is thrown away.
  2. Final stage — a slim image with only a JRE. It copies just the finished JAR out of the build stage and runs it.

The final image contains the JRE and your JAR, nothing else. The build tooling never ships. You get a small image (~200 MB instead of ~600 MB), which means faster pulls, faster deploys, lower registry storage, and a smaller attack surface. Small + non-root is the production baseline.

The complete SplitEase Dockerfile — line by line

Here is the real, correct multi-stage Dockerfile for the SplitEase Spring Boot API (Java 21, Maven, Eclipse Temurin):

# ---- Stage 1: build (fat image: JDK + Maven) ----
FROM eclipse-temurin:21-jdk AS build
WORKDIR /app

# Copy ONLY the dependency descriptor first, then download deps.
# This layer is cached until pom.xml itself changes.
COPY pom.xml .
COPY .mvn ./.mvn
COPY mvnw .
RUN ./mvnw dependency:go-offline -B

# Now copy source. Editing code only invalidates from here down.
COPY src ./src
RUN ./mvnw package -DskipTests -B

# ---- Stage 2: final (slim image: JRE only) ----
FROM eclipse-temurin:21-jre AS final
WORKDIR /app

# Create and switch to a non-root user.
RUN useradd -r -u 1001 appuser

# Copy ONLY the finished jar out of the build stage. No JDK, no Maven, no source.
COPY --from=build /app/target/splitease-api-*.jar app.jar

USER appuser

# Documentation only — publish the real port with -p at run time (Docker 04).
EXPOSE 8080

# Exec form so the JVM is PID 1 and receives SIGTERM for a clean shutdown.
ENTRYPOINT ["java", "-jar", "/app/app.jar"]

Walking it:

  • FROM eclipse-temurin:21-jdk AS build — start the build stage on a full JDK 21, pinned (not latest). AS build names the stage so the final stage can copy from it.
  • WORKDIR /app — everything happens under /app.
  • COPY pom.xml . then COPY .mvn + mvnw — bring in only what’s needed to resolve dependencies. (.mvn and mvnw are the Maven wrapper, so the build doesn’t depend on Maven being pre-installed on the base.)
  • RUN ./mvnw dependency:go-offline -B — the expensive download, isolated above the source copy so it stays cached when you edit code. -B is batch mode (no interactive output).
  • COPY src ./src — now the source. This is the layer that changes when you edit .java files; everything above stays a cache hit.
  • RUN ./mvnw package -DskipTests -B — compile and package into a JAR. (Tests usually run earlier in CI — link: CI/CD track — so the image build skips them for speed.)
  • FROM eclipse-temurin:21-jre AS final — start a fresh, slim image on just the JRE. Everything from the build stage is left behind unless explicitly copied.
  • RUN useradd -r -u 1001 appuser — create a non-root system user.
  • COPY --from=build /app/target/splitease-api-*.jar app.jar — the magic line: reach into the build stage and pull out only the finished JAR. The JDK, Maven, and source never enter the final image.
  • USER appuser — switch off root for the running app.
  • EXPOSE 8080 — document the port (does not publish it).
  • ENTRYPOINT ["java", "-jar", "/app/app.jar"] — exec form, so the JVM is PID 1 and shuts down cleanly on docker stop.

That’s a small, fast-to-rebuild, non-root, production-shaped image. Every line earns its place.

Build This

Containerise the real SplitEase API and prove the multi-stage image is smaller than a naive one.

1. Write the multi-stage Dockerfile. In the root of your splitease-api project, create a file named Dockerfile with the complete contents from the section above. Create a .dockerignore next to it:

target/
.git/
*.log
.idea/

2. Build it.

docker build -t splitease-api .

Expected: the first build downloads dependencies (slow, ~1–2 min). Watch the output — you’ll see the two stages run. It ends with naming to docker.io/library/splitease-api.

3. Check the final image size.

docker image ls splitease-api

Expected: a SIZE column around 180–230 MB — JRE plus your JAR, nothing more.

4. Compare to a naive single-stage build. Make a second file, Dockerfile.naive:

FROM eclipse-temurin:21-jdk
WORKDIR /app
COPY . .
RUN ./mvnw package -DskipTests -B
ENTRYPOINT ["java", "-jar", "/app/target/splitease-api-0.0.1-SNAPSHOT.jar"]
docker build -t splitease-naive -f Dockerfile.naive .
docker image ls | findstr splitease

Expected: splitease-naive is 2–3x larger (~500–700 MB) — it carries the whole JDK, Maven, source, and the .m2 cache. Seeing the two sizes side by side is the whole lesson.

5. Prove the cache. Edit one .java file (add a comment), then rebuild the multi-stage image:

docker build -t splitease-api .

Expected: the dependency-download step shows CACHED and the build finishes in seconds, not minutes. That’s your layer ordering paying off.

Definition of done: the multi-stage docker build finishes clean, docker image ls shows a final image clearly smaller than the naive one, and a code-only edit rebuilds in seconds thanks to the cached dependency layer.

Where to Practice

WhatWhereTime
Dockerfile reference — every instruction, authoritativedocs.docker.com — Dockerfile reference40 min
Multi-stage builds, explained by Dockerdocs.docker.com — Multi-stage builds25 min
Build and tweak Dockerfiles in-browser (no install)Play with Docker30 min
Your real surface — containerise SplitEaseyour own splitease-api repo1 hr

How to Practice

Don’t read about Dockerfiles — write them. Take the SplitEase Dockerfile above, then deliberately break it one line at a time and watch what happens: swap exec form for shell form and run docker stop (it hangs); delete the non-root USER and docker exec in to confirm you’re root with whoami; reorder so COPY src comes before the dependency download and feel the cache die on the next build. Each broken-then-fixed cycle wires the why into your hands, which is the only version that survives.

Check Yourself

Why is `FROM eclipse-temurin:latest` a trap?

latest is just the publisher’s last-pushed tag, and it moves over time — a build that passed yesterday can break today when latest points at a new major version. Pin a specific tag (21-jdk) for reproducible builds.

COPY vs ADD — which do you prefer and why?

Prefer COPY. ADD also fetches URLs and auto-extracts tar archives, which causes surprising behaviour (ADD x.tar.gz silently unpacks). Use COPY for predictability; reach for ADD only when you specifically need its extra tricks.

Why chain shell commands with `&&` in a single RUN?

Each RUN is its own layer. Chaining related commands into one RUN with && means fewer layers and a smaller image, lets you clean up caches in the same layer (so they never get frozen), and makes the whole step succeed or fail together.

Does `EXPOSE 8080` make the app reachable from your laptop?

No. EXPOSE is documentation only — it records which port the app listens on. To actually reach it you publish the port at run time with docker run -p 8080:8080. Forgetting this is a classic connection-refused beginner trap.

ENTRYPOINT vs CMD — what's the difference?

ENTRYPOINT is the fixed command (the thing the image is); CMD is the default arguments (or a default command if there’s no ENTRYPOINT). With both in exec form, Docker runs ENTRYPOINT + CMD. Arguments you append to docker run replace the CMD but leave the ENTRYPOINT intact.

Why use exec form (`["java","-jar","app.jar"]`) instead of shell form?

Exec form makes your process PID 1, so it receives SIGTERM directly and shuts down cleanly on docker stop. Shell form runs your app as a child of /bin/sh, so the signal doesn’t reach it — docker stop hangs 10 seconds then force-kills the container.

What does a multi-stage build give you, in one sentence?

A small final image: the fat build stage (JDK + Maven) compiles the JAR and is thrown away; the slim final stage (JRE only) copies just the finished JAR — so build tooling never ships, the image is ~3x smaller, and the attack surface shrinks.

Why copy `pom.xml` and download dependencies before copying `src`?

So the expensive dependency-download layer stays a cache hit when you edit code. The cache invalidates from the first changed layer downward — putting source below the download means a .java edit only rebuilds source and beyond, not the 90-second dependency pull.

Still Unclear?

Paste any of these to Claude to go deeper:

  • “Walk me through what each layer in this multi-stage SplitEase Dockerfile contributes to the final image size, and show me how to find which layer is biggest with docker history.”
  • “I changed one line in a .java file and my Docker build still re-downloads all dependencies. Here is my Dockerfile — explain exactly which layer is invalidating the cache and how to reorder it.”
  • “Explain ENTRYPOINT vs CMD with three concrete docker run examples showing how appended arguments interact with each, including shell form vs exec form.”

Why AI Can’t Do This For You

AI will happily generate a Dockerfile that builds — that’s the easy part, and it’s exactly why it’s dangerous. The skill isn’t typing the instructions; it’s the judgment behind them: why dependencies go before source (and feeling the cache die when they don’t), why multi-stage halves your image and what specifically to copy out of the build stage, why exec form matters for clean shutdown, why root-by-default is a review-blocking security hole. When a generated Dockerfile produces a 700 MB image, hangs on docker stop, or runs as root, only an engineer who understands the why can spot it and fix it. AI writes plausible Dockerfiles; you have to write correct, defensible ones — and that judgment is what gets you hired.

Module done? Add it to today’s tracker

Saves your progress on this device.