lamine.cloud ← back
// writing · containers · docker

Optimising a Docker image with a real Dockerfile: stages, why not Alpine, and the traps

Mouhamadou Lamine Gueye · August 2026 · 11 min read · English

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

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:

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:

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

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.

written late at night, watched over by Nox · ← lamine.cloud · more writing