Docker – Writing a Dockerfile

June 24, 20265 min readUpdated 8/21/2026

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 build

Every 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 /build

Sets 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 -DskipTests

Runs 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_URL

ARG 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 pizza

Everything 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 8085

This 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 --debug

So: 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.jar

In 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 1

PID 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 literal

Which 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.jar

If 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.

Next: what docker build . actually sends.