Our old Dockerfile did everything in one stage: install build tools, compile a small Rust helper binary we use for image resizing, install Node dependencies, and copy the whole thing into the final image. It worked, but the final image was 1.4GB and any change to application code invalidated the layer cache all the way back to apt-get install.
One Stage Doing Too Much
The original file looked roughly like this:
FROM node:20
RUN apt-get update && apt-get install -y build-essential curl
RUN curl https://sh.rustup.rs -sSf | sh -s -- -y
ENV PATH="/root/.cargo/bin:${PATH}"
COPY . .
RUN cd resize-helper && cargo build --release
RUN npm ci && npm run build
CMD ["node", "dist/server.js"]
Every layer after COPY . . reran on every single code change, including the Rust compile and the full npm ci, because Docker invalidates the cache for everything downstream of a changed layer. A one-line application change meant a five-minute rebuild.
Splitting Into Purpose-Built Stages
Multi-stage builds let each piece build in its own isolated stage with its own base image, and only the final artifacts get copied into the image that actually ships:
# --- stage 1: compile the Rust helper ---
FROM rust:1.79-slim AS rust-builder
WORKDIR /app/resize-helper
COPY resize-helper/Cargo.toml resize-helper/Cargo.lock ./
RUN mkdir src && echo "fn main() {}" > src/main.rs && cargo build --release
COPY resize-helper/src ./src
RUN cargo build --release
# --- stage 2: install and build the Node app ---
FROM node:20-slim AS node-builder
WORKDIR /app
COPY package.json package-lock.json ./
RUN npm ci
COPY . .
RUN npm run build
# --- stage 3: the actual runtime image ---
FROM node:20-slim
WORKDIR /app
COPY --from=node-builder /app/dist ./dist
COPY --from=node-builder /app/node_modules ./node_modules
COPY --from=rust-builder /app/resize-helper/target/release/resize-helper /usr/local/bin/resize-helper
CMD ["node", "dist/server.js"]
The trick in the Rust stage — building with a stub main.rs before copying the real source — caches the dependency compilation separately from the source code, so changing application logic doesn't force Cargo to rebuild every crate from scratch.
Why the Final Image Got So Much Smaller
The runtime stage never installs build-essential, curl, or the Rust toolchain at all — none of that exists outside the rust-builder stage, so it never ends up in the final layers. node:20-slim as the runtime base instead of the full node:20 image cut a few hundred megabytes on its own. Combined, the image went from 1.4GB down to about 240MB.
Cache Behavior in Practice
The bigger win day-to-day is the cache. Changing only application code now only invalidates node-builder's COPY . . step onward — the Rust stage and the npm ci layer are untouched, so a routine deploy build dropped from around five minutes to under forty seconds on our CI runners.
docker build --target node-builder -t app:node-builder . # useful for debugging just this stage
docker build -t app:latest .
This is the same image that ends up running on the small VPS I write about here — keeping it small mattered more than usual there, since the box has limited disk and I didn't want image pulls eating into it on every deploy.