Rohan Yeole - Homepage Rohan Yeole

GitHub Actions CI/CD for Django: Tests, Linting, and Zero-Downtime Deployment

Apr 15, 20261 min read

A working GitHub Actions CI/CD pipeline for Django takes 30 minutes to set up and eliminates the risk of deploying broken code. This guide provides a complete, production-ready workflow that runs your tests against a real PostgreSQL database, lints with Ruff, and deploys to EC2 on every push to main.

What the Pipeline Does

  1. On every push/PR to any branch: run tests + lint
  2. On push to main only: deploy to EC2 if tests pass

Complete Workflow File

Create .github/workflows/django.yml:

name: Django CI/CD

on:
  push:
    branches: ["main", "develop"]
  pull_request:
    branches: ["main"]

env:
  PYTHON_VERSION: "3.12"

jobs:
  test:
    name: Tests and Lint
    runs-on: ubuntu-22.04

    services:
      postgres:
        image: postgres:16
        env:
          POSTGRES_DB: test_db
          POSTGRES_USER: test_user
          POSTGRES_PASSWORD: test_password
        options: >-
          --health-cmd pg_isready
          --health-interval 10s
          --health-timeout 5s
          --health-retries 5
        ports:
          - 5432:5432

      redis:
        image: redis:7
        options: >-
          --health-cmd "redis-cli ping"
          --health-interval 10s
          --health-timeout 5s
          --health-retries 5
        ports:
          - 6379:6379

    env:
      SECRET_KEY: "ci-secret-key-not-used-in-production"
      DEBUG: "True"
      DATABASE_URL: "postgres://test_user:test_password@localhost:5432/test_db"
      CELERY_BROKER_URL: "redis://localhost:6379/0"

    steps:
      - name: Checkout code
        uses: actions/checkout@v4

      - name: Set up Python
        uses: actions/setup-python@v5
        with:
          python-version: ${{ env.PYTHON_VERSION }}
          cache: "pip"

      - name: Install dependencies
        run: |
          python -m pip install --upgrade pip
          pip install -r requirements.txt

      - name: Run Ruff linter
        run: ruff check .

      - name: Run Ruff formatter check
        run: ruff format --check .

      - name: Run security check
        run: python manage.py check --deploy --settings=myproject.settings.ci
        env:
          ALLOWED_HOSTS: "localhost"
          SECRET_KEY: "ci-only-secret-key"

      - name: Run migrations
        run: python manage.py migrate --no-input

      - name: Run tests
        run: |
          pytest \
            --cov=. \
            --cov-report=term-missing \
            --cov-fail-under=70 \
            -v \
            --tb=short

  deploy:
    name: Deploy to EC2
    needs: test
    runs-on: ubuntu-22.04
    if: github.ref == 'refs/heads/main' && github.event_name == 'push'

    steps:
      - name: Deploy via SSH
        uses: appleboy/ssh-action@v1
        with:
          host: ${{ secrets.EC2_HOST }}
          username: ubuntu
          key: ${{ secrets.EC2_SSH_KEY }}
          timeout: 120s
          script: |
            cd /srv/myproject

            echo "Pulling latest code..."
            git pull origin main

            echo "Activating virtual environment..."
            source venv/bin/activate

            echo "Installing dependencies..."
            pip install -r requirements.txt --quiet

            echo "Running migrations..."
            python manage.py migrate --no-input

            echo "Collecting static files..."
            python manage.py collectstatic --no-input --quiet

            echo "Restarting services..."
            sudo systemctl restart gunicorn
            sudo systemctl restart celery

            echo "Deployment complete."

Setting Up GitHub Secrets

Go to your repository → Settings → Secrets and variables → Actions, and add:

SecretValue
EC2_HOSTYour EC2 public IP or domain
EC2_SSH_KEYContents of your .pem key file

For the SSH key:

# Copy the contents of your key file
cat your-key.pem
# Paste the entire output (including -----BEGIN/END RSA PRIVATE KEY-----) as the secret value

CI-Specific Django Settings

Create myproject/settings/ci.py:

from .base import *

DEBUG = False
SECRET_KEY = os.environ.get("SECRET_KEY", "ci-secret-key")
ALLOWED_HOSTS = ["localhost", "127.0.0.1"]

DATABASES = {
    "default": {
        "ENGINE": "django.db.backends.postgresql",
        "NAME": "test_db",
        "USER": "test_user",
        "PASSWORD": "test_password",
        "HOST": "localhost",
        "PORT": "5432",
    }
}

# Faster password hashing in tests
PASSWORD_HASHERS = [
    "django.contrib.auth.hashers.MD5PasswordHasher",
]

# Celery tasks execute synchronously in CI
CELERY_TASK_ALWAYS_EAGER = True

Ruff Configuration

pyproject.toml:

[tool.ruff]
line-length = 100
target-version = "py312"

[tool.ruff.lint]
select = ["E", "F", "W", "I", "N", "UP"]
ignore = ["E501"]  # Line length handled by formatter

[tool.ruff.format]
quote-style = "double"

pytest Configuration

pyproject.toml (or pytest.ini):

[tool.pytest.ini_options]
DJANGO_SETTINGS_MODULE = "myproject.settings.ci"
python_files = ["test_*.py", "*_test.py"]
python_classes = ["Test*"]
python_functions = ["test_*"]
addopts = "--strict-markers"
pip install pytest pytest-django pytest-cov

Handling Database Migrations in Deployment

The current workflow runs migrate during deployment on the live server. This has a race condition risk: if the migration takes 30+ seconds, requests during that window may fail.

For zero-downtime migrations:

  1. Deploy the migration first, before the code that uses it (additive changes)
  2. Remove old columns in a separate deployment after the new code is running
  3. For large tables, use --fake for the deployment, then run the actual migration during a maintenance window

Most applications do not need this complexity until they reach significant scale.

Zero-Downtime Deployment with Multiple Workers

Gunicorn's graceful reload (-HUP signal) reloads workers one at a time:

# Instead of: sudo systemctl restart gunicorn
sudo kill -HUP $(cat /run/gunicorn/gunicorn.pid)

Replace systemctl restart gunicorn in the deploy script with this command. The old workers continue handling requests while new workers load the updated code.

For true zero-downtime with blue/green deployments: use an Application Load Balancer and deploy to a new set of EC2 instances, then switch traffic.

If you need a CI/CD pipeline set up for your Django application — with tests, linting, and automated deployment — hire me as a Django developer or as an AWS developer to get it running.

Chat with me on WhatsApp