There are two ways to make an image. Start a container, install things by hand, and docker commit it — which produces an artefact nobody can review, reproduce or version. Or write a Dockerfile, which is a plain text file of instructions that lives in your repository, gets code-reviewed, and produces the same image every time.
Only one of those is a real answer.
Topic 1: The Shape of Every Dockerfile
Whatever you are containerising, the thinking is the same four questions:
- What is the right base image? — the runtime your application needs, and nothing more.
- What must be installed or configured? — the setup steps you would otherwise run by hand.
- What ports does it use? — declare them.
- What command starts it? — the foreground process that is the container.
Here is the same application written the naive way and then properly. Naive first:
FROM ubuntu
RUN apt-get update && apt-get install openjdk-17-jdk wget -y
RUN wget https://example.com/artifacts/petclinic.jar
EXPOSE 8080
CMD ["java", "-jar", "petclinic.jar"]
It works. It is also a floating ubuntu tag, a full JDK where a JRE would do, a download that is not cached and not verified, and an application running as root. Properly:
FROM eclipse-temurin:21-jre-alpine
LABEL org.opencontainers.image.source="https://github.com/acme/petclinic"
LABEL org.opencontainers.image.description="Pet Clinic API"
WORKDIR /app
COPY --chown=app:app target/petclinic.jar /app/petclinic.jar
RUN addgroup -S app && adduser -S -G app app
USER app
EXPOSE 8080
HEALTHCHECK --interval=30s --timeout=3s --start-period=40s \
CMD wget -qO- http://localhost:8080/actuator/health || exit 1
ENTRYPOINT ["java", "-jar", "/app/petclinic.jar"]
Everything below is why each of those lines is the way it is.
Topic 2: The Filesystem Instructions
FROM must be the first instruction (after any ARG used in the FROM line). It sets the base image. Pin it — python:3.12-slim, not python. An unpinned base means the image you build today and the one you build in March are different, and nothing in your repository records that.
RUN executes a command at build time in a new layer and commits the result. Each RUN is a layer, so chain related commands:
# One layer, and the apt lists never get committed at all
RUN apt-get update \
&& apt-get install -y --no-install-recommends curl ca-certificates \
&& rm -rf /var/lib/apt/lists/*
Splitting that into three RUN lines gives you three layers and leaves the apt cache permanently in the image, because of the copy-on-write whiteout behaviour from lesson 5.
COPY copies files from the build context into the image. ADD does the same plus two extras: it can take a URL, and it auto-extracts local tar archives. Those extras are exactly why you should default to COPY — ADD’s behaviour is implicit and surprising. When you genuinely need a remote file, RUN curl with a checksum check is clearer and lets you verify what you downloaded.
COPY requirements.txt /app/ # trailing slash = destination is a directory
COPY src/ /app/src/
COPY --chown=app:app config/ /app/config/
WORKDIR sets the working directory for every subsequent RUN, CMD, ENTRYPOINT, COPY and ADD. It creates the directory if it does not exist. Use it instead of RUN cd /app — a cd inside a RUN only lasts for that one instruction.
USER sets the user for subsequent RUN, CMD and ENTRYPOINT. The default is root, and leaving it that way is the most common security defect in real Dockerfiles. The user must exist before you switch to it:
RUN addgroup -S app && adduser -S -G app app
USER app
Topic 3: ENTRYPOINT and CMD
This pair confuses more people than anything else in Docker, so here is the model that actually resolves it.
At run time, Docker executes ENTRYPOINT + CMD concatenated. Which of the two your docker run arguments replace depends on which you set:
| Dockerfile | docker run img executes | docker run img foo executes |
|---|---|---|
CMD ["java","-jar","app.jar"] | java -jar app.jar | foo — CMD fully replaced |
ENTRYPOINT ["java","-jar","app.jar"] | java -jar app.jar | java -jar app.jar foo — args appended |
ENTRYPOINT ["java"] + CMD ["-jar","app.jar"] | java -jar app.jar | java foo — CMD replaced, entrypoint kept |
The rule in one line: ENTRYPOINT is what the container is; CMD is the default arguments.
That third row is the pattern worth adopting. ENTRYPOINT fixes the binary; CMD supplies overridable defaults. Now docker run myimage --help does the obvious thing instead of trying to execute a program called --help.
Exec form versus shell form:
CMD ["nginx", "-g", "daemon off;"] # exec form — JSON array. Use this.
CMD nginx -g "daemon off;" # shell form — wrapped in /bin/sh -c
Shell form runs your process as a child of sh, which means sh is PID 1 and your application is PID 2. docker stop sends SIGTERM to PID 1 — to sh, which does not forward it. Your application never learns it should shut down, sits there for the full grace period, and gets SIGKILLed. Every “my container takes 10 seconds to stop” question traces back to shell form.
Use exec form. The one thing you lose is shell expansion — CMD ["echo", "$HOME"] prints the literal string. If you need a variable, be explicit: CMD ["sh", "-c", "exec java -jar $APP"], and note the exec so the JVM replaces the shell as PID 1.
Topic 4: ARG vs ENV
Both define variables. They exist at different times, and mixing them up causes real bugs.
ARG | ENV | |
|---|---|---|
| Available during build | Yes | Yes |
| Available at run time in the container | No | Yes |
| Set from the CLI | --build-arg | -e / --env at run time |
Visible in docker history | Yes | Yes, in image config |
FROM eclipse-temurin:21-jre-alpine
ARG APP_VERSION=2.7.3
ENV APP_HOME=/app \
JAVA_OPTS="-XX:MaxRAMPercentage=75"
WORKDIR ${APP_HOME}
RUN wget -q "https://artifacts.example.com/app-${APP_VERSION}.jar" -O app.jar
CMD ["sh", "-c", "exec java $JAVA_OPTS -jar app.jar"]
docker build --build-arg APP_VERSION=2.8.0 -t app:2.8.0 .
docker run -e JAVA_OPTS="-XX:MaxRAMPercentage=50" app:2.8.0
ARG is for build-time parameterisation: a version, a mirror URL, a base image tag. ENV is for configuration your application reads at run time, and it can be overridden per container with -e — which is the whole point.
Neither is a secret store. Build args land in docker history; env vars land in image metadata and in docker inspect. Anyone with the image can read both. Secrets go in via BuildKit build secrets or at run time from a secret manager — lesson 12.
Topic 5: The Remaining Instructions
LABEL attaches metadata. Prefer the OCI standard keys so tooling can read them:
LABEL org.opencontainers.image.source="https://github.com/acme/api"
LABEL org.opencontainers.image.revision="$GIT_SHA"
docker image inspect api:1.0 --format '{{json .Config.Labels}}'
EXPOSE documents which ports the application listens on. It publishes nothing (lesson 3) — but docker run -P reads it, and so do humans.
VOLUME declares a path whose contents should live outside the writable layer. Docker creates an anonymous volume there automatically if you do not mount one. Use it sparingly: anonymous volumes accumulate silently, and mounting explicitly at run time is clearer. Lesson 8 goes into this properly.
HEALTHCHECK tells Docker how to test whether the container is actually working, rather than merely running:
HEALTHCHECK --interval=30s --timeout=5s --start-period=40s --retries=3 \
CMD curl -fsS http://localhost:8080/health || exit 1
The container’s status becomes healthy, unhealthy or starting, visible in docker ps. --start-period is the flag people forget: it gives a slow-booting application time to come up without failures counting against it. Compose can gate service startup on this (lesson 11), which is the main reason to bother.
STOPSIGNAL changes the signal sent on docker stop — useful for applications that expect SIGQUIT (nginx) or SIGINT rather than SIGTERM.
ONBUILD registers an instruction that fires when another image uses yours as its base. It exists, it is occasionally right for a shared platform base image, and it surprises everyone downstream. Use with care.
Topic 6: The Build Context and .dockerignore
docker build -t myapp:1.0 .
docker build -t myapp:1.0 -f docker/Dockerfile.prod .
docker build -t myapp:1.0 https://github.com/acme/api.git#main
That trailing . is not “the Dockerfile is here”. It is the build context — the directory tree that gets packaged and sent to the daemon before the build starts. If your repository has a 3 GB node_modules, a .git history and a target/ directory, all of it is uploaded on every build.
.dockerignore fixes this, and it uses the same syntax as .gitignore:
.git
node_modules
target/
*.log
.env
**/__pycache__
Dockerfile
.dockerignore
Two wins, one of them security. The obvious one is speed: builds stop uploading gigabytes of irrelevance. The important one is that COPY . /app cannot copy in .env, .git or a stray private key if .dockerignore excludes them. COPY . . without a .dockerignore is how credentials end up in published images.
The -f flag lets the Dockerfile live outside the context root, which is how you keep several build variants in one repo. And note the third form: a git URL works as a context directly.
Ordering for cache hits:
FROM node:22-alpine
WORKDIR /app
COPY package.json package-lock.json ./ # changes rarely
RUN npm ci # expensive — stays cached
COPY . . # changes every commit
RUN npm run build
Copy the dependency manifest and install before copying source. A code change invalidates only the last two layers; npm ci is reused. Copy everything first and every commit reinstalls the world. This one ordering decision is usually worth minutes per build.
Try it yourself: Build an image with COPY . . before the dependency install, time a rebuild after touching one source file. Reorder as above and time it again. The difference is the lesson.
Common mistake: Using shell-form CMD and then wondering why docker stop takes ten seconds every time. Switch to exec form, and if you must use a shell, exec your process so it inherits PID 1 and receives the signal.