Cloud 01 — a multi-stage Dockerfile, cross-built for x86

An M-series Mac builds arm64 images natively — and every cheap Fargate task, Lambda, or EC2 instance you'll actually deploy to runs amd64. This is the first place that mismatch bites, and it costs nothing to hit it now: docker buildx cross-compiles for the target architecture right on your laptop, under QEMU emulation, with no cloud account involved at all. The second half of the lesson is a habit that has nothing to do with architecture — a build stage full of compilers and headers should never be the image you ship.

runs entirely on your Mac$0 multi-stage buildbuildx / QEMU cloud-01

What this creates

Nothing leaves your machine. This guide builds two local Docker images — one the naive way, one the way you'll actually ship — so you can compare them directly. Pick a build below; the readout recomputes the way every other cloud guide's cost readout does, except here there's no dollar cost to show — the thing worth watching is image size, build time, and layer count. The numbers are illustrative (approx.; your docker images will differ by a few tens of MB), the ratio is not.

Preconditions

This guide never touches AWS and never spends a cent, so the usual first line — "cloud-02's zero-spend budget alarm exists" — does not apply yet: cloud-02 is the next cloud item and the first thing to do before any item that does touch AWS. What you do need on the Mac:

Do it

0. A one-endpoint FastAPI service to have something real to build — two files:

# requirements.txt  (pin exact versions in a real project: pip freeze > requirements.txt)
fastapi>=0.115
uvicorn[standard]>=0.30
# app/main.py
from fastapi import FastAPI

app = FastAPI()


@app.get("/health")
def health():
    return {"ok": True}

1. .dockerignore — keep the build context small and keep secrets/tests out of any layer:

# .dockerignore
.venv
__pycache__/
*.pyc
.git
.env
tests/
.pytest_cache/
.mypy_cache/
*.md

2. The multi-stage Dockerfile — a builder stage with the compiler toolchain some Python packages need to build from source, and a slim runtime stage that copies out only the installed packages and the app:

# syntax=docker/dockerfile:1
# ---- builder: gcc + headers so any source-only wheel can compile -- none of this ships ----
FROM python:3.12-slim AS builder
RUN apt-get update && apt-get install -y --no-install-recommends build-essential \
 && rm -rf /var/lib/apt/lists/*
WORKDIR /build
COPY requirements.txt .
RUN pip install --no-cache-dir --prefix=/install -r requirements.txt

# ---- runtime: only the installed packages and the app code land here ----
FROM python:3.12-slim AS runtime
RUN useradd --create-home --uid 1000 appuser
WORKDIR /app
COPY --from=builder /install /usr/local
COPY app/ ./app/
USER appuser
EXPOSE 8000
HEALTHCHECK --interval=30s --timeout=3s --start-period=5s --retries=3 \
  CMD python -c "import urllib.request,sys; sys.exit(0 if urllib.request.urlopen('http://localhost:8000/health').status==200 else 1)"
CMD ["uvicorn", "app.main:app", "--host", "0.0.0.0", "--port", "8000"]

3. The naive comparison point, Dockerfile.single — one FROM, so the toolchain, pip's cache and everything COPY . . drags in all stay in the image that ships:

# Dockerfile.single -- do NOT ship this; it exists to be measured against
FROM python:3.12-slim
RUN apt-get update && apt-get install -y build-essential
WORKDIR /app
COPY . .
RUN pip install -r requirements.txt
EXPOSE 8000
CMD ["uvicorn", "app.main:app", "--host", "0.0.0.0", "--port", "8000"]

4. Build both ways and compare — the single stage, the real one, then the cross-build:

# the naive comparison point
docker build -t myservice:single -f Dockerfile.single .

# the multi-stage build, native arch
docker build -t myservice:native .

# one-time: a builder instance that can actually target another platform
docker buildx create --name xbuilder --use
docker buildx inspect --bootstrap

# cross-build for the x86_64 hosts Fargate/ECS/Lambda actually run on.
# --load puts the result in the local image store; it only works for ONE platform --
# a multi-platform build (--platform linux/amd64,linux/arm64) needs --push to a registry instead.
docker buildx build --platform linux/amd64 -t myservice:amd64 --load .

5. check.sh — run this against myservice:amd64 before you trust any of the numbers above:

#!/usr/bin/env bash
set -euo pipefail
IMAGE="${1:-myservice:amd64}"

SIZE_BYTES=$(docker image inspect "$IMAGE" --format='{{.Size}}')
SIZE_MB=$((SIZE_BYTES / 1024 / 1024))
echo "image size: ${SIZE_MB} MB"
[ "$SIZE_MB" -lt 250 ] || { echo "FAIL: image is ${SIZE_MB} MB, want < 250 MB"; exit 1; }

USR=$(docker image inspect "$IMAGE" --format='{{.Config.User}}')
[ -n "$USR" ] && [ "$USR" != "root" ] || { echo "FAIL: image has no non-root USER set"; exit 1; }

HC=$(docker image inspect "$IMAGE" --format='{{json .Config.Healthcheck}}')
[ "$HC" != "null" ] || { echo "FAIL: no HEALTHCHECK set"; exit 1; }

ARCH=$(docker run --rm --platform linux/amd64 "$IMAGE" python -c "import platform; print(platform.machine())")
[ "$ARCH" = "x86_64" ] || { echo "FAIL: expected x86_64 under emulation, got ${ARCH}"; exit 1; }

echo "OK: ${SIZE_MB} MB, user ${USR}, healthcheck present, arch ${ARCH}"

Verify

Teardown

Nothing here costs money to leave running, but disk space isn't free and a stray buildx builder is easy to forget:

# remove the images this guide built
docker rmi myservice:single myservice:native myservice:amd64

# remove the cross-platform builder instance
docker buildx rm xbuilder

# reclaim any dangling layers left behind by the builds above
docker image prune -f

After teardown: nothing remains anywhere — no AWS resource was ever created, so there is nothing to check in Cost Explorer. The only thing teardown reclaims is local disk space.

This is self-attestation — the site cannot see your Docker daemon, so checking the box and pressing the button is you telling The Path you actually ran it.

Takeaways: a single-stage Dockerfile ships everything it took to build the image, not just what it takes to run it — a multi-stage build throws the builder stage away and keeps only the runtime layer. Cross-architecture is a --platform flag and a buildx builder instance, not a different Mac. Both lessons matter together: the ECR push in a later cloud item will refuse an arm64 image on purpose, and the smaller runtime image is what makes that push and every cold start after it faster.