Docker

Docker - Hard

Page 1 of 3

Layer Caching and Build Order, For Real

The medium tier introduced the idea that every instruction creates a cached layer. Here's the mechanic that actually matters day to day: Docker hashes each layer's inputs, and the moment ANY layer's inputs change, every layer after it is invalidated too - even if those later layers, in isolation, didn't change at all. Cache reuse is all-or-nothing from the first changed layer onward.

This is the exact reason a Dockerfile like this is a real, common production mistake:

# BAD - invalidates the dependency install on every single code change
FROM node:20-slim
WORKDIR /app
COPY . .
RUN npm install
CMD ["node", "server.js"]

COPY . . copies your entire source tree - including files that change on every commit. Docker has no way to know that your package.json didn't change, only that "the input to this COPY step is different from last time," so it invalidates that layer AND npm install right after it, every single build, even for a one-line change to an unrelated file. On a real project, that's the difference between a 10-second build and a 3-minute one, multiplied by every commit, every developer, every CI run.

The fix is to copy only what the dependency install actually needs, first:

# GOOD - dependency install only reruns when package.json actually changes
FROM node:20-slim
WORKDIR /app
COPY package.json package-lock.json ./
RUN npm install
COPY . .
CMD ["node", "server.js"]

Now npm install only reruns when package.json/package-lock.json actually change - which is rare - and every other code change just reuses that cached layer instantly. The general rule: order instructions from least likely to change to most likely to change. This single pattern is worth more to real build times than almost anything else in this tier.


Multi-Stage Builds Are the Default, Not an Optimization

A lot of material treats multi-stage builds as a nice-to-have trick for shaving off megabytes. That framing undersells it. For any language with a real build step - compiled languages, but also anything that needs a build toolchain (TypeScript, bundlers, native module compilation) - a multi-stage build isn't an optimization on top of a "normal" Dockerfile. It IS the correct default structure, because a build toolchain and a running application have fundamentally different, and mostly non-overlapping, needs.

# Stage 1: build - has the full toolchain, compilers, dev dependencies
FROM node:20 AS build
WORKDIR /app
COPY package.json package-lock.json ./
RUN npm ci
COPY . .
RUN npm run build

# Stage 2: runtime - only what's needed to actually RUN the built output
FROM node:20-slim
WORKDIR /app
COPY --from=build /app/dist ./dist
COPY --from=build /app/node_modules ./node_modules
CMD ["node", "dist/server.js"]

The build stage can be as large and toolchain-heavy as it needs to be - compilers, dev dependencies, build caches - because none of it ships. Only the COPY --from=build lines actually carry anything into the final image. The runtime image never contains a compiler, a test framework, or a single dev dependency, because it was never asked to build anything - it just runs what was already built.

The reason this is "the default, not an optimization": a single-stage build for a compiled or bundled app either ships a bloated image full of build tooling it will never use again, or forces you to manually clean up build artifacts and hope you got it right. A multi-stage build makes the correct outcome the automatic one.


Running as Root Is the Default, and Why You Shouldn't Accept It

Unless a Dockerfile explicitly says otherwise, a container's main process runs as root inside the container. This is rarely a deliberate decision - it's just what happens if you don't set a USER, and most tutorials never mention it.

Here's why it matters: container isolation is good, but it isn't a perfect, unbreakable wall. If an attacker finds a way to escape the container - through a kernel vulnerability, a misconfigured mount, or a container runtime bug - what they land as on the host is directly related to what they were running as inside the container. Root inside the container is a meaningfully worse starting position for an attacker to escalate from than an unprivileged user, and it's needless risk for the vast majority of apps that have no actual reason to need root.

The fix is a couple of lines:

FROM node:20-slim
RUN addgroup --system appgroup && adduser --system --ingroup appgroup appuser
WORKDIR /app
COPY --chown=appuser:appgroup . .
USER appuser
CMD ["node", "server.js"]

Many official base images now ship a pre-made unprivileged user for exactly this purpose (Node's images, for example, include a node user) - worth checking before writing your own. Either way, the principle is the same: your app should run with the least privilege it actually needs, and "root" is almost never actually needed.


    Welcome to OpsQuiz!

    Real scenario-based DevOps questions, hands-on practice, and clear explanations for every answer.