Why this matters
A platform team never supports just one language. The Java pattern generalises: a fat stage that builds, a thin stage that runs. What changes per language is what "build" leaves behind and what the runtime really needs - and each language has one trap that shows up as a confusing error.
What you need to know already: multi-stage builds (two lessons ago), cache ordering and lockfiles (the cache lesson), PID 1 and SIGTERM (previous lesson).
Node
Node.js runs JavaScript on a server. npm installs its packages into node_modules, reading package.json (what you depend on) and package-lock.json (the lockfile: exact versions). devDependencies are packages only needed to build and test (TypeScript, test runners), not to run. TypeScript is JavaScript with types; tsc compiles it to plain JavaScript in dist/.
FROM node:22 AS build
WORKDIR /app
COPY package.json package-lock.json ./
RUN npm ci # all deps: typescript, jest, eslint...
COPY . .
RUN npm run build # tsc -> dist/
FROM node:22-slim
ENV NODE_ENV=production
WORKDIR /app
COPY package.json package-lock.json ./
RUN npm ci --omit=dev # runtime deps only
COPY --from=build /app/dist ./dist
USER node
CMD ["node","dist/server.js"]
node:22 1.1GB buildpack-deps: compilers, git, python - build stage only
node:22-slim 226MB Debian slim + node
node:22-alpine 153MB musl
Details that decide the result:
npm ci, notnpm install: it installs exactly the lockfile, fails if package.json and the lock disagree, and refuses to run without a lockfile:npm error The \npm ci\command can only install with an existing package-lock.json. Commit the lockfile.--omit=devdrops devDependencies - usually most ofnode_modules. (--productionis the older spelling of the same thing.)- The official node images ship a
nodeuser (uid 1000). Use it. - SIGTERM: node has no default handler, so as PID 1 it ignores
docker stop(previous lesson). Addprocess.on('SIGTERM', ...)or use--init/tini. CMD ["npm","start"]puts npm in as PID 1, and npm does not forward signals reliably. Runnodedirectly.
Python
pip installs Python packages, listed in requirements.txt. A virtualenv (venv) is a self-contained folder with its own Python and packages - which makes it trivially copyable between stages.
FROM python:3.12-slim AS build
RUN python -m venv /opt/venv
ENV PATH="/opt/venv/bin:$PATH"
COPY requirements.txt .
RUN pip install --no-cache-dir -r requirements.txt
FROM python:3.12-slim
COPY --from=build /opt/venv /opt/venv
ENV PATH="/opt/venv/bin:$PATH" PYTHONUNBUFFERED=1
WORKDIR /app
COPY app.py .
RUN useradd -r -u 1001 app
USER app
CMD ["gunicorn","-b","0.0.0.0:8000","app:app"]
python -m venv /opt/venvcreates the venv; putting itsbinfirst onPATHmakespipandpythonuse it.--no-cache-dir: pip otherwise keeps its downloads in/root/.cache/pip- in the layer, often as big as the packages themselves.- Some packages contain C code and need
gcc(the C compiler) and header files to install -psycopg2(a database driver) rather than the prebuiltpsycopg2-binary. That is exactly what the build stage is for: installgcc libpq-devthere, and only the finished venv reaches the runtime. PYTHONUNBUFFERED=1makesprintand logging reachdocker logs(the container's output, likejournalctl -ufor a unit) immediately instead of sitting in a buffer until the process exits.gunicornis a Python web server;-b 0.0.0.0:8000binds all interfaces on port 8000 (Ch 9).useradd -r -u 1001 appcreates a system user with uid 1001.python:3.12(the full image) is about 1GB. Use-slimfor runtime.
Go: the best case
Go compiles a program into one binary (a single executable file). The runtime stage can be almost nothing:
FROM golang:1.23 AS build
WORKDIR /src
COPY go.mod go.sum ./
RUN go mod download
COPY . .
RUN CGO_ENABLED=0 go build -ldflags="-s -w" -o /out/pinger .
FROM gcr.io/distroless/static-debian12:nonroot
COPY --from=build /out/pinger /pinger
ENTRYPOINT ["/pinger"]
$ docker images pinger
REPOSITORY TAG IMAGE ID CREATED SIZE
pinger latest 4c1d2e3f4a5b 5 seconds ago 11.7MB
go.mod/go.sum are Go's dependency list and lockfile. go build -o names the output file; -ldflags="-s -w" strips the symbol table and debug info. distroless/static is 2MB: CA certificates (Ch 9, TLS), an /etc/passwd with a nonroot user, and timezone data. Plus a 9.7MB binary.
The CGO trap. Most programs rely on the system's C library - glibc on Ubuntu and Debian - loaded when the program starts. A statically linked binary has everything it needs built in; a dynamically linked one needs the library and a small dynamic loader (/lib/ld-linux-aarch64.so.1) to be on disk. CGO is Go's way of calling C code; in the golang image it is on by default, and using Go's net or net/http then produces a dynamically linked binary. Copy that into an image without glibc and it cannot start:
# pinger:cgo = the cgo build of the mission below
docker run --rm pinger:cgo
exec /pinger: no such file or directory
The file exists; the loader it names does not. CGO_ENABLED=0 builds a static binary. (If you genuinely need cgo, use gcr.io/distroless/base-debian12, which has glibc.)
scratch vs distroless/static. FROM scratch is literally empty: no CA certificates (every HTTPS call fails with x509: certificate signed by unknown authority - the certificate chain from Ch 9 cannot be checked), no /etc/passwd (so no named user), no timezone data. distroless static adds exactly those for 2MB. Prefer it.
Same four questions, every language
- What does the build need that the runtime does not? That is the build stage.
- Where does the package manager put its cache? Keep it out of layers (
--no-cache-dir,--no-cache, a cache mount). - Which user does the runtime base provide? Use it.
- Who is PID 1, and does it handle SIGTERM?
What you can now do
- Write multi-stage Dockerfiles for Node, Python and Go.
- Recognise the CGO
no such file or directoryand the scratchx509errors. - Ask the four questions of any language's image.