A Dockerfile decides image size (which decides cold-start time, registry cost), build speed (CI minutes), and security (CVE surface). Get it right; this post is how.

Multi-stage builds

# Build stage
FROM rust:1.85 AS builder
WORKDIR /build
COPY Cargo.toml Cargo.lock ./
COPY src ./src
RUN cargo build --release

# Runtime stage
FROM gcr.io/distroless/cc:nonroot
COPY --from=builder /build/target/release/api /usr/local/bin/api
ENTRYPOINT ["api"]

Build with full toolchain. Runtime with just the binary. Result: ~30 MB image instead of 1.2 GB.

For language specifics: Production HTTP Service in Rust , Modern TypeScript Backend with Hono on Bun , FastAPI + Pydantic v2 + SQLAlchemy 2.0 .

Layer caching

# Bad — code change invalidates dep cache
COPY . .
RUN uv sync

# Good — deps cached separately from code
COPY pyproject.toml uv.lock ./
RUN uv sync --frozen
COPY . .

Order operations from least-changing to most. Deps change rarely; code changes per commit. Caching deps saves CI time.

BuildKit cache mounts

# syntax=docker/dockerfile:1.7
FROM python:3.13-slim
RUN --mount=type=cache,target=/root/.cache/pip \
    pip install -r requirements.txt

--mount=type=cache persists /root/.cache/pip across builds even when the layer reruns. Massively faster.

For Rust:

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

For Bun:

RUN --mount=type=cache,target=/root/.bun/install/cache \
    bun install --frozen-lockfile

Non-root user

FROM gcr.io/distroless/cc:nonroot
USER nonroot:nonroot      # already the default in distroless:nonroot

Or:

FROM debian:bookworm-slim
RUN useradd -r -u 1000 app
USER app

Containers run as root by default. Compromise = root in container. Always non-root.

Distroless images

FROM gcr.io/distroless/cc        # for Rust / C / C++
FROM gcr.io/distroless/python3   # Python
FROM gcr.io/distroless/nodejs22  # Node
FROM gcr.io/distroless/static    # static binary (Go)

Distroless images contain just enough to run; no shell, no package manager, no curl. Smaller. More secure. No surprise CVEs in unused tooling.

The tradeoff: harder to debug live containers. For dev, use a :debug variant; for prod, the bare distroless.

.dockerignore

# .dockerignore
.git
.github
node_modules
__pycache__
*.pyc
target/
dist/
.venv
.env

Keeps secrets and bloat out of the build context. Without it, COPY . pulls in your .git history.

Image scanning in CI

- uses: aquasecurity/trivy-action@master
  with:
    image-ref: my-app:${{ github.sha }}
    severity: HIGH,CRITICAL
    exit-code: 1

Fail the build on high CVEs. See Software Supply Chain Security .

Pin base image versions

FROM python:3.13.0-slim                              # pinned
FROM python:3.13.0-slim@sha256:abc123...             # pinned + hash

FROM python:latest is a recipe for “yesterday it built fine, today it doesn’t.” Pin.

Healthcheck

HEALTHCHECK --interval=30s --timeout=3s \
    CMD curl -f http://localhost:8000/healthz || exit 1

Docker / Kubernetes both honor this. Pair with Health Checks That Don’t Lie .

Common mistakes

1. COPY . . early

Invalidates cache on every code change. Copy deps first.

2. apt-get update without && apt-get install

Cached update becomes stale. Always combined.

3. Multi-stage but copying tools too

COPY --from=builder /build/target ... should copy ONE binary, not the whole target dir.

4. Running as root

Default but wrong. Always USER.

5. Latest tags

Reproducibility goes out the window. Pin.

6. Logs writing inside container

Container fs is ephemeral. Log to stdout; let the orchestrator capture.

What I’d ship today

For a typical Python service:

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

FROM gcr.io/distroless/python3:nonroot
WORKDIR /app
COPY --from=builder --chown=nonroot:nonroot /app/.venv /app/.venv
COPY --chown=nonroot:nonroot . .
ENV PATH="/app/.venv/bin:$PATH"
HEALTHCHECK --interval=30s CMD ["python", "-c", "import urllib.request; urllib.request.urlopen('http://localhost:8000/livez')"]
EXPOSE 8000
ENTRYPOINT ["uvicorn", "app.main:app", "--host", "0.0.0.0", "--port", "8000"]

~150 MB. Non-root. Distroless. Cache-friendly. Builds in <30s warm.

Read this next

If you want my Dockerfile templates per-language, 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 .