Topic 2.4
Dockerfile Best Practices
In one line
A checklist of habits that separate a Dockerfile a senior engineer would approve in code review from one that 'just works' on your laptop.
Key ideas
- 01
Use SPECIFIC base image tags, never bare
latest(Topic 1.4) —FROM eclipse-temurin:17.0.9_9-jre(or at least17-jre) is reproducible;FROM eclipse-temurin:latestis a ticking time bomb of surprise breakage. - 02
Prefer smaller base images (
-alpineor-slimvariants) when your app doesn't need the full OS toolset — smaller images pull faster, deploy faster, and have a smaller attack surface (fewer packages that could have vulnerabilities). Weigh this against Alpine's musl-libc occasionally causing subtle compatibility issues with certain compiled dependencies. - 03
Never run as the root user inside a container if you can avoid it — create and switch to a non-root user with
RUN useradd -m appuserandUSER appuser. If an attacker compromises your app inside the container, running as non-root limits what they can do even within that container's boundary (Phase 8 covers this in full depth). - 04
Combine related RUN instructions with
&&into fewer layers where sensible — e.g.RUN apt-get update && apt-get install -y curl && rm -rf /var/lib/apt/lists/*in ONE instruction, not three separate RUNs, because otherwise the package LISTS downloaded byapt-get updateget baked into their own layer and never actually get cleaned up even if a later separate RUN tries to remove them (each RUN's cleanup only affects its OWN layer). - 05
Use a
HEALTHCHECKinstruction so Docker (and orchestrators built on top of it) can tell whether your app is actually WORKING, not just whether the process is technically still running — a hung app that never crashes but never responds either is invisible without one. - 06
Set explicit resource-conscious defaults where it makes sense (e.g. JVM heap flags for a memory-constrained container) rather than letting the JVM assume it has the WHOLE host machine's memory available, which it will do by default even inside a container with a much smaller memory limit.
- 07
Keep ONE process/concern per container as the general rule (Docker's own philosophy) — a container running a Spring Boot app should just run that app, not also a cron daemon, a log rotator, and an SSH server bolted on; compose multiple containers together instead (Phase 5).
In your stack
- →
A production-grade Spring Boot Dockerfile checklist in one place: pin the base image tag, run as a non-root user, set
-XX:MaxRAMPercentageso the JVM respects the container's memory limit instead of the host's total memory, and add a HEALTHCHECK hitting Spring Boot Actuator's/actuator/healthendpoint.
Code & diagrams
Every line here maps directly to one of this topic's bullet points.
FROM eclipse-temurin:17.0.9_9-jre
# Create and switch to a non-root user — never run as root if avoidable
RUN useradd -m -u 1000 appuser
USER appuser
WORKDIR /app
COPY --chown=appuser:appuser target/myapp.jar app.jar
EXPOSE 8080
# Let Docker (and any orchestrator) know whether the app is actually healthy,
# not just whether the process happens to still be alive
HEALTHCHECK --interval=30s --timeout=3s --start-period=10s \
CMD wget -qO- http://localhost:8080/actuator/health || exit 1
# Respect the CONTAINER's memory limit, not the host machine's total RAM
CMD ["java", "-XX:MaxRAMPercentage=75.0", "-jar", "app.jar"]Explain it without notes
Why is RUN apt-get update && apt-get install -y curl in ONE instruction meaningfully different from the same two commands as TWO separate RUN instructions?
Why does a JVM running inside a container with a 512MB memory limit sometimes get OOM-killed even though -Xmx was never explicitly set very high?
Practice
Take any Dockerfile you've written so far and add a non-root USER instruction to it, rebuild, and confirm with docker exec <container> whoami that it's no longer running as root.
Add a HEALTHCHECK to a Dockerfile for any app you have (even a simple one hitting / with wget/curl), rebuild, run it, and watch docker ps show a 'healthy'/'unhealthy' status over time.
Trade-offs
- ↔
Every best practice here (non-root user, healthchecks, pinned versions, combined RUN layers) adds a small amount of Dockerfile verbosity and setup time — worthwhile for anything beyond a quick local experiment, and effectively mandatory for anything that will ever run in production or be shared with a team.
Done when you can
My Dockerfiles pin specific base image versions, never bare 'latest'.
My containers run as a non-root user by default.
I combine related RUN commands to avoid baking temporary files into permanent layers.
My production-bound Dockerfiles include a HEALTHCHECK.