06 — Your first Dockerfile
Read this first: this lesson has you containerise a small Spring Boot service and then discover,
by measuring a rebuild with a stopwatch, why the order of two lines decides whether your build takes
9 seconds or 23. It covers FROM/COPY/RUN/ENTRYPOINT, the layer cache, and .dockerignore.
Time: about 50 minutes. Assumes lesson 05.
The app you will containerise
The course ships a toy app in the same shape as the real system: a Spring Boot API
(greeter-api) and a React frontend (greeter-web), with Postgres behind them. It is
deliberately tiny — one controller, one component — because it exists to be built and deployed,
not to be a good example of application design.
Start with the API. Copy it somewhere you can make a mess:
cp -r learn-devops/greeter-api ~/devops-course/greeter-api
cd ~/devops-course/greeter-api
rm Dockerfile .dockerignore # you are going to write these yourself
It has two endpoints and one deliberate landmine:
| Endpoint | Returns |
|---|---|
GET /api/ | The version baked into the image at build time |
GET /api/count | Increments a row in Postgres |
GET /actuator/health | UP only when it is genuinely ready |
It also refuses to start unless APP_SECRET is at least 16 characters — you will find out why in
lesson 09.
Write the naive version first
Create Dockerfile:
FROM maven:3.9-eclipse-temurin-21 AS build
WORKDIR /build
COPY . .
RUN mvn -B -q package -DskipTests
FROM eclipse-temurin:21-jre-alpine
WORKDIR /app
COPY /build/target/greeter-api.jar ./
ENTRYPOINT ["java", "-jar", "greeter-api.jar"]
Line by line:
| Instruction | Does |
|---|---|
FROM | The base image you start from. Everything is FROM something. |
WORKDIR /build | Sets the working directory for what follows, creating it if needed |
COPY . . | Copy from the build context (your directory) into the image |
RUN | Execute a command at build time, keeping the resulting filesystem |
ENTRYPOINT | The default command at run time |
The RUN/ENTRYPOINT distinction is the one to internalise: RUN happens once, while the image is
built. ENTRYPOINT happens every time a container starts.
There are already two FROM lines here — that is a multi-stage build, and
lesson 07 explains it properly. For now, notice only that it builds with Maven
and ships with a JRE.
Build it, and time it:
time docker build -t greeter-api:v1 .
real 0m25.886s
The trailing . is the build context — the directory sent to the Docker daemon. It is not
decoration; it is an argument, and it decides what COPY can see.
Break it on purpose: measure the rebuild
Predict: change one character in a source file and rebuild. Which instructions will be reused from cache? Write your answer down before running it.
sed -i 's/Hello from greeter-api}/Hello from greeter-api!}/' src/main/resources/application.yml
time docker build -t greeter-api:v1 .
real 0m22.792s
Barely faster than building from scratch. Maven re-resolved and re-downloaded every dependency, for a change that touched one string.
Here is why. Docker caches each instruction against the inputs it touched. COPY . . touched a file
that changed, so that layer is invalidated — and every instruction after an invalidated layer is
invalidated too. RUN mvn package comes after, so it re-runs, and with it the whole dependency
resolution.
Fix it
Copy the pom.xml first, resolve dependencies, and only then copy the source:
FROM maven:3.9-eclipse-temurin-21 AS build
WORKDIR /build
COPY pom.xml .
RUN mvn -B -q dependency:go-offline
COPY src ./src
RUN mvn -B -q package -DskipTests
Now repeat the experiment — edit a source file, rebuild, and watch the log:
#12 [build 4/7] RUN --mount=type=cache,target=/root/.m2 mvn -B -q dependency:go-offline
#12 CACHED
#13 [build 5/7] COPY src ./src
#14 [build 6/7] RUN --mount=type=cache,target=/root/.m2 mvn -B -q package -DskipTests
real 0m9.254s
dependency:go-offline says CACHED, and the rebuild is two and a half times faster. The rule
generalises to every language:
Copy the thing that changes rarely (the dependency manifest) before the thing that changes constantly (your source), so the expensive step lands in a layer that survives.
--mount=type=cache,target=/root/.m2 is a second, independent win: it gives the build a persistent
Maven repository that lives outside the image, so even a cold build reuses previously downloaded
jars. This is exactly what backend/Dockerfile does, and it is why the
real backend — which has far more dependencies than this toy — is not agonising to rebuild.
The same rule applies to the React frontend, where it is package.json and package-lock.json that
get copied before src/. Open learn-devops/greeter-web/Dockerfile
and you will see the identical shape, which is also
frontend/Dockerfile's shape.
.dockerignore, and the bug it prevents
Predict: you just ran mvn package locally, so target/ holds a 25 MB jar. COPY . . uploads
it to the daemon and copies it into the image — which then rebuilds it anyway. What does that cost?
Create the file:
target
.git
.env
Everything in the build context is uploaded to the Docker daemon before the build starts, so a
context full of build output, .git history and secrets is slow and leaky. The repository's own
.dockerignore exists for exactly this reason, and its comment says so —
without it, every docs image build would upload the entire monorepo including a 30 MB derived graph.
For the React app the stakes are higher than speed. Without node_modules in .dockerignore, the
host's node_modules is copied over the one the image installed. If your laptop is macOS or Windows
and the image is Linux, any package with a compiled binary is now the wrong architecture, and the
container dies with MODULE_NOT_FOUND — for a directory that is visibly present. That is what
makes it confusing rather than merely broken.
Three more things that bite
Use the exec form of ENTRYPOINT.
ENTRYPOINT ["java", "-jar", "greeter-api.jar"] # exec form — java is PID 1
ENTRYPOINT java -jar greeter-api.jar # shell form — /bin/sh is PID 1
In the shell form your process is a child of /bin/sh. When docker stop sends SIGTERM the shell
gets it and the JVM does not, so the app never shuts down cleanly and Docker kills it after the full
10-second timeout.
Cap the JVM heap against the container, not the host.
ENTRYPOINT ["java", "-XX:MaxRAMPercentage=75", "-jar", "greeter-api.jar"]
Without it a JVM can size its heap against the host's memory and get OOM-killed inside a memory-limited container. The real backend sets exactly this flag.
Do not run as root.
RUN addgroup -S spring && adduser -S spring -G spring && chown -R spring:spring /app
USER spring
The MotorPH backend image creates the same spring user for the same reason.
Bake the version in
ARG APP_VERSION=dev
ENV APP_VERSION=${APP_VERSION}
ARG is a build-time variable; ENV makes it visible to the running process. Build with it:
docker build -t greeter-api:v1 --build-arg APP_VERSION=v1 .
Passing the git commit as APP_VERSION is what lets curl /api/ answer "which build is actually
live?" — turning a rollback from something you hope happened into something you can see. The
capstone depends on it.
Run it:
docker run --rm -p 8390:8080 -e APP_SECRET=your-local-dev-secret-value greeter-api:v1
curl localhost:8390/api/
{"version":"v1","greeting":"Hello from greeter-api"}
Break it on purpose: the secret that is too short
docker run --rm -e APP_SECRET=changeme greeter-api:v1
Caused by: java.lang.IllegalStateException: APP_SECRET must be at least 16 characters (got 8).
If you set it in an env file and it contains a $, single-quote the value.
The app refuses to boot. That is a deliberate copy of how the MotorPH backend behaves —
JwtProperties.validateSecret refuses to start on a weak or placeholder JWT secret. It looks like an
annoyance until lesson 09, where you will find a way to set a perfectly long
secret and have the application receive only the first fragment of it.
Where this shows up in MotorPH
- backend/Dockerfile is this same file with one extra idea: it splits the built jar into layers that change at different rates. That is lesson 07.
- frontend/Dockerfile builds with Node and serves from nginx, so the shipped image contains no Node at all.
- Both are built once per commit in CI and tagged
sha-<first 12 chars of the commit>— theAPP_VERSIONidea applied to a real system.
Recap
- Each instruction is a cached layer; invalidating one invalidates everything after it.
Copy
pom.xml(orpackage.json) before your source — measured here as 9s versus 23s. .dockerignoreis not optional. Without it you uploadtarget/and.git, and ship anode_modulesbuilt for the wrong platform.- Use the exec form of
ENTRYPOINT, cap the heap withMaxRAMPercentage, and addUSERso the process is not root.
Next: 07 — Multi-stage builds.