Optimising a Docker image with a real Dockerfile: stages, why not Alpine, and the traps
Most articles about Docker image optimisation start from a ten-line hello world and end with "use Alpine". This one starts from a 289-line Dockerfile that runs in production today, for a Node.js API that generates PDFs with a headless Chrome, hashes passwords with a native module, and resizes images with another. It is a good Dockerfile. It was written by people who knew what they were doing, and it still contains five traps, one of which puts production credentials inside the image.
I am going to read it in the order it is written, say what each part buys, and then rewrite the last stage. Everything below is real, lightly anonymised.
What the image has to contain
- A NestJS application, compiled from TypeScript to
dist/. - 59 runtime dependencies, three of which are native or heavy:
bcrypt(compiled C++),sharp(libvips, prebuilt binaries per platform),puppeteer(downloads a full Chrome). - 25 development dependencies: the Nest CLI, TypeScript, Jest, ESLint, Prettier, the
@typespackages. None of them is needed to run the application. - The system libraries Chrome needs to start on Debian: 28
aptpackages.
That mix is the honest case. A pure JavaScript service with no native code can be optimised by reflex; this one forces every decision to be argued.
Stages: what they actually buy
The file declares six stages. Multi-stage builds are usually sold as "smaller images", which is true but secondary. What they really give you is the right to install things you will not ship.
# base: the one thing every stage shares
FROM node:24-slim AS base
WORKDIR /app
RUN corepack enable
# deps: compilers for native modules, thrown away afterwards
FROM base AS deps
RUN apt-get update && apt-get install -y --no-install-recommends python3 make g++ \
&& rm -rf /var/lib/apt/lists/*
COPY package.json yarn.lock .yarnrc.yml ./
RUN corepack prepare --activate && yarn install --immutable
# build: TypeScript to dist/
FROM base AS build
COPY --from=deps /app/node_modules ./node_modules
COPY . .
RUN yarn build
Three things worth copying from this:
- The compilers live in
depsand nowhere else.python3 make g++are needed once, to compilebcrypt. The runtime image never sees them. Without stages, you either ship a C++ toolchain to production or you write anapt-get removethat never quite removes everything. - Dependencies are installed from the lockfile alone, before the source is copied.
package.json,yarn.lockand.yarnrc.ymlare the only inputs to that layer, so it is rebuilt only when they change. Editing a controller does not reinstall 84 packages.--immutablemakes the build fail if the lockfile would have changed, which is exactly what you want in CI. - The package manager version is pinned by Corepack, from the
packageManagerfield, instead of whatever Yarn the base image happens to carry. The comment in the file says why: the image ships Yarn 1.22, the project uses Yarn 4, and the difference is not subtle.
Then a stage most people would not think of:
# chrome-standalone: published to ECR on its own, rebuilt only when deps change
FROM base AS chrome-standalone
COPY --from=deps /app/node_modules ./node_modules
ENV PUPPETEER_CACHE_DIR=/puppeteer-cache
RUN npx puppeteer browsers install chrome
# chrome: the pre-built image, referenced by name
ARG CHROME_BASE_IMAGE=…/cossuel-backend-chrome-base:latest
FROM ${CHROME_BASE_IMAGE} AS chrome
Downloading Chrome is the slowest step of the build and its result changes only when Puppeteer is upgraded. So it is built as a separate image, pushed to the registry, and every application build simply copies /puppeteer-cache out of it. A build that used to fetch 150 MB of browser on every commit now fetches nothing. This is the pattern for any heavy, rarely changing artefact: models, browsers, SDKs.
Why the runtime is Debian slim, not Alpine
The first comment in the file is a scar:
# Use slim (glibc) for build AND runtime: bcrypt/sharp compiled on Alpine (musl)
# break the Debian production container → ECS circuit breaker / rollback.
What happened is the canonical Alpine failure. Native modules are compiled against the C library of the image that builds them. Alpine uses musl; Debian, and nearly every prebuilt binary on npm, uses glibc. A bcrypt compiled in an Alpine build stage and copied into a Debian runtime stage loads, then crashes with an unhelpful symbol error at the first call. The container exits, ECS notes the failure, the deployment circuit breaker rolls back to the previous task definition, and the deploy is reported as a failure with a stack trace that mentions none of this.
The general rule: build and run on the same C library, and if you have any native code, that library should be glibc. Alpine's advantage is about 100 MB on a base image. Against that:
sharp,bcrypt,canvasand friends either need to be compiled from source (slow, needs the toolchain) or ship separate musl builds that lag behind;- musl's
mallocis measurably slower for allocation-heavy runtimes like Node and the JVM, and its DNS resolver behaves differently with search domains and multiple nameservers, which shows up as intermittent "cannot resolve" errors inside clusters; - debugging tools you reach for at 2 a.m. (
strace,gdb, a normalps) are not there or are the BusyBox variant.
node:24-slim is Debian with the documentation and locales removed. It is about 200 MB, everything on npm works on it, and the 100 MB you would have saved with Alpine is smaller than the Chrome you are about to add anyway. Alpine is the right answer for a static Go binary. It is rarely the right answer for Node, Python or Java with native dependencies.
The five traps
1. Eighty-four build arguments, and the secrets they carry
The production stage opens with 84 ARG lines and 85 ENV lines that copy them into the image: database URI, JWT secrets, AWS access key and secret, SMTP password, Twilio token, an OpenAI key, payment webhook secrets. The CI passes them as --build-arg and the Dockerfile bakes each one into the image configuration.
ARG AWS_SECRET_ACCESS_KEY
ENV AWS_SECRET_ACCESS_KEY=$AWS_SECRET_ACCESS_KEY # now in `docker inspect`, forever
Two consequences. First, anyone who can pull the image has production credentials: docker inspect prints the environment in clear, and so does the registry's API. Second, the image is welded to one environment. There is no staging image that becomes the production image once it has been tested; there is a staging build and a separate production build from the same commit, which is not the same artefact. The ECS task definition for this service had zero environment variables, because everything was already inside the image. That is the symptom to look for.
The fix is not a Docker trick. Configuration is injected at run time: an ECS task definition (or a Kubernetes manifest) with secrets pulled from Secrets Manager or SSM, and plain environment entries for the rest. The image is built once, tagged with the commit, and promoted from staging to production untouched. The Dockerfile loses 169 lines.
2. A feature flag with a default
Among the arguments: ARG TEXTRACT_ENABLED=false. The external document-validation service, off by default, and off in production, because nobody passed the argument. The application kept running, the validation silently did nothing, and the CPU-heavy fallback ran on every upload. I wrote about what that did to the platform in the previous note. A default in a Dockerfile is a production decision made in a file nobody reads at deploy time. Flags belong in configuration, with no default, so that a missing value fails loudly.
3. Shipping the development dependencies
COPY --from=build /app/node_modules ./node_modules
The build stage needs TypeScript and the Nest CLI, so its node_modules contains them, so the production image contains them: 25 packages, plus their transitive tree, that will never be required at run time. The classic fix is a dedicated stage that installs with the production flag, or prunes after the build:
FROM deps AS prod-deps
RUN yarn workspaces focus --all --production # Yarn Berry; npm: npm prune --omit=dev
and copying node_modules from that stage. Native modules stay compiled, the compilers stay behind, and the test framework does not go to production.
4. chown -R after COPY
COPY --from=build /app/node_modules ./node_modules
COPY --from=build /app/dist ./dist
RUN chown -R nestjs:nodejs /app /home/nestjs
Running as a non-root user is right. Doing it with a recursive chown in a separate instruction is the single most expensive line in the file. Each instruction is a layer; changing the owner of a file in a new layer copies the whole file into that layer. node_modules with Chrome's cache next to it is written to the image twice. The instruction that fixes it costs nothing:
COPY --chown=nestjs:nodejs --from=prod-deps /app/node_modules ./node_modules
COPY --chown=nestjs:nodejs --from=build /app/dist ./dist
5. Health checks and signals that go to the wrong place
The file ends with a HEALTHCHECK and a CMD wrapped in a shell:
HEALTHCHECK --interval=30s … CMD node -e "require('http').get(…)"
ENTRYPOINT ["dumb-init", "--"]
CMD ["sh", "-c", "export MONGO_URI=\"${MONGO_URI:-$MONGODB_URI}\"; node dist/main.js"]
Two small things. ECS does not read a HEALTHCHECK from the image; it only runs the one declared in the task definition, and here the load balancer's target group check is what actually decides. The Dockerfile line is documentation that looks like behaviour. And sh -c "…; node …" makes node a child of sh: dumb-init forwards SIGTERM to the shell, and whether the shell forwards it to Node depends on the shell. Either exec node dist/main.js at the end of the command, or, better, delete the shell entirely and fix the variable name upstream, which is what the MONGO_URI fallback is quietly working around.
Not a trap, but worth naming: the Chrome base image is referenced by a :latest tag. Two builds of the same commit can produce two different images. Tag it with the Puppeteer version, or pin the digest.
The rewrite
Same application, same Chrome, same non-root user. The production stage after the changes above:
FROM deps AS prod-deps
RUN yarn workspaces focus --all --production
FROM node:24-slim AS production
RUN apt-get update && apt-get install -y --no-install-recommends \
dumb-init ca-certificates fonts-liberation libnss3 libgbm1 libasound2 \
# … the Chrome runtime libraries, unchanged …
&& rm -rf /var/lib/apt/lists/*
RUN groupadd -r -g 1001 nodejs && useradd -r -u 1001 -g nodejs nestjs
WORKDIR /app
COPY --chown=nestjs:nodejs --from=prod-deps /app/node_modules ./node_modules
COPY --chown=nestjs:nodejs --from=build /app/dist ./dist
COPY --chown=nestjs:nodejs package.json ./
COPY --chown=nestjs:nodejs --from=chrome /puppeteer-cache /home/nestjs/.cache/puppeteer
USER nestjs
ENV NODE_ENV=production PUPPETEER_CACHE_DIR=/home/nestjs/.cache/puppeteer
EXPOSE 3000
ENTRYPOINT ["dumb-init", "--"]
CMD ["node", "dist/main.js"]
No secrets, no defaults, no shell, no recursive chown, no development tooling. Everything the application needs from its environment arrives through the task definition, which means the same image runs in staging on Monday and in production on Tuesday.
A checklist, in the order the traps bite
- Nothing secret in
ARGorENV. Ifdocker inspectwould embarrass you, it is in the wrong place. - One image per commit, not per environment. Configuration is injected, the artefact is promoted.
- Same C library in every stage that touches native code, and glibc unless you have a reason. Slim over Alpine for Node, Python and Java.
- Lockfile first, source last. The dependency layer should survive a code change.
- Production dependencies only in the final stage. A separate stage, not a hopeful
rm -rf. COPY --chown, neverRUN chown -R. The second one doubles what it touches.- Heavy, stable artefacts get their own image. Browsers, models, SDKs: built when they change, copied otherwise.
- The process is PID 1's direct child. A tiny init and an
exec, nosh -cwrapper. - Pin what you build from. Tags move; digests do not.
Closing
None of the five traps would show up in a benchmark of image sizes, which is why "use Alpine" is such a satisfying and useless piece of advice. The expensive mistakes in a Dockerfile are about what is in the image and when it got there: credentials at build time, defaults that decide production behaviour, a test framework that ships, a layer that copies itself. Read your own Dockerfile the way you would read someone else's pull request. The scars are usually in the comments.
Want a second pair of eyes on a Dockerfile that has grown in production? Work with me.