
Dockerfile tips: from a build that works to a build nobody touches again
Every Dockerfile starts the same way: FROM, COPY . ., RUN the install,
CMD - and it works. Then one day, you notice that every build re-downloads
every dependency, that the image weighs in at 1.2 GB, that it runs as root,
and worse, that secrets have ended up baked into a layer.
This article walks through the tips & tricks that fix all of that, from the
simplest to the most advanced.
The starting point (do not imitate)
FROM eclipse-temurin:21
COPY . .
RUN ./mvnw package
CMD ["java", "-jar", "target/app.jar"]Four lines, five problems: an inefficient cache, a bloated image, build
tools shipped into production, running as root, and the entire
directory - .git included - copied into the image. Let’s fix them in
order.
Tip 1 - order layers from stable to volatile
Docker caches every instruction as a layer, and invalidates everything that follows the first modified layer. The practical consequence: copy what changes rarely first (the dependency descriptor), install, then copy the code that changes all the time.
# ✗ Bad: the slightest code change reinstalls everything
COPY . .
RUN pip install -r requirements.txt
# ✓ Good: dependencies are only reinstalled if requirements.txt changes
COPY requirements.txt .
RUN pip install -r requirements.txt
COPY . .On a real project, that’s the difference between a 4-minute rebuild and
an 8-second rebuild. The same logic applies to Maven (with its
pom.xml), npm (with package*.json), or Go (with go.mod and
go.sum).
Tip 2 - .dockerignore, the file everyone forgets
COPY . . sends the entire build context to the Docker daemon: .git,
local node_modules, build artifacts, your .env files. A
.dockerignore shrinks the context, speeds up the build, and above all
avoids shipping unwanted files into a layer (secrets, for instance):
.git
target/
node_modules/
__pycache__/
*.env
Dockerfile
docker-compose*.ymlA tip within the tip (to show just how much it matters to control
what ends up in your layers): without a .dockerignore, a simple
git commit (which changes .git/) invalidates the COPY . . cache
even though no source file has changed.
Tip 3 - the multi-stage build
The technique that changes everything: compile in a fully-tooled image, ship only the result in a minimal image.
# ── Stage 1: build ───────────────────────────────
FROM maven:3-eclipse-temurin-21 AS build
WORKDIR /app
COPY pom.xml .
RUN mvn dependency:go-offline # cached layer as long as pom.xml doesn't change
COPY src ./src
RUN mvn package -DskipTests
# ── Stage 2: final image ─────────────────────────
FROM eclipse-temurin:21-jre-alpine
WORKDIR /app
COPY --from=build /app/target/app.jar app.jar
ENTRYPOINT ["java", "-jar", "app.jar"]The final image contains neither Maven, nor the JDK, nor the sources: you go from ~750 MB down to ~180 MB, and the attack surface shrinks by just as much. The same pattern in Python, where the build stage produces a wheel:
FROM python:3.12 AS build
WORKDIR /app
COPY requirements.txt .
RUN pip wheel --no-deps --wheel-dir /wheels -r requirements.txt
FROM python:3.12-slim
WORKDIR /app
COPY --from=build /wheels /wheels
RUN pip install --no-index --find-links=/wheels /wheels/* && rm -rf /wheels
COPY src ./src
CMD ["python", "-m", "src.main"]Bonus: docker build --target build . lets you build only the first
stage - handy for running tests in CI against the fully-tooled image,
while still publishing the lightweight one.
Tip 4 - BuildKit cache mounts
Multi-stage protects the final image, but every build still starts from scratch on downloads as soon as the dependency descriptor changes. Cache mounts mount a cache directory that persists between builds, without it ever ending up in the image:
RUN --mount=type=cache,target=/root/.m2 mvn package -DskipTestsRUN --mount=type=cache,target=/root/.cache/pip pip install -r requirements.txtA modified pom.xml (or requirements.txt) now only re-downloads the
new dependencies. In the same family, --mount=type=secret gives access
to a secret (a token for repository access, an SSH key, etc.) during
the RUN, without ever storing it in any layer:
RUN --mount=type=secret,id=pip_token \
PIP_INDEX_URL=https://user:$(cat /run/secrets/pip_token)@pypi.internal/simple \
pip install -r requirements.txtdocker build --secret id=pip_token,src=.pip_token .This is THE right answer to the dangerous reflex of ARG TOKEN -
ARG values remain visible in docker history, so a secret set
through an ARG is a secret you’ve just handed to every future user of
your image.
Tip 5 - don’t run as root
By default, the container process runs as root. A container escape or an application flaw becomes noticeably less severe with a dedicated user:
RUN addgroup -S app && adduser -S app -G app
USER appPlace USER after the install RUN instructions (which need the
privileges), and before ENTRYPOINT. If the application writes
somewhere, prepare the directory ahead of time:
RUN mkdir /data && chown app:app /data.
Tip 6 - ENTRYPOINT, CMD, and the signal that never arrives
Two rules avoid 90% of the surprises:
Always use the exec form (
["java", "-jar", "app.jar"]), never the shell form (java -jar app.jar). In shell form,/bin/shholds PID 1: your application never receives theSIGTERMfromdocker stop, and gets killed outright after 10 seconds - no graceful shutdown.ENTRYPOINTfor the executable,CMDfor the default arguments:dockerfile ENTRYPOINT ["python", "-m", "src.main"] CMD ["--port", "8000"]docker run my-image --port 9000then simply overrides the arguments.
Tip 7 - the details of a Dockerfile crafted with care
Pin the base image versions:
python:3.12-slim, neverpython:latest- if you want a reproducible build (and I guarantee you do), this is the key.COPYrather thanADD, unless you explicitly need to extract an archive;ADDwith a URL downloads without verification or caching.Merge your
apt-getcalls so you don’t freeze package indexes into a layer:dockerfile RUN apt-get update && apt-get install -y --no-install-recommends curl \ && rm -rf /var/lib/apt/lists/*HEALTHCHECKso the orchestrator knows whether the application is alive - not just the process:dockerfile HEALTHCHECK --interval=30s --timeout=3s \ CMD curl -sf http://localhost:8000/health || exit 1Lint everything with hadolint:
docker run --rm -i hadolint/hadolint < Dockerfilecatches most of the points above, in CI as well as locally.
Putting it all together
FROM maven:3-eclipse-temurin-21 AS build
WORKDIR /app
COPY pom.xml .
RUN --mount=type=cache,target=/root/.m2 mvn dependency:go-offline
COPY src ./src
RUN --mount=type=cache,target=/root/.m2 mvn package -DskipTests
FROM eclipse-temurin:21-jre-alpine
RUN addgroup -S app && adduser -S app -G app
WORKDIR /app
COPY --from=build /app/target/app.jar app.jar
USER app
HEALTHCHECK --interval=30s --timeout=3s \
CMD wget -qO- http://localhost:8080/actuator/health || exit 1
ENTRYPOINT ["java", "-jar", "app.jar"]A good Dockerfile is recognized by three things: it rebuilds in seconds (ordered layers, cache mounts), it ships a minimal image (multi-stage, pinned slim base), and it runs without excess privileges (dedicated USER, exec form, healthcheck). Everything else is a detail - one that hadolint will happily check for you.