Skip to main content

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:

EndpointReturns
GET /api/The version baked into the image at build time
GET /api/countIncrements a row in Postgres
GET /actuator/healthUP 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 --from=build /build/target/greeter-api.jar ./
ENTRYPOINT ["java", "-jar", "greeter-api.jar"]

Line by line:

InstructionDoes
FROMThe base image you start from. Everything is FROM something.
WORKDIR /buildSets the working directory for what follows, creating it if needed
COPY . .Copy from the build context (your directory) into the image
RUNExecute a command at build time, keeping the resulting filesystem
ENTRYPOINTThe 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 --mount=type=cache,target=/root/.m2 mvn -B -q dependency:go-offline

COPY src ./src
RUN --mount=type=cache,target=/root/.m2 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> — the APP_VERSION idea applied to a real system.

Recap​

  • Each instruction is a cached layer; invalidating one invalidates everything after it. Copy pom.xml (or package.json) before your source — measured here as 9s versus 23s.
  • .dockerignore is not optional. Without it you upload target/ and .git, and ship a node_modules built for the wrong platform.
  • Use the exec form of ENTRYPOINT, cap the heap with MaxRAMPercentage, and add USER so the process is not root.

Next: 07 — Multi-stage builds.