Week 24: CI/CD & Progressive Delivery for FastAPI

Week 13's pipeline built and pushed an image, then deployed it in one step — the whole fleet gets the new version at once, and the only way to find out something's wrong is if it breaks for everyone. This week is the last stop before the capstone: a real multi-stage pipeline with a security gate, and progressive delivery techniques — blue-green and canary deploys, feature flags — that let a bad release affect a small, controlled slice of traffic instead of all of it.

Module 21 of 22 Week 24 of 26 ~4–5 Hours Hands-on Exercise Included

By the end of this week, you'll be able to

  • Build a multi-stage pipeline: test, build, scan, deploy, each gating the next
  • Implement a blue-green deploy and a weighted canary rollout for a FastAPI service
  • Decouple deploy from release using feature flags

1. A Real Multi-Stage Pipeline

Week 13's pipeline built an image and deployed it in essentially one step. A production-grade pipeline separates concerns into distinct, sequentially-gated stages, so a failure at any point stops the pipeline before it reaches production.

.github/workflows/deploy.yml
name: Test, Scan & Deploy

on:
  push:
    branches: [main]

jobs:
  test:
    runs-on: ubuntu-latest
    steps:
      - uses: actions/checkout@v4
      - uses: actions/setup-python@v5
        with: { python-version: "3.12" }
      - run: pip install poetry && poetry install
      - run: poetry run pytest --cov

  build-and-scan:
    needs: test
    runs-on: ubuntu-latest
    outputs:
      image: ${{ steps.image.outputs.image }}
    steps:
      - uses: actions/checkout@v4
      - run: docker build -t acme/task-service:${{ github.sha }} .
      - name: Scan image
        uses: aquasecurity/trivy-action@0.24.0
        with:
          image-ref: acme/task-service:${{ github.sha }}
          severity: CRITICAL,HIGH
          exit-code: "1"
          ignore-unfixed: true
      - run: docker push acme/task-service:${{ github.sha }}
      - id: image
        run: echo "image=acme/task-service:${{ github.sha }}" >> "$GITHUB_OUTPUT"

  deploy-canary:
    needs: build-and-scan
    runs-on: ubuntu-latest
    environment: production
    steps:
      - run: ./scripts/deploy-canary.sh ${{ needs.build-and-scan.outputs.image }}

Each job's needs: is what makes this a real gate rather than decoration — build-and-scan cannot start if test fails, and deploy-canary cannot start if the Trivy scan fails. This is worth verifying directly: deliberately break a test and separately reference a known-vulnerable dependency, and confirm the deploy job never runs in either case, rather than trusting the YAML at a glance.

Run Testcontainers integration tests as their own stage, not bundled with unit tests

Integration tests spinning up real containers are meaningfully slower than in-memory unit tests. Separating them into their own stage (or running them in parallel with a separate job) means a quick unit test failure fails fast, in seconds, instead of every push waiting for the full integration suite before learning about a trivial typo.

2. Blue-Green & Canary Deploys

A plain Kubernetes rolling update replaces Pods gradually, but the new version still eventually receives all traffic with no way to hold it at a fixed, small percentage first. Two stronger patterns for a production FastAPI service:

Blue-green runs two complete, identical environments — "blue" (currently live) and "green" (the new version) — and switches all traffic from one to the other atomically, typically by repointing a load balancer or Kubernetes Service selector:

switching traffic to the green deployment
apiVersion: v1
kind: Service
metadata:
  name: task-service
spec:
  selector:
    app: task-service
    version: green   # was "blue" -- this single line switch is the whole cutover
  ports:
    - port: 80
      targetPort: 8000

Both environments run simultaneously before and briefly after the switch, which means rollback is just as instant as the deploy — flip the selector back to blue — but it doubles resource usage for the duration, and gives no opportunity to observe the new version under a small slice of real traffic before it takes everything at once.

Canary deploys address exactly that gap by shifting a small, controlled percentage of traffic first:

a weighted canary using two Deployments behind a shared Service
# Two Deployments, "task-service-stable" (10 replicas) and
# "task-service-canary" (1 replica), both labeled app=task-service
# and selected by the same Service -- Kubernetes load-balances
# across all matching Pods roughly proportional to replica count,
# giving the canary ~9% of traffic with a 10:1 replica ratio.

kubectl scale deployment/task-service-canary --replicas=1
# watch error rate and p99 latency on the canary specifically
# (tag metrics with a "version" label from Week 12's observability)
kubectl scale deployment/task-service-canary --replicas=5   # ramp up once it looks healthy
Blue-green and canary answer different questions — pick based on which one you're actually asking

Blue-green answers "can I switch to the new version instantly, and back instantly, with zero in-between state" — good for a change you're confident in but want an instant abort button for. Canary answers "does the new version actually behave correctly under a small slice of real traffic before I commit to it fully" — good for a change you're genuinely uncertain about. They're not competing options for the same problem.

3. Feature Flags

Every deployment strategy so far ties a code change directly to user-visible behavior changing at the same moment. A feature flag breaks that coupling deliberately: the code for a new feature ships and runs in production behind a conditional, but stays off until explicitly turned on — separating deploy (the code exists and is running) from release (users can actually reach it).

a simple flag check gating a new endpoint's behavior
@router.get("/tasks/{task_id}/summary")
async def get_summary(
    task_id: int,
    tenant: Tenant = Depends(get_current_tenant),
    flags: FeatureFlags = Depends(get_feature_flags),
) -> TaskSummaryResponse:
    if flags.is_enabled("ai-generated-summaries", tenant.id):
        return await ai_summary_service.generate(task_id)   # the new, riskier path
    return await legacy_summary_service.generate(task_id)     # the existing, proven path

With a real flagging service (Unleash, LaunchDarkly, or a simple database-backed toggle table for a smaller service), that flag can be enabled for 1% of tenants, or only internal accounts, or only a specific customer who asked for early access — controlled at runtime, with no new deploy required to change who sees it:

a minimal database-backed flag dependency
class FeatureFlags:
    def __init__(self, db: AsyncSession):
        self.db = db

    async def is_enabled(self, flag_name: str, tenant_id: int) -> bool:
        flag = await flag_repository.get(self.db, flag_name)
        if flag is None:
            return False
        return (
            flag.globally_enabled
            or tenant_id in flag.enabled_tenant_ids
            or (flag.rollout_percentage > 0 and tenant_id % 100 < flag.rollout_percentage)
        )

This is the direct answer to a real limitation in Sections 1–2: a blue-green switch or a canary's replica ratio controls which version of the code serves a request, but both are coarse and infrastructure-level. A feature flag controls which behavior within a single running version a specific user sees, which is a finer-grained, faster, and reversible tool for exactly the case where you want to validate one new feature's behavior, not roll out an entire new build.

Old flags left in code become their own kind of technical debt

Every conditional branch a flag introduces is a maintenance cost and a source of confusing dead code once the feature is either fully rolled out or abandoned. Treat "remove the flag and the losing code path" as a required follow-up task once a rollout is complete — a codebase with dozens of permanently-on flags nobody removed is nearly as hard to reason about as one with no flags at all.

4. Hands-on Exercise

Hands-on

Build a gated pipeline, run a real canary, and gate a feature behind a flag

Apply all three practices to your service, in preparation for the capstone.

Requirements:

  1. Build the multi-stage pipeline from Section 1 with separate test, build-and-scan, and deploy stages properly gated with needs:, and verify each gate by deliberately breaking it.
  2. Deploy a stable and a canary version of your service behind a shared Kubernetes Service, ramp the canary's replica count up gradually, and confirm via metrics tagged with a version label that traffic is actually splitting between both.
  3. Add a simple database-backed feature flag gating one real piece of new behavior, and confirm you can enable it for a specific tenant without deploying new code.
  4. Simulate a bad canary: deploy a deliberately broken version as the canary, confirm its error rate is visibly worse in your dashboard, and roll it back by scaling it to zero — without touching the stable deployment at all.
Hint

Tag every metric and log line with the running version (an environment variable baked into the image at build time is enough) from the very first deploy — retrofitting that tag onto an already-running canary mid-incident is far harder than having it from the start.

5. Knowledge Check

Three quick questions. Expand each to check your answer.

Q1

Why should Testcontainers integration tests run as a separate pipeline stage from unit tests, rather than in the same job?

Integration tests spinning up real containers are significantly slower than in-memory unit tests. Bundling them together means every push, including one with a trivial unit test failure, has to wait for the full slow integration suite before reporting anything — separating them lets a fast unit test failure fail the pipeline in seconds instead of minutes.

Q2

What question does blue-green answer that canary doesn't, and vice versa?

Blue-green answers whether you can switch fully to a new version, and back, instantly with no partial-traffic state — appropriate when you're confident in the change but want an instant rollback option. Canary answers whether a new version actually behaves correctly under real traffic before committing to it fully, by exposing it to only a small slice first — appropriate when you're genuinely uncertain about the change's behavior in production.

Q3

How does a feature flag let you control a rollout more finely than a canary deploy alone?

A canary controls which version of the running code a request hits, at the infrastructure level — coarse, and typically random with respect to which specific user gets which version. A feature flag controls which behavior within one running version a specific user or tenant sees, decided at runtime with no redeploy, which allows targeting a rollout to a specific tenant, percentage, or cohort with far more precision than replica ratios or traffic weights provide.