A Dockerfile is a script for building an image. Each instruction produces a layer; the layers stack into a filesystem; the metadata instructions record how to start a process in it.
There are about fifteen instructions and you will use eight.
FROM
FROM eclipse-temurin:21-jdk AS buildEvery image starts from another image. eclipse-temurin is the Adoptium build of
OpenJDK — the same one sdk install java gives you by default — and 21-jdk
is the tag.
AS build names this stage. A Dockerfile can have several, and that is lesson 7.
Pin something. FROM eclipse-temurin with no tag means
:latest, and your build silently moves to Java 25 the day it is released.
WORKDIR
WORKDIR /buildSets the directory for every following RUN, COPY, ENTRYPOINT
and CMD, and creates it if it does not exist.
Use it rather than RUN cd /build. Each RUN is a fresh shell, so a
cd in one has no effect on the next — a genuinely confusing failure where files land
in / and nothing errors.
COPY
COPY .mvn/ .mvn/
COPY mvnw pom.xml ./Copies from the build context (lesson 5) into the image. Paths on the left are relative to the
context; paths on the right are relative to WORKDIR.
The trailing slash is load-bearing. COPY src/ src/ copies the
contents of src into src. COPY src /app where
/app does not exist copies the directory as /app. Getting it
wrong produces /app/src/src or a file where a directory should be.
There is also ADD, which does everything COPY does plus fetching URLs
and auto-extracting tarballs. Use COPY. The auto-extraction is a
surprise nobody wants — ADD app.tar.gz /app unpacks it, and if you wanted the file you
now have its contents instead.
RUN
RUN ./mvnw -B dependency:go-offline || true
COPY src/ src/
RUN ./mvnw -B clean package -DskipTestsRuns a command at build time and commits the result as a layer. (Contrast
CMD, which runs at run time and commits nothing.)
Two things in those three lines are deliberate and worth stealing.
|| true on the warm-up: dependency:go-offline resolves what the build
will need and stops, but some plugins only reveal their dependencies once they actually run, so it
can exit non-zero on a warning. Failing the whole build over a warm-up step is not what anyone
wants; the real build below downloads whatever was missed.
-DskipTests: tests belong in CI, where a failure is reported against a commit with
the report attached. Running them here makes every image build pay for them and turns a flaky test
into a failed deploy. The workflow in lesson 20 runs tests in a separate job, first.
RUN is where images get fat. Every one is a layer, and a layer
keeps whatever the command left behind — see lesson 3 on why && rm -rf belongs
in the same RUN as the thing it cleans up.
ENV and ARG
ARG VITE_API_BASE_URL=""
ENV VITE_API_BASE_URL=$VITE_API_BASE_URLARG is a build-time variable, passed with --build-arg, and it does not
exist in the finished image. ENV is an environment variable that does — set
in every layer after it and in every container started from it.
The two lines above, from the React frontend's Dockerfile, accept a build argument and promote it to an environment variable so the build tool can read it.
⚠️ Neither is a place for a secret. An ARG is recorded in the image
metadata and docker history prints it; an ENV is in the image forever.
Lesson 12 covers where secrets actually go.
USER
RUN groupadd --system pizza && useradd --system --gid pizza --no-create-home pizza
USER pizzaEverything after this line — remaining build steps and the container's own process — runs as that user. Without it, everything runs as root. Lesson 9 is about why that matters more than it sounds.
EXPOSE
EXPOSE 8085This publishes nothing. It is documentation: a record of which port the image
expects to serve on, readable by docker inspect, and the list docker run -P
uses when it assigns random host ports.
Reaching the port from your machine still requires -p. Omitting EXPOSE
breaks nothing; including it tells the next person which port to publish.
CMD and ENTRYPOINT
The distinction everyone gets wrong once.
ENTRYPOINT is the executable. CMD is its default
arguments. Anything you type after the image name on docker run replaces
CMD and is appended to ENTRYPOINT.
ENTRYPOINT ["java", "-jar", "app.jar"]
CMD ["--spring.profiles.active=prod"]docker run pizza-api # java -jar app.jar --spring.profiles.active=prod
docker run pizza-api --debug # java -jar app.jar --debugSo: ENTRYPOINT when the image is one program and arguments configure it.
CMD alone when the image is an environment someone might want a shell in — with only
CMD, docker run myimage sh gets you a shell; with
ENTRYPOINT, it tries to pass sh to your program.
Exec form, always
This is the part that actually bites.
# exec form — a JSON array. Correct.
ENTRYPOINT ["java", "-jar", "app.jar"]
# shell form — a bare string. Runs as: /bin/sh -c "java -jar app.jar"
ENTRYPOINT java -jar app.jarIn shell form your process is a child of /bin/sh, and that shell
does not forward signals. So docker stop sends SIGTERM to the shell, the JVM never
hears about it, Docker waits ten seconds and SIGKILLs everything — cutting off in-flight requests
and skipping every shutdown hook.
Exec form makes your process PID 1, which receives the signal. You can check:
$ docker compose logs api | grep "PID"
Starting PizzaSpringbootBackendApplication v0.0.1-SNAPSHOT using Java 21.0.11 with PID 1PID 1. That is what a graceful shutdown depends on, and lesson 21 returns to it.
Exec form has no shell, and that is the catch
No shell means no variable expansion and no globbing:
ENTRYPOINT ["java", "-jar", "/app/*.jar"] # fails: Unable to access jarfile /app/*.jar
ENTRYPOINT ["java", "-jar", "$APP_HOME/app.jar"] # fails: $APP_HOME is a literalWhich is exactly why the real Dockerfile renames the jar during the build rather than globbing
for it at start-up — Spring Boot's jar comes out as
pizza-springboot-backend-0.0.1-SNAPSHOT.jar, and hard-coding that version is an edit
that gets forgotten on the next release:
RUN mv extracted/application/*.jar extracted/application/app.jarIf you genuinely need shell behaviour, ask for it explicitly —
ENTRYPOINT ["sh", "-c", "exec java -jar $APP_HOME/app.jar"]. The exec
matters: it replaces the shell with the JVM, so you get expansion and your process back
at PID 1.