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
| Stack | Reasonable 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 .