The general techniques from previous chapters apply to every language. But each runtime has its own package ecosystem, build toolchain, and deployment artifact — and each has specific optimisations that make a significant difference. This chapter covers four major runtimes: Python, Node.js, Go, and Java.
Python is the language where Docker image bloat is most common, because the full python:3.x base image ships with gcc, make, and hundreds of development packages that no production application needs.
python:3.12-slim-bookwormUse the slim bookworm variant for production. It provides glibc (required for binary wheels from PyPI), Python, and almost nothing else. Alpine is tempting but almost always causes problems with packages that have C extensions (psycopg2, numpy, pillow, cryptography) because PyPI wheels are compiled for glibc, not musl.
FROM python:3.12-slim-bookworm AS builder
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-bookworm
COPY --from=builder /opt/venv /opt/venv
ENV PATH="/opt/venv/bin:$PATH"
COPY app/ /app
USER nobody
CMD ["python", "-m", "app"]
The venv is self-contained: it includes the interpreter path, all packages, and their metadata. Copying it to the runtime stage is clean and predictable.
.pyc filesPython compiles .py files to .pyc bytecache on first import, writing to __pycache__. In a read-only container filesystem (--read-only flag), this write will fail. Pre-compile during the build:
RUN python -m compileall /app
This also slightly reduces startup time by skipping the compilation step at runtime.
| Image | Typical size |
|---|---|
python:3.12 (full) |
~1.0 GB |
python:3.12-slim-bookworm |
~45 MB |
| slim + typical web app deps | 80–150 MB |
| slim + multi-stage + venv copy | 70–120 MB |
Full Dockerfile: code/dockerfiles/python_multistage.Dockerfile
Node.js images can grow large quickly because node_modules often contains hundreds of packages, including development-only tools.
FROM node:20-alpine AS builder
WORKDIR /app
COPY package*.json ./
RUN npm ci
COPY . .
RUN npm run build
FROM node:20-alpine
WORKDIR /app
COPY --from=builder /app/dist ./dist
COPY package*.json ./
RUN npm ci --omit=dev
USER node
CMD ["node", "dist/index.js"]
The key: the runtime stage installs only production dependencies (--omit=dev) and copies only the compiled output (dist/). The full node_modules from the builder stage — including TypeScript, ESLint, webpack, and other dev tools — never reaches the runtime image.
For frontend-serving Node applications, consider bundling with esbuild or webpack. A bundled server often has zero node_modules at runtime:
FROM node:20-alpine AS builder
RUN npm ci && npm run bundle
FROM node:20-alpine
COPY --from=builder /app/bundle.js /app/bundle.js
CMD ["node", "/app/bundle.js"]
node:20-alpine is the most common choice. Alpine’s musl libc rarely causes problems for pure-JS packages; issues arise mainly with native addons. If your project uses native addons (sharp, bcrypt, canvas), use node:20-slim instead.
Full Dockerfile: code/dockerfiles/node_multistage.Dockerfile
Go is the ideal Docker citizen: it compiles to a single static binary with no runtime dependencies. The resulting images can be as small as a few megabytes.
FROM golang:1.22-alpine AS builder
WORKDIR /app
COPY go.mod go.sum ./
RUN go mod download
COPY . .
RUN CGO_ENABLED=0 GOARCH=amd64 \
go build -ldflags="-s -w" -o server .
FROM scratch
COPY --from=builder /app/server /server
ENTRYPOINT ["/server"]
Key flags:
CGO_ENABLED=0 — disables CGo, producing a fully static binary with no libc dependency. Required for FROM scratch.-ldflags="-s -w" — -s strips the symbol table, -w strips DWARF debug info. Typically reduces binary size by 25–35%.go mod download before COPY . — caches the module download layer separately from the source layer.If you require a shell for health checks, init scripts, or debugging, use Alpine instead of scratch:
FROM alpine:3.19
RUN apk add --no-cache ca-certificates
COPY --from=builder /app/server /server
ENTRYPOINT ["/server"]
This adds ~5 MB but gives you a full shell environment.
FROM scratch has no TLS certificates. If your binary makes HTTPS connections, copy the system certificates:
FROM alpine:3.19 AS certs
RUN apk add --no-cache ca-certificates
FROM scratch
COPY --from=certs /etc/ssl/certs/ca-certificates.crt /etc/ssl/certs/
COPY --from=builder /app/server /server
ENTRYPOINT ["/server"]
Full Dockerfile: code/dockerfiles/go_scratch.Dockerfile
Java images are inherently larger than Go or Python because the JVM itself is substantial. The primary lever is using the JRE (runtime environment) instead of the JDK (development kit), and using Alpine where possible.
The JDK includes the compiler (javac), jshell, jlink, debug tools, and headers — none of which belong in a production runtime. Use the JRE:
FROM eclipse-temurin:21-jdk-alpine AS builder
WORKDIR /app
COPY . .
RUN ./mvnw -q package -DskipTests
FROM eclipse-temurin:21-jre-alpine
WORKDIR /app
COPY --from=builder /app/target/*.jar app.jar
EXPOSE 8080
USER nobody
ENTRYPOINT ["java", "-jar", "app.jar"]
eclipse-temurin is the Eclipse Foundation’s distribution of OpenJDK, widely regarded as the best community-maintained option. The 21-jre-alpine variant is approximately 180 MB — compared to over 400 MB for 21-jdk.
jlinkFor applications that do not use the full Java standard library, jlink can produce a minimal custom JRE containing only the modules your application actually uses:
FROM eclipse-temurin:21-jdk AS builder
# ... build the jar ...
RUN jlink --add-modules $(jdeps --ignore-missing-deps \
--print-module-deps target/app.jar) \
--output /custom-jre --no-header-files --no-man-pages
FROM debian:bookworm-slim
COPY --from=builder /custom-jre /opt/jre
COPY --from=builder /app/target/app.jar /app/app.jar
ENV PATH="/opt/jre/bin:$PATH"
ENTRYPOINT ["java", "-jar", "/app/app.jar"]
A jlink-based JRE for a typical Spring Boot application is typically 50–80 MB, compared to 180 MB for the full JRE.
For maximum reduction, GraalVM’s native-image compiles Java to a native binary, eliminating the JVM entirely:
Final image size: 50–100 MB (comparable to Python slim)
Startup time: milliseconds instead of seconds
Tradeoff: complex build, limited reflection, longer compile time
This is worth investigating for microservices and serverless deployments where startup time matters, but requires application-level changes to work reliably.
Full Dockerfile: code/dockerfiles/java_jre.Dockerfile
slim-bookworm, copy a virtualenv from a builder stage, avoid Alpine for C extension packages.--omit=dev) in the runtime stage.CGO_ENABLED=0 -ldflags="-s -w" and deploy to FROM scratch; add Alpine if you need a shell.eclipse-temurin:21-jre-alpine is a good default; jlink for further reduction.| ← Chapter 6: Build Cache and BuildKit | Table of Contents | Chapter 8: Analyzing Images → |