Docker images bloat by default. A “Hello world” Python app at 1.2GB is a missed opportunity. The patterns that produce small, fast, secure images are well-known but inconsistently applied. This post is the working playbook.

Multi-stage builds

FROM python:3.13-slim AS builder
WORKDIR /app
RUN pip install uv
COPY pyproject.toml uv.lock ./
RUN uv sync --frozen --no-dev

FROM python:3.13-slim
WORKDIR /app
COPY --from=builder /app/.venv ./.venv
COPY src ./src
ENV PATH=/app/.venv/bin:$PATH
USER 1000:1000
CMD ["python", "-m", "myapp"]

Build deps in stage 1; runtime image only carries what’s needed. ~80% size reduction is typical.

Distroless

FROM python:3.13-slim AS builder
# ... build ...

FROM gcr.io/distroless/python3-debian12
COPY --from=builder /app /app
WORKDIR /app
USER 1000:1000
CMD ["src/main.py"]

Distroless images: no shell, no apt, no curl. Just the runtime + your code. Smaller attack surface, smaller bytes.

For Go binaries: gcr.io/distroless/static or scratch.

Layer caching

Order from least-changing to most-changing:

COPY pyproject.toml uv.lock ./
RUN uv sync --frozen          # cached unless deps change
COPY src ./src                # invalidated on every code change

The dep install is expensive; the code copy is fast. Order matters for build cache hits.

BuildKit cache mounts

# syntax=docker/dockerfile:1.7
FROM python:3.13-slim AS builder
WORKDIR /app
RUN --mount=type=cache,target=/root/.cache/uv \
    --mount=type=bind,source=pyproject.toml,target=pyproject.toml \
    --mount=type=bind,source=uv.lock,target=uv.lock \
    uv sync --frozen --no-dev

--mount=type=cache survives across builds. uv / pip / npm / cargo caches: massive speedup.

For Node:

RUN --mount=type=cache,target=/root/.npm npm ci

For Rust:

RUN --mount=type=cache,target=/usr/local/cargo/registry \
    --mount=type=cache,target=/app/target \
    cargo build --release

.dockerignore

.git
.venv
__pycache__
node_modules
*.pyc
.env
dist
.pytest_cache
target

Build context bloat slows builds and may include secrets. Always have a .dockerignore.

Run as non-root

RUN useradd -u 10001 -m app
USER app

Or use a known-non-root distroless variant. Containers running as root are a security smell.

Secrets

Never bake secrets:

# BAD
ENV DATABASE_URL=postgresql://user:pass@host

Use BuildKit secrets:

RUN --mount=type=secret,id=npm_token \
    NPM_TOKEN=$(cat /run/secrets/npm_token) npm install
docker build --secret id=npm_token,env=NPM_TOKEN .

The secret is mounted during build but not in any layer.

For runtime: env vars from your secret manager / Kubernetes Secrets.

Image scanning

trivy image myapp:latest
grype myapp:latest

Scan in CI; block on high/critical CVEs. See Software Supply Chain Security .

Signing

cosign sign myapp:latest --keyless
cosign verify myapp:latest --certificate-identity ...

Sigstore-based signing. Free; standard. Required for SLSA compliance.

SBOM

syft myapp:latest -o spdx-json > sbom.json

Software Bill of Materials. Required by NIST guidance. Most regs ask for it.

Pinned base images

# BAD: rolling tag
FROM python:3.13

# GOOD: digest-pinned
FROM python:3.13-slim@sha256:abc123...

Otherwise: a base image update can silently change your build.

HEALTHCHECK

HEALTHCHECK --interval=30s --timeout=5s --start-period=10s \
  CMD curl -f http://localhost:8080/healthz || exit 1

Lets orchestrators detect crashes and traffic-manage. K8s prefers explicit probes; Docker Compose / Swarm uses HEALTHCHECK directly.

Small image targets

StackReasonable size
Go binary (scratch)5–20 MB
Rust binary (scratch / distroless)5–25 MB
Python (distroless)80–150 MB
Node (distroless)80–200 MB
Java (distroless)200–400 MB

If yours is 5x larger: investigate.

Common mistakes

1. Single-stage build

The image carries gcc, dev headers, build artifacts forever. Always multi-stage.

2. Latest tag in production

Untraceable. Always tag with version + digest.

3. apt-get update without cleanup

Leaves apt cache in the layer. End with && rm -rf /var/lib/apt/lists/*.

4. Running as root

Container escapes are easier from root. Use a non-root user.

5. Re-copying everything

COPY . . invalidates cache on any change. Be selective.

Production checklist

  • Multi-stage build
  • Distroless or slim runtime
  • Non-root USER
  • BuildKit cache mounts
  • .dockerignore
  • Pinned base image (digest)
  • HEALTHCHECK
  • No secrets baked in
  • Scan in CI (trivy/grype)
  • Sign with cosign
  • SBOM generated
  • Tagged with version

What I’d ship today

For new services:

  • Multi-stage Dockerfile.
  • Distroless runtime.
  • BuildKit cache mounts.
  • GitHub Actions with build, scan, sign, push.
  • Renovate to auto-update base image digests.
  • Kubernetes with explicit probes, resource limits.

Read this next

If you want my Dockerfile templates (Python, Go, Rust, Node) + CI workflow, it’s at rajpoot.dev .


Building something AI-, backend-, or data-heavy and want a second pair of eyes? I do consulting and freelance work — see my projects and ways to reach me at rajpoot.dev .