Phase 5: Deploy & Ship
Production Deployment
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 /install /usr/local
COPY . .
RUN chown -R taskflow:taskflow /app
USER taskflow
HEALTHCHECK \
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:
| Principle | Why It Matters |
|---|---|
| Multi-stage build | Builder stage is discarded; final image contains only runtime dependencies |
| Pinned base image | python:3.13-slim instead of python:latest prevents surprise breakages |
| Non-root user | Limits damage if the container is compromised |
| HEALTHCHECK | Orchestrators (Compose, ECS, K8s) know when the app is truly ready |
| Layer ordering | COPY 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
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:
| Category | Item | How |
|---|---|---|
| Security | Non-root Docker user | USER taskflow in Dockerfile |
| Security | Security headers | Middleware: X-Content-Type-Options, X-Frame-Options, Strict-Transport-Security |
| Security | CORS restricted | Explicit allowed origins, never * in production |
| Performance | Gunicorn workers | gunicorn -w 4 -k uvicorn.workers.UvicornWorker |
| Reliability | Healthchecks | /health endpoint returns 200 when DB and Redis are reachable |
| Reliability | Restart policy | restart: unless-stopped in Compose |
| Versioning | API prefix | All routes under /api/v1/ |
| Secrets | No hardcoded keys | All 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:
| Question | Why 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. :::
Sign in to rate