Skip to content

    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:

    exemplos/api_pedidos/Dockerfile
    # 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:

    DecisionReason
    Copy pyproject.toml and uv.lock before from the codeThe dependencies layer is only rebuilt when they change, not with every code change
    uv sync --locked --no-devReproducible (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 appRunning as root inside the container exacerbates any vulnerability
    CMD in list formThe uvicorn becomes the main process and receives the SIGTERM from the orchestrator, shutting down gracefully
    PYTHONUNBUFFERED=1Logs are output immediately to standard output, where Docker collects them
    HEALTHCHECKThe 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:

    exemplos/api_pedidos/.dockerignore
    .venv
    __pycache__
    .pytest_cache
    .mypy_cache
    .ruff_cache
    .git
    .env
    *.db
    tests
    

    Build and run

    Terminal
    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:

    exemplos/api_pedidos/compose.yaml
    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:
    
    Terminal
    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 .env to the image, put a password in ENV or in ARG (they are recorded in the layers and in the history), use the tag latest of the base image in production (the build becomes non-reproducible), run as root, 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.