Advanced Docker#

A 1.2 GB image that rebuilds from scratch on every code change is a build-system bug. Multi-stage builds and correct layer order fix both size and speed.

โฑ ~10 min read ยท ~20 min hands-on ๐Ÿ”— needs: Docker & Compose ยท GitHub Actions Advanced

You can already write a Dockerfile. Production adds three requirements: small (fast pulls, less to attack), cached (rebuild in seconds), and safe (no root, no secrets baked in).

Try it in 5 minutes โ€” multi-stage + correct layer order#

# syntax=docker/dockerfile:1

# ---- build stage: toolchain lives here and never ships ----
FROM python:3.13-slim AS builder
COPY --from=ghcr.io/astral-sh/uv:latest /uv /usr/local/bin/uv
WORKDIR /app

# Dependencies FIRST, in their own layer. Code changes won't invalidate this.
COPY pyproject.toml uv.lock ./
RUN uv sync --frozen --no-dev --no-install-project

# Now the source. Edits below this line only rebuild from here down.
COPY . .
RUN uv sync --frozen --no-dev

# ---- runtime stage: only what's needed to run ----
FROM python:3.13-slim
WORKDIR /app

# Never run as root.
RUN useradd --create-home --uid 1000 app
COPY --from=builder --chown=app:app /app /app
USER app

ENV PATH="/app/.venv/bin:$PATH"
EXPOSE 8000
HEALTHCHECK --interval=30s --timeout=3s CMD python -c "import urllib.request;urllib.request.urlopen('http://localhost:8000/health')"
CMD ["uvicorn", "main:app", "--host", "0.0.0.0", "--port", "8000"]

โœ… Two wins in one file: build tools never reach the final image, and because dependencies are copied before source, editing a .py file rebuilds in seconds instead of minutes.

The layer-cache rule#

Docker caches each layer and invalidates every layer after the first change. So order from least to most frequently changed:

1. Base image           โ† changes rarely
2. System packages      โ† changes rarely
3. Dependency manifests โ† changes occasionally   โ† install deps HERE
4. Application source   โ† changes constantly

COPY . . before installing dependencies is the single most common Dockerfile mistake: every one-character edit re-downloads the whole dependency tree.

Add a .dockerignore or you’ll ship your .git, .venv, and secrets โ€” and bust the cache constantly:

.git
.venv
__pycache__/
*.pyc
.env
data/

Secrets: never COPY, never ARG#

Anything added to a layer stays in the image history, even if a later layer deletes it. docker history reveals it. Use BuildKit mounts for build-time secrets:

RUN --mount=type=secret,id=pip_token \
    PIP_TOKEN=$(cat /run/secrets/pip_token) uv sync --frozen
docker build --secret id=pip_token,env=PIP_TOKEN .

Runtime secrets come from the environment or a secret manager โ€” never the image.

Smaller and safer#

TechniqueEffect
-slim baseHundreds of MB smaller than the full image
Multi-stageCompilers/headers never ship
Distroless / AlpineSmaller still; Alpine’s musl can break Python wheels โ€” test
Non-root USERContainer escape doesn’t hand over root
Pin base tags/digestsReproducible builds; no surprise upgrades
Scan imagesdocker scout cves or Trivy in CI

Cache Docker builds in CI#

Without this, CI rebuilds everything every time:

      - uses: docker/setup-buildx-action@v3
      - uses: docker/build-push-action@v6
        with:
          push: true
          tags: ghcr.io/${{ github.repository }}:${{ github.sha }}
          cache-from: type=gha
          cache-to: type=gha,mode=max

When it fails#

SymptomCauseFix
Every build reinstalls depsCOPY . . before installCopy manifests first
Image is over 1 GBFull base + build tools shipped-slim + multi-stage
Permission denied after USERFiles owned by rootCOPY --chown=app:app
Secret visible in docker historyARG/COPY for secretsBuildKit --mount=type=secret
Works locally, fails on the serverArchitecture mismatch (arm64 vs amd64)docker buildx --platform linux/amd64
Alpine build fails on a Python depmusl vs glibc wheelsUse -slim instead

Your turn (โ‰ˆ20 min)#

  1. Containerise a FastAPI app with the Dockerfile above; note the final image size.
  2. Change one line of Python and rebuild โ€” time it. Then move COPY . . above the dependency install and rebuild again. Compare.
  3. Confirm whoami inside the container isn’t root.
  4. Run docker scout cves (or Trivy) and fix or record the findings.
  5. Add the buildx cache to a workflow and compare cold vs warm CI builds.

Checklist#

  • I use multi-stage builds so build tools never ship.
  • I order layers least- to most-frequently-changed.
  • I keep a .dockerignore.
  • I run as a non-root USER.
  • I never bake secrets into layers.
  • I cache Docker layers in CI and scan images for CVEs.

Go deeper#