Deployment & DevOps for APIs

Ship your APIs to production with confidence using Docker, CI/CD, blue-green deployments, and modern deployment strategies

From Code to Production

Building an API is only half the story. Deploying it reliably, maintaining it at scale, and releasing updates without downtime requires DevOps practices. In this lesson, you'll learn to containerize your API with Docker, automate deployments with CI/CD pipelines, implement blue-green and canary deployment strategies, manage configuration across environments, and deploy serverless APIs using AWS Lambda. These are the skills that separate hobby projects from production systems serving millions of users.

Dockerizing APIs

Docker containers package your API with all its dependencies, ensuring it runs identically everywhere, from your laptop to production servers. This eliminates "works on my machine" problems and makes deployments predictable and repeatable.

Why Dockerize Your API?

Consistency

Same environment in development, staging, and production. No dependency conflicts or version mismatches.

Isolation

Each API runs in its own container with isolated dependencies. Multiple Python versions on the same host.

Portability

Deploy anywhere, AWS, GCP, Azure, or your own servers. Switch cloud providers without code changes.

Scalability

Spin up multiple containers instantly. Horizontal scaling becomes trivial with orchestration tools.

Creating a Production-Ready Dockerfile

A well-crafted Dockerfile uses multi-stage builds to minimize image size, leverages caching for faster builds, and follows security best practices.

# Dockerfile for FastAPI application

# Stage 1: Build stage
FROM python:3.11-slim as builder

WORKDIR /app

# Install system dependencies
RUN apt-get update && apt-get install -y \
    gcc \
    && rm -rf /var/lib/apt/lists/*

# Copy requirements first (leverage Docker layer caching)
COPY requirements.txt .

# Install Python dependencies
RUN pip install --no-cache-dir --user -r requirements.txt

# Stage 2: Runtime stage
FROM python:3.11-slim

WORKDIR /app

# Create non-root user for security
RUN useradd -m -u 1000 apiuser && chown -R apiuser:apiuser /app

# Copy Python dependencies from builder
COPY --from=builder /root/.local /home/apiuser/.local

# Copy application code
COPY --chown=apiuser:apiuser ./app ./app

# Switch to non-root user
USER apiuser

# Add local bin to PATH
ENV PATH=/home/apiuser/.local/bin:$PATH

# Expose port
EXPOSE 8000

# Health check
HEALTHCHECK --interval=30s --timeout=3s --start-period=5s --retries=3 \
    CMD python -c "import urllib.request; urllib.request.urlopen('http://localhost:8000/health')"

# Run with uvicorn
CMD ["uvicorn", "app.main:app", "--host", "0.0.0.0", "--port", "8000"]
Multi-Stage Build Benefits: Stage 1 installs build tools and dependencies. Stage 2 copies only the runtime artifacts, reducing final image size from ~1GB to ~200MB. Faster deployments and lower storage costs.

Docker Compose for Local Development

Docker Compose orchestrates multiple containers (API + database + Redis) for local development that mirrors production architecture.

# docker-compose.yml

services:
  api:
    build: .
    ports:
      - "8000:8000"
    environment:
      - DATABASE_URL=postgresql://postgres:password@db:5432/apidb
      - REDIS_URL=redis://redis:6379
      - ENV=development
    volumes:
      - ./app:/app/app  # Hot reload in development
    depends_on:
      - db
      - redis
    command: uvicorn app.main:app --host 0.0.0.0 --port 8000 --reload

  db:
    image: postgres:15-alpine
    environment:
      - POSTGRES_PASSWORD=password
      - POSTGRES_DB=apidb
    volumes:
      - postgres_data:/var/lib/postgresql/data
    ports:
      - "5432:5432"

  redis:
    image: redis:7-alpine
    ports:
      - "6379:6379"

volumes:
  postgres_data:

Usage: docker-compose up starts all services.docker-compose down stops them. Code changes reload automatically.

Optimizing Build Context with .dockerignore

# .dockerignore - exclude unnecessary files from Docker build

__pycache__
*.pyc
*.pyo
*.pyd
.Python
*.so
*.egg
*.egg-info/
dist/
build/

# Virtual environments
venv/
env/
.venv/

# IDE
.vscode/
.idea/
*.swp

# Git
.git/
.gitignore

# Testing
.pytest_cache/
.coverage
htmlcov/

# Documentation
docs/
*.md

# Environment files (security!)
.env
.env.local
*.pem
*.key

Excluding these files reduces build context size from ~500MB to ~10MB, speeding up builds and preventing secrets from leaking into images.

CI/CD Pipelines

Continuous Integration and Continuous Deployment (CI/CD) automate testing, building, and deploying your API. Every push to main triggers tests, builds a Docker image, and deploys to production, no manual steps, no human error.

GitHub Actions Pipeline

GitHub Actions runs workflows triggered by git events (push, pull request, release). This pipeline tests code, builds a Docker image, pushes to a registry, and deploys.

# .github/workflows/deploy.yml

name: Deploy API

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

env:
  DOCKER_IMAGE: ghcr.io/your-org/api
  AWS_REGION: us-east-1

jobs:
  test:
    runs-on: ubuntu-latest
    steps:
      - uses: actions/checkout@v4

      - name: Set up Python
        uses: actions/setup-python@v5
        with:
          python-version: '3.11'
          cache: 'pip'

      - name: Install dependencies
        run: |
          pip install -r requirements.txt
          pip install pytest pytest-cov

      - name: Run tests
        run: pytest --cov=app --cov-report=xml

      - name: Upload coverage
        uses: codecov/codecov-action@v5

  build:
    needs: test
    runs-on: ubuntu-latest
    if: github.ref == 'refs/heads/main'
    steps:
      - uses: actions/checkout@v4

      - name: Log in to GitHub Container Registry
        uses: docker/login-action@v3
        with:
          registry: ghcr.io
          username: ${{ github.actor }}
          password: ${{ secrets.GITHUB_TOKEN }}

      - name: Build and push Docker image
        uses: docker/build-push-action@v6
        with:
          context: .
          push: true
          tags: |
            ${{ env.DOCKER_IMAGE }}:latest
            ${{ env.DOCKER_IMAGE }}:${{ github.sha }}
          cache-from: type=registry,ref=${{ env.DOCKER_IMAGE }}:latest
          cache-to: type=inline

  deploy:
    needs: build
    runs-on: ubuntu-latest
    environment: production
    steps:
      - name: Configure AWS credentials
        uses: aws-actions/configure-aws-credentials@v4
        with:
          aws-access-key-id: ${{ secrets.AWS_ACCESS_KEY_ID }}
          aws-secret-access-key: ${{ secrets.AWS_SECRET_ACCESS_KEY }}
          aws-region: ${{ env.AWS_REGION }}

      - name: Deploy to ECS
        run: |
          aws ecs update-service \
            --cluster api-cluster \
            --service api-service \
            --force-new-deployment \
            --desired-count 2

      - name: Wait for deployment
        run: |
          aws ecs wait services-stable \
            --cluster api-cluster \
            --services api-service

      - name: Notify deployment
        if: success()
        uses: slackapi/slack-github-action@v1
        with:
          webhook-url: ${{ secrets.SLACK_WEBHOOK }}
          payload: |
            {
              "text": "API deployed successfully! 🚀\nCommit: ${{ github.sha }}"
            }
What This Does: On every push to main, it runs tests, builds a Docker image tagged with the commit SHA, pushes to GitHub Container Registry, deploys to AWS ECS, and notifies Slack. Failed tests block deployment.

GitLab CI/CD Pipeline

GitLab CI offers similar capabilities with a different YAML syntax. This pipeline includes security scanning and deployment to Kubernetes.

# .gitlab-ci.yml

stages:
  - test
  - build
  - security
  - deploy

variables:
  DOCKER_IMAGE: registry.gitlab.com/your-org/api

test:
  stage: test
  image: python:3.11-slim
  cache:
    paths:
      - .pip-cache/
  before_script:
    - pip install --cache-dir=.pip-cache -r requirements.txt pytest pytest-cov
  script:
    - pytest --cov=app --cov-report=term --cov-report=html
  artifacts:
    reports:
      coverage_report:
        coverage_format: cobertura
        path: coverage.xml
  coverage: '/TOTAL.*\s+(\d+%)$/'

build:
  stage: build
  image: docker:latest
  services:
    - docker:dind
  before_script:
    - docker login -u $CI_REGISTRY_USER -p $CI_REGISTRY_PASSWORD $CI_REGISTRY
  script:
    - docker build -t $DOCKER_IMAGE:$CI_COMMIT_SHA -t $DOCKER_IMAGE:latest .
    - docker push $DOCKER_IMAGE:$CI_COMMIT_SHA
    - docker push $DOCKER_IMAGE:latest
  only:
    - main

security_scan:
  stage: security
  image: aquasec/trivy:latest
  script:
    - trivy image --severity HIGH,CRITICAL $DOCKER_IMAGE:$CI_COMMIT_SHA
  only:
    - main

deploy_production:
  stage: deploy
  image: bitnami/kubectl:latest
  before_script:
    - kubectl config set-cluster k8s --server="$KUBE_URL" --insecure-skip-tls-verify=true
    - kubectl config set-credentials admin --token="$KUBE_TOKEN"
    - kubectl config set-context default --cluster=k8s --user=admin
    - kubectl config use-context default
  script:
    - kubectl set image deployment/api-deployment api=$DOCKER_IMAGE:$CI_COMMIT_SHA -n production
    - kubectl rollout status deployment/api-deployment -n production
  environment:
    name: production
    url: https://api.example.com
  only:
    - main
  when: manual  # Require manual approval for production

This pipeline adds security scanning with Trivy and requires manual approval before production deployment, a good practice for critical systems.

Blue-Green Deployments

Blue-green deployment eliminates downtime by running two identical production environments. Deploy the new version to the "green" environment, test it, then switch traffic instantly. If issues arise, rollback is instant, just switch back to "blue".

How Blue-Green Deployment Works

1
Blue (Current Production)

All traffic routes to the blue environment running v1.0

2
Deploy Green (New Version)

Deploy v1.1 to green environment. Blue still serves traffic.

3
Test Green

Run smoke tests against green. Verify health checks pass.

4
Switch Traffic

Update load balancer to route all traffic to green. Instant cutover.

Database Migrations:Ensure migrations are backward-compatible. Green must work with the blue database schema during the transition window.

Blue-Green with AWS ECS

# appspec.yml for AWS CodeDeploy Blue-Green

version: 0.0
Resources:
  - TargetService:
      Type: AWS::ECS::Service
      Properties:
        TaskDefinition: "arn:aws:ecs:region:account:task-definition/api:5"
        LoadBalancerInfo:
          ContainerName: "api"
          ContainerPort: 8000
        PlatformVersion: "LATEST"
        NetworkConfiguration:
          AwsvpcConfiguration:
            Subnets:
              - "subnet-12345"
              - "subnet-67890"
            SecurityGroups:
              - "sg-abc123"
            AssignPublicIp: "ENABLED"

Hooks:
  - BeforeInstall: "BeforeInstallHookFunction"
  - AfterInstall: "AfterInstallHookFunction"
  - AfterAllowTestTraffic: "AfterAllowTestTrafficHookFunction"
  - BeforeAllowTraffic: "BeforeAllowTrafficHookFunction"
  - AfterAllowTraffic: "AfterAllowTrafficHookFunction"

# Deployment configuration
DeploymentConfiguration:
  Type: "LINEAR"
  LinearPercentage: 100  # All at once
  Interval: 0  # Immediate switch

CodeDeploy automatically creates a green task set, runs health checks, switches the target group, and keeps blue running for rollback. Rollback is one command.

Blue-Green with Kubernetes

# kubernetes/blue-green-deployment.sh

#!/bin/bash

# Deploy green version
kubectl apply -f deployment-green.yaml

# Wait for green to be ready
kubectl wait --for=condition=available --timeout=300s deployment/api-green

# Run smoke tests against green
kubectl run smoke-test --image=curlimages/curl --rm -it --restart=Never \
  -- curl -f http://api-green-service/health || exit 1

# Switch service to green
kubectl patch service api-service -p '{"spec":{"selector":{"version":"green"}}}'

echo "Traffic switched to green. Blue deployment still running for rollback."
echo "To rollback: kubectl patch service api-service -p '{"spec":{"selector":{"version":"blue"}}}'"

# Optional: After confidence window, delete blue
# kubectl delete deployment api-blue

Canary Releases & Feature Flags

Canary releases gradually roll out new versions to a small percentage of users before full deployment. Feature flags allow toggling features on/off without code changes. Together, they enable safe, incremental releases.

Canary Deployment Strategy

Instead of switching all traffic at once, route a small percentage (5-10%) to the new version. Monitor error rates, latency, and business metrics. Gradually increase the percentage if metrics look good.

Typical Canary Schedule:
  • 5% traffic → monitor for 10 minutes
  • 25% traffic → monitor for 30 minutes
  • 50% traffic → monitor for 1 hour
  • 100% traffic → new version fully deployed
Automatic Rollback: If error rate exceeds 1% or latency increases by 50%, automatically rollback to previous version.

Canary with Kubernetes & Istio

# istio-canary-virtualservice.yaml

apiVersion: networking.istio.io/v1beta1
kind: VirtualService
metadata:
  name: api-canary
spec:
  hosts:
  - api.example.com
  http:
  - match:
    - headers:
        cookie:
          regex: "^(.*?;)?(canary=true)(;.*)?$"
    route:
    - destination:
        host: api-service
        subset: v2
  - route:
    - destination:
        host: api-service
        subset: v1
      weight: 90
    - destination:
        host: api-service
        subset: v2
      weight: 10

---
apiVersion: networking.istio.io/v1beta1
kind: DestinationRule
metadata:
  name: api-destination
spec:
  host: api-service
  subsets:
  - name: v1
    labels:
      version: v1
  - name: v2
    labels:
      version: v2

This routes 10% of traffic to v2, while users with a canary=true cookie always get v2 (useful for internal testing).

Feature Flags for Gradual Rollouts

Feature flags decouple deployment from release. Deploy code with new features disabled, then enable them for specific users or percentages via configuration.

# app/feature_flags.py

import hashlib
from functools import lru_cache
import os
from typing import Optional

class FeatureFlags:
    """Manage feature flags with percentage rollouts"""

    def __init__(self):
        self.flags = {
            "new_recommendation_engine": {
                "enabled": os.getenv("FF_NEW_RECS", "false").lower() == "true",
                "rollout_percentage": int(os.getenv("FF_NEW_RECS_PCT", "0")),
            },
            "v2_api_endpoints": {
                "enabled": os.getenv("FF_V2_API", "false").lower() == "true",
                "rollout_percentage": int(os.getenv("FF_V2_API_PCT", "0")),
            },
        }

    def is_enabled(self, flag_name: str, user_id: Optional[str] = None) -> bool:
        """Check if feature is enabled for user"""
        if flag_name not in self.flags:
            return False

        flag = self.flags[flag_name]

        # Feature globally disabled
        if not flag["enabled"]:
            return False

        # Full rollout
        if flag["rollout_percentage"] >= 100:
            return True

        # No rollout
        if flag["rollout_percentage"] == 0:
            return False

        # Percentage-based rollout (consistent per user)
        if user_id:
            user_hash = int(hashlib.md5(user_id.encode()).hexdigest(), 16)
            return (user_hash % 100) < flag["rollout_percentage"]

        return False

@lru_cache()
def get_feature_flags():
    return FeatureFlags()

# Usage in API endpoint
from app.feature_flags import get_feature_flags

@app.get("/recommendations/{user_id}")
async def get_recommendations(user_id: str):
    flags = get_feature_flags()

    if flags.is_enabled("new_recommendation_engine", user_id):
        # New ML-based recommendations
        return new_recommendation_engine(user_id)
    else:
        # Old rule-based recommendations
        return legacy_recommendations(user_id)
Benefits: Rollout new features to 5% of users, monitor metrics, then gradually increase. Rollback is instant (set percentage to 0). No redeployment needed.

Using LaunchDarkly for Feature Flags

# app/feature_flags_launchdarkly.py

import os
import ldclient
from ldclient.config import Config

# Initialize LaunchDarkly client
ldclient.set_config(Config(os.getenv("LAUNCHDARKLY_SDK_KEY")))
ld_client = ldclient.get()

@app.get("/recommendations/{user_id}")
async def get_recommendations(user_id: str):
    user = {
        "key": user_id,
        "email": f"{user_id}@example.com",
        "custom": {
            "plan": "premium",  # Use for targeting
        }
    }

    # Check feature flag with LaunchDarkly
    use_new_engine = ld_client.variation(
        "new-recommendation-engine",
        user,
        default=False
    )

    if use_new_engine:
        return new_recommendation_engine(user_id)
    else:
        return legacy_recommendations(user_id)

# Close client on shutdown
@app.on_event("shutdown")
async def shutdown():
    ld_client.close()

LaunchDarkly provides a UI to control flags, target specific users, run A/B tests, and track metrics, no code changes required.

Environment Configuration Management

Managing configuration across environments (dev, staging, production) without hardcoding secrets is critical. Use environment variables for configuration and secret managers for sensitive data.

12-Factor App: Config in Environment

Store configuration in environment variables, not code. Separate config from code means the same Docker image can run in any environment.

❌ Bad: HardcodedDB_URL = "postgres://..."
✅ Good: EnvironmentDB_URL = os.getenv("DATABASE_URL")
✅ Best: Pydanticsettings.database_url

Type-Safe Configuration with Pydantic

# app/config.py

from pydantic_settings import BaseSettings, SettingsConfigDict
from functools import lru_cache

class Settings(BaseSettings):
    """Application configuration loaded from environment"""

    # Application
    app_name: str = "My API"
    debug: bool = False
    environment: str = "production"

    # Database
    database_url: str
    db_pool_size: int = 10
    db_max_overflow: int = 20

    # Redis
    redis_url: str = "redis://localhost:6379"

    # Security
    secret_key: str
    jwt_algorithm: str = "HS256"
    access_token_expire_minutes: int = 30

    # AWS
    aws_region: str = "us-east-1"
    aws_access_key_id: str | None = None
    aws_secret_access_key: str | None = None

    # External APIs
    stripe_api_key: str | None = None
    sendgrid_api_key: str | None = None

    # Observability
    sentry_dsn: str | None = None
    log_level: str = "INFO"

    model_config = SettingsConfigDict(
        env_file=".env",
        env_file_encoding="utf-8",
        case_sensitive=False,
    )

@lru_cache()
def get_settings() -> Settings:
    return Settings()

# Usage
from app.config import get_settings

settings = get_settings()
print(settings.database_url)  # Type-safe, validated
Benefits: Type validation, required fields enforced, defaults, IDE autocomplete, and automatic parsing from .env files. Missing required config fails fast at startup.

AWS Secrets Manager Integration

Store sensitive secrets (API keys, database passwords) in AWS Secrets Manager instead of environment variables. Rotate them automatically.

# app/secrets.py

import boto3
import json
from functools import lru_cache

@lru_cache()
def get_secret(secret_name: str, region: str = "us-east-1") -> dict:
    """Retrieve secret from AWS Secrets Manager"""
    client = boto3.client("secretsmanager", region_name=region)

    try:
        response = client.get_secret_value(SecretId=secret_name)
        return json.loads(response["SecretString"])
    except Exception as e:
        raise RuntimeError(f"Failed to retrieve secret {secret_name}: {e}")

# Usage in config
from pydantic import model_validator

class Settings(BaseSettings):
    database_url: str | None = None

    @model_validator(mode="after")
    def load_production_secrets(self) -> "Settings":
        """Load secrets from AWS Secrets Manager in production"""
        if self.environment == "production":
            db_secrets = get_secret("production/database")
            self.database_url = db_secrets["url"]

            api_secrets = get_secret("production/api-keys")
            self.stripe_api_key = api_secrets["stripe"]
            self.sendgrid_api_key = api_secrets["sendgrid"]
        return self

Environment-Specific .env Files

DEBUG=true
ENVIRONMENT=development
DATABASE_URL=sqlite:///dev.db
REDIS_URL=redis://localhost:6379
SECRET_KEY=dev-secret-key
LOG_LEVEL=DEBUG
DEBUG=false
ENVIRONMENT=production
DATABASE_URL=<from Secrets Manager>
REDIS_URL=<from Secrets Manager>
SECRET_KEY=<from Secrets Manager>
LOG_LEVEL=INFO
SENTRY_DSN=https://...

Key Takeaways

  • Docker containers ensure consistency across development, staging, and production. Multi-stage builds reduce image size by 80%+.
  • CI/CD pipelines automate testing and deployment, eliminating manual errors. GitHub Actions and GitLab CI integrate seamlessly with cloud providers.
  • Blue-green deployments enable zero-downtime releases. Deploy to green, test, switch traffic instantly. Rollback is one command.
  • Canary releases reduce risk by gradually routing traffic (5% → 25% → 100%) while monitoring metrics. Automatic rollback protects users.
  • Feature flags decouple deployment from release. Ship code with features disabled, enable for specific users or percentages via configuration.
  • Environment configuration belongs in environment variables, not code. Pydantic Settings provides type-safe config. AWS Secrets Manager stores sensitive data.
  • Modern deployment strategies enable continuous delivery with confidence. Blue-green for instant rollbacks, canary for gradual rollouts, feature flags for controlled releases.

Ready to Deploy?


You now have the knowledge to ship your API to production with confidence. Start with Docker and a simple CI/CD pipeline. Add blue-green deployments as your user base grows. Experiment with feature flags to control releases. The best deployment strategy is the one you can maintain and iterate on. Ship often, monitor everything, and improve continuously. Next, explore serverless deployment options for event-driven workloads.