Phase 5: Deploy & Ship

Production Deployment

3 min read

You have built TaskFlow from the ground up -- models, migrations, endpoints, auth, tests. Now it is time to package it into a production-ready container and ship it.

Multi-Stage Docker Builds

A multi-stage build separates the build environment (where you install dependencies and compile) from the runtime environment (what actually runs in production). This produces smaller, more secure images.

# Stage 1: Builder -- install dependencies
FROM python:3.13-slim AS builder

WORKDIR /app

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

COPY requirements.txt .
RUN pip install --no-cache-dir --prefix=/install -r requirements.txt

# Stage 2: Production -- slim runtime
FROM python:3.13-slim AS production

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

# Non-root user for security
RUN groupadd -r taskflow && useradd -r -g taskflow taskflow

WORKDIR /app

# Copy only installed packages from builder
COPY --from=builder /install /usr/local
COPY . .

RUN chown -R taskflow:taskflow /app
USER taskflow

HEALTHCHECK --interval=30s --timeout=5s --retries=3 \
    CMD curl -f http://localhost:8000/health || exit 1

EXPOSE 8000
CMD ["gunicorn", "app.main:app", "-w", "4", "-k", "uvicorn.workers.UvicornWorker", "--bind", "0.0.0.0:8000"]

Key principles:

PrincipleWhy It Matters
Multi-stage buildBuilder stage is discarded; final image contains only runtime dependencies
Pinned base imagepython:3.13-slim instead of python:latest prevents surprise breakages
Non-root userLimits damage if the container is compromised
HEALTHCHECKOrchestrators (Compose, ECS, K8s) know when the app is truly ready
Layer orderingCOPY requirements.txt before COPY . so dependency layers are cached

Docker Compose for Production

Keep separate files for development and production. Development uses hot-reload and debug settings; production uses resource limits and restart policies.

# docker-compose.prod.yml
services:
  api:
    build:
      context: .
      dockerfile: Dockerfile
      target: production
    ports:
      - "8000:8000"
    env_file: .env.production
    depends_on:
      db:
        condition: service_healthy
      redis:
        condition: service_healthy
    deploy:
      resources:
        limits:
          cpus: "1.0"
          memory: 512M
    restart: unless-stopped
    networks:
      - external
      - internal

  db:
    image: postgres:18
    volumes:
      - pgdata:/var/lib/postgresql/data
    environment:
      POSTGRES_DB: taskflow
      POSTGRES_USER: taskflow
      POSTGRES_PASSWORD: ${DB_PASSWORD}
    healthcheck:
      test: ["CMD-SHELL", "pg_isready -U taskflow"]
      interval: 10s
      timeout: 5s
      retries: 5
    networks:
      - internal

  redis:
    image: redis:8-alpine
    command: redis-server --appendonly yes --maxmemory 128mb --maxmemory-policy allkeys-lru
    volumes:
      - redisdata:/data
    healthcheck:
      test: ["CMD", "redis-cli", "ping"]
      interval: 10s
      timeout: 5s
      retries: 5
    networks:
      - internal

volumes:
  pgdata:
  redisdata:

networks:
  external:
  internal:
    internal: true

What actually keeps the database off the internet

It is tempting to read internal: true as "this makes Postgres private." It does not, on its own — and misreading it is how a database ends up listening on a public IP. Two independent mechanisms are doing the work in that file, and you need both:

Two separate mechanisms, two different threats

Inbound — controlled by port publishing
Outbound — controlled by internal: true
The failure mode to remember

So: ports: decides who can reach in; internal: true decides whether the containers can reach out. They are not substitutes. The habit worth forming is to read a Compose file by asking those two questions separately of every service, rather than looking for one flag that means "secure".

Environment Management

Never hardcode secrets. Use .env files for local development and proper secret stores for production.

# .env.development (committed to repo as .env.example with empty values)
DATABASE_URL=postgresql+asyncpg://taskflow:localpass@localhost:5432/taskflow
REDIS_URL=redis://localhost:6379/0
SECRET_KEY=dev-only-not-for-production
ENVIRONMENT=development

# .env.production (NEVER committed -- use CI/CD secrets)
DATABASE_URL=postgresql+asyncpg://taskflow:${DB_PASSWORD}@db:5432/taskflow
REDIS_URL=redis://redis:6379/0
SECRET_KEY=${SECRET_KEY}
ENVIRONMENT=production

With Pydantic Settings, configuration is validated at startup:

from pydantic_settings import BaseSettings

class Settings(BaseSettings):
    database_url: str
    redis_url: str
    secret_key: str
    environment: str = "development"
    api_v1_prefix: str = "/api/v1"

    @property
    def is_production(self) -> bool:
        return self.environment == "production"

    model_config = {"env_file": ".env"}

settings = Settings()

CI/CD with GitHub Actions

A standard pipeline: test, build, deploy -- triggered on every push to main and on pull requests.

# .github/workflows/ci.yml
name: CI/CD Pipeline

on:
  push:
    branches: [main]
  pull_request:
    branches: [main]

jobs:
  test:
    runs-on: ubuntu-latest
    services:
      postgres:
        image: postgres:18
        env:
          POSTGRES_DB: taskflow_test
          POSTGRES_USER: taskflow
          POSTGRES_PASSWORD: testpassword
        ports: ["5432:5432"]
        options: >-
          --health-cmd="pg_isready -U taskflow"
          --health-interval=10s
          --health-timeout=5s
          --health-retries=5
      redis:
        image: redis:8-alpine
        ports: ["6379:6379"]
        options: >-
          --health-cmd="redis-cli ping"
          --health-interval=10s
          --health-timeout=5s
          --health-retries=5

    steps:
      - uses: actions/checkout@v4
      - uses: actions/setup-python@v5
        with:
          python-version: "3.13"
      - run: pip install -r requirements.txt
      - run: ruff check .
      - run: pytest --cov=app tests/
        env:
          DATABASE_URL: postgresql+asyncpg://taskflow:testpassword@localhost:5432/taskflow_test
          REDIS_URL: redis://localhost:6379/0
          SECRET_KEY: test-secret-key

  build:
    needs: test
    if: github.ref == 'refs/heads/main'
    runs-on: ubuntu-latest
    steps:
      - uses: actions/checkout@v4
      - run: docker build -t taskflow-api:${{ github.sha }} .

Production Checklist

Before going live, verify every item:

CategoryItemHow
SecurityNon-root Docker userUSER taskflow in Dockerfile
SecuritySecurity headersMiddleware: X-Content-Type-Options, X-Frame-Options, Strict-Transport-Security
SecurityCORS restrictedExplicit allowed origins, never * in production
PerformanceGunicorn workersgunicorn -w 4 -k uvicorn.workers.UvicornWorker
ReliabilityHealthchecks/health endpoint returns 200 when DB and Redis are reachable
ReliabilityRestart policyrestart: unless-stopped in Compose
VersioningAPI prefixAll routes under /api/v1/
SecretsNo hardcoded keysAll secrets from environment variables or secret manager

Choosing Somewhere to Run It

Because you built a container rather than a platform-specific app, this decision is reversible — which is most of the reason to containerise in the first place. Any host that runs an OCI image will run TaskFlow.

Rather than a table of tiers and prices that is wrong within a quarter, here are the questions that actually decide it, in the order they bite:

QuestionWhy it decides the answer
Who runs the database?A managed Postgres with automated backups and point-in-time recovery is the single largest operational difference between hosts. Running your own in a container is fine until the first disk failure.
What is your recovery story?Ask specifically: how do I restore to 03:00 yesterday, and have I tested it? A platform with one-click deploys and no tested restore is a worse bet than a fiddlier one with backups you have practised.
Do you need more than one region?Multi-region is a genuine differentiator between hosts and a genuine cost. Most APIs do not need it, and the ones that do usually know why.
Does anything require a VPC or compliance boundary?This is what pushes teams to AWS/GCP/Azure regardless of ergonomics. If it applies, it overrides every other consideration.
What does the exit look like?If moving away means rewriting deployment config only, you are fine. If it means rewriting application code, you have coupled yourself to the host.

Platforms commonly used for this shape of app include Railway, Render, Fly.io and AWS ECS/Fargate. Their free tiers, regions and managed-database offerings change often enough that any figure printed here would be wrong before you read it — those links are the source of record, and pricing is the one thing you should always check on the vendor's own page and never in a tutorial.

Next: hands-on lab where you will Dockerize TaskFlow and set up the full CI/CD pipeline. :::

Quiz

Module 5: Deploy & Ship Quiz

Take Quiz
Was this lesson helpful?

Sign in to rate