Chapter 60, Backend
Docker for Python
A container packages the application with everything it needs, so it runs the same on your laptop, in CI, and on the server. Writing a good Dockerfile is a matter of layer order and least privilege.
The files for this chapter are in exemplos/api_pedidos/.
What is an image
A image is a stack of read-only layers, and each instruction in the Dockerfile creates one. A container is a running image. Docker caches each layer and only rebuilds it if the instruction or the files it uses have changed. The practical consequence governs the entire design: what changes rarely goes first, and what changes always (your code) goes last.
The Dockerfile of the orders API
It has two stages. In the first, uv installs the dependencies from the uv.lock (the same versions you tested). In the second, only the result goes to the final image: no uv, no cache, no build tools:
# Estágio 1: instala as dependências com o uv
FROM python:3.13-slim AS construcao
COPY --from=ghcr.io/astral-sh/uv:latest /uv /uvx /bin/
ENV UV_COMPILE_BYTECODE=1 UV_LINK_MODE=copy
WORKDIR /app
# Dependências primeiro: esta camada só é refeita quando o uv.lock muda
COPY pyproject.toml uv.lock README.md ./
RUN --mount=type=cache,target=/root/.cache/uv \
uv sync --locked --no-install-project --no-dev
COPY src ./src
COPY migrations ./migrations
COPY alembic.ini ./
RUN --mount=type=cache,target=/root/.cache/uv \
uv sync --locked --no-dev
# Estágio 2: imagem final, sem o uv e sem ferramentas de construção
FROM python:3.13-slim
RUN useradd --create-home --uid 10001 app
WORKDIR /app
COPY --from=construcao --chown=app:app /app /app
ENV PATH="/app/.venv/bin:$PATH" PYTHONUNBUFFERED=1
USER app
EXPOSE 8000
HEALTHCHECK --interval=15s --timeout=3s --retries=3 \
CMD python -c "import urllib.request,sys; sys.exit(0 if urllib.request.urlopen('http://127.0.0.1:8000/saude').status == 200 else 1)"
CMD ["uvicorn", "api_pedidos.app:criar_app", "--factory", "--host", "0.0.0.0", "--port", "8000"]
Every decision has a reason:
| Decision | Reason |
|---|---|
Copy pyproject.toml and uv.lock before from the code | The dependencies layer is only rebuilt when they change, not with every code change |
uv sync --locked --no-dev | Reproducible (fails if the lock is outdated) and without pytest, mypy or ruff in the production image |
| Two stages (multi-stage) | The final image is smaller and has less attack surface |
useradd and USER app | Running as root inside the container exacerbates any vulnerability |
CMD in list form | The uvicorn becomes the main process and receives the SIGTERM from the orchestrator, shutting down gracefully |
PYTHONUNBUFFERED=1 | Logs are output immediately to standard output, where Docker collects them |
HEALTHCHECK | The orchestrator knows when the application is actually responding |
What is left out of the image
The .dockerignore keeps junk and secrets out of the build context. Without it, the COPY could bring your .env and your .venv into the image:
.venv
__pycache__
.pytest_cache
.mypy_cache
.ruff_cache
.git
.env
*.db
tests
Build and run
docker build -t api-pedidos .
docker run --rm -p 8000:8000 -e API_AMBIENTE=dev api-pedidos
Configuration comes through environment variables (-e), just like chapter 58 taught. The image is the same in all environments.
Docker Compose: the application with the database
A real API needs the database. Compose describes the services, the variables, and the order in which they come up. Here, PostgreSQL is only considered ready when its healthcheck passes, the migration runs once and finishes, and only then does the API come up:
services:
banco:
image: postgres:16
environment:
POSTGRES_USER: app
POSTGRES_PASSWORD: app
POSTGRES_DB: pedidos
volumes:
- dados_pg:/var/lib/postgresql/data
healthcheck:
test: ["CMD-SHELL", "pg_isready -U app -d pedidos"]
interval: 5s
timeout: 3s
retries: 10
migracao:
build: .
command: ["alembic", "upgrade", "head"]
environment:
API_AMBIENTE: prod
API_DATABASE_URL: postgresql+psycopg://app:app@banco:5432/pedidos
depends_on:
banco:
condition: service_healthy
api:
build: .
ports:
- "8000:8000"
environment:
API_AMBIENTE: prod
API_DATABASE_URL: postgresql+psycopg://app:app@banco:5432/pedidos
depends_on:
banco:
condition: service_healthy
migracao:
condition: service_completed_successfully
volumes:
dados_pg:
docker compose up --build
docker compose logs -f api
docker compose down -v
The depends_on with service_healthy and service_completed_successfully solves the classic problem of "the API started before the database". The down -v also deletes the database volume, and without the -v the data survives between runs.
What never to do in a Dockerfile
Copy the
.envto the image, put a password inENVor inARG(they are recorded in the layers and in the history), use the taglatestof the base image in production (the build becomes non-reproducible), run asroot, or install testing tools in the final image. And a container runs one main process: if you need two, they are two services.
Migration is a step of the deploy, not the start of the API
If the API applied migrations on startup, two replicas starting together would try to migrate at the same time. That's why migration is a separate service that runs once before any API replica. In Kubernetes, this role is that of a Job.