FastAPI – Containerising It Properly

October 17, 202613 min readUpdated 8/21/2026

A container is how the application stops depending on your laptop. This lesson builds StayHub's image — multi-stage so the compiler does not ship, non-root, with the settings that decide whether it works at all — and reports the size and build time it actually produced rather than estimating them.

The naive version

FROM python:3.12

WORKDIR /app
COPY . .
RUN pip install -r requirements.txt

CMD ["uvicorn", "app.main:app"]

Six lines, and five separate problems:

  • python:3.12 is about 1 GB. python:3.12-slim is roughly 150.
  • COPY . . before pip install means every source edit reinstalls every dependency.
  • It also copies .venv, .git and .env — the last of which bakes your secrets into a layer.
  • It runs as root.
  • Uvicorn binds 127.0.0.1 by default, so nothing outside the container can reach it.

Every one of those is one line to fix.

Two stages

FROM python:3.12-slim AS builder

RUN apt-get update && apt-get install -y --no-install-recommends \
        build-essential libpq-dev \
    && rm -rf /var/lib/apt/lists/*

ENV VIRTUAL_ENV=/opt/venv
RUN python -m venv $VIRTUAL_ENV
ENV PATH="$VIRTUAL_ENV/bin:$PATH"

WORKDIR /build

The reason for two stages is compilers. psycopg, bcrypt and cryptography may need a toolchain to install, and a toolchain is roughly 350 MB that must not be in the shipped image.

A virtualenv rather than system site-packages, because a whole directory is trivial to COPY into the next stage while system packages are scattered.

Then the ordering that makes rebuilds fast:

# ⚠️ requirements.txt is copied ALONE, before the source. Docker caches per layer and invalidates
# every layer after the first changed one. Copying the app first means each source edit re-runs
# pip install — a minute of downloads for a one-line change. This ordering means dependencies are
# reinstalled only when requirements.txt itself changes.
COPY requirements.txt .
RUN pip install --no-cache-dir --upgrade pip \
    && pip install --no-cache-dir -r requirements.txt

This is the single highest-value line in any Python Dockerfile. Dependencies change rarely and source changes constantly, so the expensive step belongs above the volatile one.

The runtime stage takes the virtualenv and nothing else:

FROM python:3.12-slim AS runtime

RUN apt-get update && apt-get install -y --no-install-recommends \
        libpq5 curl \
    && rm -rf /var/lib/apt/lists/*

ENV VIRTUAL_ENV=/opt/venv
ENV PATH="$VIRTUAL_ENV/bin:$PATH"
COPY --from=builder $VIRTUAL_ENV $VIRTUAL_ENV

libpq5 is the runtime half of libpq-dev — the shared library psycopg loads, without the headers and compiler. That pairing is the general pattern: -dev in the builder, the bare library in the runtime.

The environment variables that matter

# ⚠️ PYTHONUNBUFFERED is not a nicety. Python buffers stdout when it is not a TTY — which is
# exactly the case in a container — so without this, logs appear in `docker logs` in delayed
# chunks, and a process that crashes loses whatever was still in the buffer. The symptom is
# "my container produces no logs", and it is entirely this.
ENV PYTHONUNBUFFERED=1 \
    PYTHONDONTWRITEBYTECODE=1 \
    PIP_NO_CACHE_DIR=1

"My container produces no logs" is almost always this one variable, and the fact that a crashing process loses its buffer means it hides exactly the output you most need.

PYTHONDONTWRITEBYTECODE stops .pyc files being written into the container filesystem, where they are never reused.

And the JSON logging from the last lesson gets switched on here:

ENV STAYHUB_LOG_JSON=true

Not root

# ⚠️ A non-root user. The default is root, and root in the container is root on the host kernel
# for anything that escapes. This costs one line.
RUN useradd --create-home --shell /bin/bash --uid 1000 stayhub
WORKDIR /app

Then copy with ownership, and create the writable directories before dropping privileges:

COPY --chown=stayhub:stayhub alembic.ini pytest.ini ./
COPY --chown=stayhub:stayhub alembic ./alembic
COPY --chown=stayhub:stayhub app ./app
COPY --chown=stayhub:stayhub scripts ./scripts
RUN mkdir -p uploads notifications && chown -R stayhub:stayhub uploads notifications

USER stayhub

The order is the part people get wrong: everything needing root happens before USER, because a non-root process cannot mkdir inside a root-owned WORKDIR. The failure appears at runtime as a permission error on the first upload.

Copying specific directories rather than COPY . . is a second layer of defence — even with a .dockerignore, naming what goes in means a new file at the repository root does not silently join the image.

.dockerignore does two jobs

# ⚠️ Without this file the build context is ~400 MB, almost all of it .venv — and it is uploaded
# to the daemon on EVERY build, before a single instruction runs. A .dockerignore is the single
# biggest build-speed win in a Python project.
#
# It is also a security control. `.env` holds the Stripe secret key; COPY-ing the directory
# without excluding it bakes that key into an image layer, where it survives even if a later
# layer deletes the file.

The second half is the one worth repeating. Deleting a file in a later layer does not remove it from the image — layers are additive, and anybody with the image can read the earlier one. A secret copied in at any point is in there permanently.

.venv/
venv/
__pycache__/
*.pyc

# Secrets. Never in an image.
.env
.env.*
!.env.example

# Runtime output — these are volumes, not image content
uploads/
notifications/

Health and the command

HEALTHCHECK --interval=15s --timeout=5s --start-period=20s --retries=3 \
    CMD curl -fsS http://localhost:8000/health || exit 1

--start-period is the argument people omit. During it, failures do not count toward --retries, which is what stops a container being killed for not being ready while it is still starting.

Then the command, which contains the single most common container mistake in the ecosystem:

CMD ["uvicorn", "app.main:app", "--host", "0.0.0.0", "--port", "8000", "--workers", "4"]
# ⚠️ --host 0.0.0.0, and this is THE container gotcha. Uvicorn's default is 127.0.0.1, which
# inside a container means the container's own loopback. The bind succeeds, the app logs that it
# is running, `docker ps` shows the port published — and every request from the host is refused.
# Nothing anywhere says why.

Every signal says the application is fine. Nothing reports an error. The requests simply do not arrive.

How many workers

# --workers 4: these are separate PROCESSES, and they are what uses more than one CPU core, since
# a single uvicorn process is one event loop on one core no matter how async the code is. Four is
# a starting point for a 2-4 core container; tune it against real traffic, and remember each
# worker carries its OWN SQLAlchemy connection pool — 4 workers × pool_size 5 is 20 connections to
# Postgres, which must be under max_connections.

Two consequences follow from workers being separate processes, and both catch people.

Nothing in memory is shared. An in-process cache, a rate-limit counter, a WebSocket connection registry — each worker has its own. Anything that must be shared belongs in Redis or the database.

Connections multiply. Workers × (pool_size + max_overflow) × containers, all against one max_connections. This is the number that turns a successful scale-up into FATAL: sorry, too many clients already.

And no --reload in an image. It watches the filesystem, costs CPU forever, and buys nothing when the code cannot change.

What it actually produced

Built and inspected on 2026-08-21:

build time          45s (cold)
image size          304MB
runs as             stayhub (non-root)
.env in the image   absent
gcc in the runtime  absent
healthcheck         healthy

Each of those was checked rather than assumed:

docker images stayhub-api:local --format '{{.Repository}}:{{.Tag}}  {{.Size}}'
docker run --rm stayhub-api:local whoami
docker run --rm stayhub-api:local sh -c 'test -f /app/.env && echo LEAKED || echo absent'
docker run --rm stayhub-api:local sh -c 'which gcc || echo "gcc absent"'

Those four commands are worth running against any image you build. "The Dockerfile says non-root" and "the container runs as non-root" are different claims, and only one of them is checkable.

In compose

  api:
    build:
      context: ./stayhub-fastapi-backend
    image: stayhub-api:local
    container_name: stayhub-api
    profiles: ["api"]
    restart: unless-stopped
    depends_on:
      postgres:
        condition: service_healthy
      elasticsearch:
        condition: service_healthy
    ports:
      - "8000:8000"

condition: service_healthy rather than a bare depends_on. The plain form waits for the container to start, not to be usable, so the API begins connecting to a Postgres that is still initialising.

The service is behind a profile, which is a pattern worth borrowing:

  # ⚠️ It is behind a profile because the documented workflow runs uvicorn on the HOST with
  # --reload, which is far better for development. Both bind port 8000, so running this while the
  # host uvicorn is up fails with "port is already allocated". That is the intended, obvious
  # failure — the alternative was a second port that nothing else in the project knows about.
docker compose up -d                    # backing services only — the default, unchanged
docker compose --profile api up -d      # backing services PLUS the API in a container

The networking trap, again

      # ⚠️ `postgres:5432` and `elasticsearch:9200` — the SERVICE NAME and the container's own
      # port. The 5433 in the host workflow is a host-side publish only and means nothing inside
      # this network. Same trap the hasura service documents above; it catches everyone once.
      STAYHUB_DATABASE_URL: postgresql+psycopg://stayhub:stayhub@postgres:5432/stayhub

Inside the compose network, containers reach each other by service name on the container's own port. The published port — the left half of "5433:5432" — exists only on the host. Using it from another container fails with a connection error that looks like the database being down.

The mirror image applies to CORS:

      # `localhost` here is the BROWSER's localhost — the Vite dev servers on the host machine —
      # not the container's. CORS is judged by what the browser sends in `Origin`.
      STAYHUB_CORS_ORIGINS: '["http://localhost:5174","http://localhost:5175"]'

Container-to-container uses service names; browser-facing configuration uses what the browser sees. Getting those the wrong way round is the second-most-common compose mistake.

Making the build faster still

Two techniques beyond layer ordering, both worth knowing once builds start feeling slow.

A cache mount keeps pip's download cache between builds without putting it in the image:

RUN --mount=type=cache,target=/root/.cache/pip \
    pip install -r requirements.txt

The cache lives on the builder, not in a layer, so a changed requirements file re-resolves but does not re-download everything.

A lock file matters more than either. requirements.txt with pinned versions is the minimum, and it is what makes a build reproducible — fastapi>=0.100 means today's image and next month's image contain different code with no record of the change. Pin exactly, and upgrade deliberately:

fastapi==0.115.5
uvicorn[standard]==0.32.1
pydantic[email]==2.10.3
SQLAlchemy[asyncio]==2.0.36

Note the extras are part of the pin. SQLAlchemy and SQLAlchemy[asyncio] install different things, and the difference only shows up at runtime when an async query fails at close time.

Building for the right architecture

An image built on an Apple Silicon laptop is linux/arm64. Most clusters are linux/amd64. Pushing the first to the second produces a container that dies at import with an error about an incompatible binary — from a compiled dependency like pydantic-core, not from anything you wrote.

docker build --platform linux/amd64 -t stayhub-api:latest .
docker buildx build --platform linux/amd64,linux/arm64 -t stayhub-api:latest --push .

The second builds both and pushes a manifest, so the right one is pulled automatically. Emulated cross-building is slow; a CI runner on the target architecture is faster if you have one.

The check that catches it before deploy:

docker run --rm stayhub-api:local python -c "import pydantic_core; print('ok')"

Tags, and why `latest` is a trap

docker build -t registry/stayhub-api:$(git rev-parse --short HEAD) .
docker tag registry/stayhub-api:$(git rev-parse --short HEAD) registry/stayhub-api:latest

Tag with the commit, always. latest is a moving pointer, so "the version running in production" becomes unanswerable and a rollback has nothing to roll back to. A commit-tagged image is a rollback target that still exists next week.

The related habit is not rebuilding between environments. Build once, promote the same digest from staging to production. An image rebuilt for production is a different image, and the thing you tested is not the thing you shipped.

Making it smaller, if it matters

304 MB is fine for most purposes — layers are cached, so a redeploy transfers only what changed. If it does matter:

ChangeEffect
slim instead of the full image~1GB → ~150MB base
Multi-stage, dropping the toolchain−350MB
--no-install-recommendstens of MB
rm -rf /var/lib/apt/lists/* in the same RUN~40MB
Alpine instead of slimsmaller, and often slower to build

Two of those need a caveat. Cleaning apt lists must happen in the same RUN — a separate one adds a layer removing files the previous layer already committed, so the image does not shrink at all. And Alpine uses musl rather than glibc, so many Python wheels have no prebuilt binary and get compiled from source: a smaller image bought with much longer builds and occasional runtime surprises. slim is the better default for Python.

Developing against the container

The image is built for production, which makes it a poor development environment — no reload, and a rebuild per change. The usual compromise is a second stage:

FROM runtime AS dev

USER root
RUN pip install -r requirements-dev.txt
USER stayhub

CMD ["uvicorn", "app.main:app", "--host", "0.0.0.0", "--port", "8000", "--reload"]
  api:
    build:
      context: ./stayhub-fastapi-backend
      target: dev
    volumes:
      - ./stayhub-fastapi-backend/app:/app/app:ro

target: dev builds the extra stage; the bind mount puts your source over the copied one so --reload sees edits. Read-only, so nothing the container writes can surprise you on the host.

StayHub does not do this — its documented workflow runs uvicorn directly on the host, which is simpler and faster still. The container version earns its place when the application needs something awkward to install locally, or when "works on my machine" has already cost a day.

Debugging a container that will not start

Four commands, in order.

docker logs stayhub-api                  # 1. what did it say on the way down
docker run --rm -it stayhub-api:local sh # 2. poke around inside the image
docker inspect stayhub-api               # 3. env, mounts, health, exit code
docker exec stayhub-api env              # 4. what it actually received

The fourth catches more than it should. A variable that is set in your shell, or in a .env the compose file does not reference, is simply not there — and with a default in the settings model, the application starts and misbehaves rather than complaining.

An empty log with a non-zero exit is nearly always the buffering problem: without PYTHONUNBUFFERED=1 the traceback that explains the crash was still in the buffer when the process died.

And if the container is running but unreachable, check the bind address before anything else. Inside the container:

docker exec stayhub-api sh -c 'curl -s localhost:8000/health'

If that works and the host cannot reach it, the process is bound to the container's loopback and the fix is --host 0.0.0.0. If it fails there too, the application is genuinely not serving and the logs are the place to look.

What a good image is measured by

Four properties, roughly in order of how much they matter.

It contains what it needs and nothing else. No compiler, no source control, no secrets, no test fixtures. Each of those is attack surface, size, or both.

It is reproducible. Pinned dependencies and a commit tag mean the image running in production can be rebuilt identically and identified exactly. Unpinned versions make "the same image" a claim nobody can verify.

It fails fast and loudly. A missing secret should stop the container from starting, not produce a service that runs with a default. That is a settings decision rather than a Dockerfile one, but the container is where it shows up.

It rebuilds quickly. Layer ordering, a .dockerignore, and a cache mount. This one is about how often people are willing to rebuild, which decides how much of the rest is actually maintained.

Notably absent from that list: being as small as possible. 304 MB versus 250 MB is rarely worth the Alpine build problems, and layer caching means a redeploy transfers only the layers that changed — usually just the source.

What stays outside the image

Secrets. Environment variables at runtime, or a secrets manager. Never ENV STAYHUB_JWT_SECRET=..., which is readable by anyone with the image.

State. A container filesystem does not survive a restart and is not shared between replicas, so uploads need a volume — or, in production, object storage:

    volumes:
      # Uploads must outlive the container. Without this, every restart silently loses every photo
      # a host ever added — the container filesystem is not storage.
      - stayhub-uploads:/app/uploads

Migrations. Tempting to run alembic upgrade head in the entrypoint, and wrong once there is more than one replica: they all start at once and race. Migrations are a deploy step, covered next.

Next: getting it into production — what sits in front of this image, how to roll it out without dropping requests, and what to check when it works locally and not in the cluster.