DevOps Advanced

Production CI/CD for Django with GitHub Actions

Build a complete CI/CD pipeline: automated tests, linting, security scans, Docker builds, and zero-downtime deploys. Reusable workflows, matrix builds, and deployment strategies.

DjangoZen Team Apr 17, 2026 19 min read 1 views

A good CI/CD pipeline catches bugs before merge and deploys without drama. Here's a production-grade setup for Django.

Pipeline Overview

Push → Lint → Test → Security Scan → Build Image → Deploy (staging) → Deploy (prod)

Each step gates the next. If tests fail, nothing ships.

Project Layout

.github/
  workflows/
    ci.yml         # runs on every push/PR
    deploy.yml     # runs on main branch / tags
    scheduled.yml  # nightly security scans

Basic CI Workflow

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

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

env:
  PYTHON_VERSION: '3.12'

jobs:
  lint:
    runs-on: ubuntu-latest
    steps:
      - uses: actions/checkout@v4
      - uses: actions/setup-python@v5
        with:
          python-version: ${{ env.PYTHON_VERSION }}
          cache: 'pip'

      - run: pip install ruff black mypy
      - run: ruff check .
      - run: ruff format --check .
      - run: mypy myapp/

  test:
    runs-on: ubuntu-latest
    services:
      postgres:
        image: postgres:16
        env:
          POSTGRES_PASSWORD: postgres
          POSTGRES_DB: test_db
        ports: [5432:5432]
        options: >-
          --health-cmd pg_isready
          --health-interval 10s
          --health-timeout 5s
          --health-retries 5

      redis:
        image: redis:7
        ports: [6379:6379]

    steps:
      - uses: actions/checkout@v4
      - uses: actions/setup-python@v5
        with:
          python-version: ${{ env.PYTHON_VERSION }}
          cache: 'pip'

      - run: pip install -r requirements.txt -r requirements-dev.txt

      - name: Run tests
        env:
          DATABASE_URL: postgres://postgres:postgres@localhost:5432/test_db
          REDIS_URL: redis://localhost:6379/0
          SECRET_KEY: test-secret
        run: |
          python manage.py migrate --noinput
          pytest --cov=myapp --cov-report=xml

      - uses: codecov/codecov-action@v4
        with:
          file: coverage.xml

Matrix Testing

Test against multiple Python/Django versions:

strategy:
  fail-fast: false
  matrix:
    python-version: ['3.11', '3.12', '3.13']
    django-version: ['4.2', '5.2']

steps:
  - uses: actions/setup-python@v5
    with:
      python-version: ${{ matrix.python-version }}

  - run: pip install "django==${{ matrix.django-version }}.*"

Security Scanning

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

      # Secret scanning
      - uses: trufflesecurity/trufflehog@main
        with:
          path: ./
          base: ${{ github.event.repository.default_branch }}

      # Dependency vulnerabilities
      - run: pip install safety
      - run: safety check --json

      # Static analysis
      - run: pip install bandit
      - run: bandit -r myapp/ -f json

      # SAST with CodeQL
      - uses: github/codeql-action/init@v3
        with:
          languages: python
      - uses: github/codeql-action/analyze@v3

Build and Push Docker Image

# .github/workflows/deploy.yml
name: Deploy

on:
  push:
    branches: [main]
    tags: ['v*']

jobs:
  build:
    runs-on: ubuntu-latest
    permissions:
      contents: read
      packages: write

    outputs:
      image: ${{ steps.meta.outputs.tags }}

    steps:
      - uses: actions/checkout@v4
      - uses: docker/setup-buildx-action@v3

      - uses: docker/login-action@v3
        with:
          registry: ghcr.io
          username: ${{ github.actor }}
          password: ${{ secrets.GITHUB_TOKEN }}

      - id: meta
        uses: docker/metadata-action@v5
        with:
          images: ghcr.io/${{ github.repository }}
          tags: |
            type=sha,prefix={{branch}}-
            type=semver,pattern={{version}}
            type=raw,value=latest,enable={{is_default_branch}}

      - uses: docker/build-push-action@v5
        with:
          context: .
          push: true
          tags: ${{ steps.meta.outputs.tags }}
          labels: ${{ steps.meta.outputs.labels }}
          cache-from: type=gha
          cache-to: type=gha,mode=max

Zero-Downtime Deploy

  deploy:
    needs: build
    runs-on: ubuntu-latest
    environment:
      name: production
      url: https://myapp.com

    steps:
      - name: Deploy via SSH
        uses: appleboy/ssh-action@v1
        with:
          host: ${{ secrets.DEPLOY_HOST }}
          username: ${{ secrets.DEPLOY_USER }}
          key: ${{ secrets.DEPLOY_KEY }}
          script: |
            cd /srv/myapp
            docker pull ghcr.io/${{ github.repository }}:latest
            docker-compose run --rm web python manage.py migrate --noinput
            docker-compose run --rm web python manage.py collectstatic --noinput
            docker-compose up -d --no-deps --wait web
            docker image prune -f

Note: docker-compose up -d --wait waits for health check to pass before considering the deploy done.

Blue-Green Deploy

For truly zero downtime, run two sets of containers:

# Start green (new version) alongside blue (current)
docker-compose -p green up -d

# Wait for green to be healthy
until curl -sf http://green:8000/health/; do sleep 2; done

# Switch nginx to point at green
ln -sf /etc/nginx/upstream-green.conf /etc/nginx/upstream.conf
systemctl reload nginx

# Stop blue after confirming green handles traffic
docker-compose -p blue down

Deployment Environments

jobs:
  deploy-staging:
    if: github.ref == 'refs/heads/main'
    environment: staging
    # ... deploys to staging server

  deploy-prod:
    needs: deploy-staging
    if: startsWith(github.ref, 'refs/tags/v')
    environment: production  # requires manual approval
    # ... deploys to prod

Configure "production" environment in GitHub repo settings with required reviewers.

Reusable Workflows

DRY up your workflows:

# .github/workflows/reusable-test.yml
on:
  workflow_call:
    inputs:
      python-version:
        type: string
        default: '3.12'

jobs:
  test:
    runs-on: ubuntu-latest
    # ... test steps
# In another workflow
jobs:
  test:
    uses: ./.github/workflows/reusable-test.yml
    with:
      python-version: '3.11'

Caching

Speed up builds:

- uses: actions/cache@v4
  with:
    path: ~/.cache/pip
    key: pip-${{ runner.os }}-${{ hashFiles('requirements*.txt') }}
    restore-keys: |
      pip-${{ runner.os }}-

Or use built-in caching:

- uses: actions/setup-python@v5
  with:
    python-version: '3.12'
    cache: 'pip'
    cache-dependency-path: requirements*.txt

Secrets Management

Store secrets in GitHub Secrets (repo or organization level):

env:
  DJANGO_SECRET_KEY: ${{ secrets.DJANGO_SECRET_KEY }}
  DATABASE_URL: ${{ secrets.DATABASE_URL }}

For cross-repo secrets, use organization secrets or a secrets manager (AWS Secrets Manager, HashiCorp Vault).

Database Migrations

Critical for safe deploys:

- name: Check for dangerous migrations
  run: |
    python manage.py makemigrations --check --dry-run
    pip install django-migration-linter
    python manage.py lintmigrations

django-migration-linter catches: renaming columns, dropping columns, changes that lock tables, etc.

Rollback Strategy

Every deploy should be reversible:

# Keep previous image tagged
docker tag myapp:v1.0.0 myapp:previous

# Rollback = re-deploy previous
docker-compose up -d --no-deps myapp

Or use kubectl rollout undo in Kubernetes.

Notifications

Alert on failures:

- name: Notify Slack on failure
  if: failure()
  uses: slackapi/slack-github-action@v1
  with:
    payload: |
      {"text": "Deploy failed: ${{ github.event.head_commit.message }}"}
  env:
    SLACK_WEBHOOK_URL: ${{ secrets.SLACK_WEBHOOK }}

Scheduled Jobs

Nightly security scans:

# .github/workflows/scheduled.yml
on:
  schedule:
    - cron: '0 2 * * *'  # 2 AM UTC daily

jobs:
  security-scan:
    runs-on: ubuntu-latest
    steps:
      - uses: actions/checkout@v4
      - run: pip install pip-audit
      - run: pip-audit --desc

Monitoring Deploy Success

After deploy, verify:

- name: Smoke test
  run: |
    for i in {1..10}; do
      if curl -sf https://myapp.com/health/; then
        echo "Healthy!"
        exit 0
      fi
      sleep 5
    done
    echo "Deployment failed health check"
    exit 1

Cost Optimization

  • Use ubuntu-latest (free) over macos (expensive)
  • Cache aggressively to reduce build time
  • Use paths filters to skip workflows when irrelevant files change:
on:
  push:
    paths-ignore:
      - 'docs/**'
      - '*.md'

Summary Checklist

  • [ ] Lint and format on every commit
  • [ ] Test matrix (multiple Python versions)
  • [ ] Secret scanning
  • [ ] Dependency vulnerability checks
  • [ ] Docker image builds with cache
  • [ ] Migration linting
  • [ ] Separate staging and production environments
  • [ ] Manual approval for production deploys
  • [ ] Automatic rollback on failure
  • [ ] Slack/Discord notifications
  • [ ] Scheduled security scans
  • [ ] Post-deploy health checks

A well-tuned pipeline makes shipping boring. That's the goal.