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
- On every push/PR to any branch: run tests + lint
- On push to
mainonly: 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:
| Secret | Value |
|---|---|
EC2_HOST | Your EC2 public IP or domain |
EC2_SSH_KEY | Contents 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:
- Deploy the migration first, before the code that uses it (additive changes)
- Remove old columns in a separate deployment after the new code is running
- For large tables, use
--fakefor 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.