CI/CD Pipelines for Django: Testing, Linting, and Deployment Automation
Automating Everything from Commit to Production

I once worked on a team that deployed Django applications manually. Every release was a ritual — SSH into the server, git pull, run migrations, restart services, pray nothing breaks.
It took 45 minutes. It happened twice a week. And roughly once a month, someone would forget a step, and production would go down.
Then we implemented CI/CD. Commits triggered automatic tests. Merges to main deployed automatically. What used to take 45 minutes of focused attention now took zero — the pipeline handled everything.
Today, I’ll show you how to build a complete CI/CD pipeline for Django using GitHub Actions. By the end, you’ll have automated testing, linting, security scanning, and deployment that runs on every push.
Let’s automate everything.
What We’re Building
A complete pipeline that:
On every push: Runs linting, type checking, and tests
On pull requests: All of the above plus coverage reports and security scans
On merge to main: All of the above plus automatic deployment to staging
On release tag: Deploy to production
Project Structure
myproject/
├── .github/
│ └── workflows/
│ ├── ci.yml # Main CI pipeline
│ ├── deploy-staging.yml # Staging deployment
│ └── deploy-prod.yml # Production deployment
├── pyproject.toml # Ruff, pytest, coverage config
├── Makefile
├── Dockerfile
├── docker-compose.yml
└── ...The Main CI Pipeline
# .github/workflows/ci.yml
name: CI
on:
push:
branches: [main, develop]
pull_request:
branches: [main, develop]
env:
PYTHON_VERSION: “3.12”
DJANGO_SETTINGS_MODULE: config.settings.testing
jobs:
# ==========================================
# Job 1: Linting and Code Quality
# ==========================================
lint:
name: Lint & Format
runs-on: ubuntu-latest
steps:
- name: Checkout code
uses: actions/checkout@v4
- name: Set up Python
uses: actions/setup-python@v5
with:
python-version: ${{ env.PYTHON_VERSION }}
- name: Cache pip dependencies
uses: actions/cache@v4
with:
path: ~/.cache/pip
key: ${{ runner.os }}-pip-${{ hashFiles(’requirements/*.txt’) }}
restore-keys: |
${{ runner.os }}-pip-
- name: Install dependencies
run: |
python -m pip install --upgrade pip
pip install ruff mypy
- name: Run Ruff linter
run: ruff check . --output-format=github
- name: Run Ruff formatter check
run: ruff format . --check
- name: Run MyPy type checker
run: mypy apps/ --ignore-missing-imports
continue-on-error: true # Don’t fail build on type errors (initially)
# ==========================================
# Job 2: Security Scanning
# ==========================================
security:
name: Security Scan
runs-on: ubuntu-latest
steps:
- name: Checkout code
uses: actions/checkout@v4
- name: Set up Python
uses: actions/setup-python@v5
with:
python-version: ${{ env.PYTHON_VERSION }}
- name: Install dependencies
run: |
python -m pip install --upgrade pip
pip install bandit safety pip-audit
- name: Run Bandit security linter
run: bandit -r apps/ -ll -ii -x tests
- name: Check dependencies for vulnerabilities
run: pip-audit -r requirements/base.txt
continue-on-error: true # Don’t fail on vulnerability warnings
- name: Run Safety check
run: safety check -r requirements/base.txt --full-report
continue-on-error: true
# ==========================================
# Job 3: Run Tests
# ==========================================
test:
name: Test (Python ${{ matrix.python-version }})
runs-on: ubuntu-latest
needs: [lint] # Only run tests if linting passes
strategy:
fail-fast: false
matrix:
python-version: [”3.11”, “3.12”]
services:
postgres:
image: postgres:16-alpine
env:
POSTGRES_USER: testuser
POSTGRES_PASSWORD: testpass
POSTGRES_DB: testdb
ports:
- 5432:5432
options: >-
--health-cmd pg_isready
--health-interval 10s
--health-timeout 5s
--health-retries 5
redis:
image: redis:7-alpine
ports:
- 6379:6379
options: >-
--health-cmd “redis-cli ping”
--health-interval 10s
--health-timeout 5s
--health-retries 5
steps:
- name: Checkout code
uses: actions/checkout@v4
- name: Set up Python ${{ matrix.python-version }}
uses: actions/setup-python@v5
with:
python-version: ${{ matrix.python-version }}
- name: Cache pip dependencies
uses: actions/cache@v4
with:
path: ~/.cache/pip
key: ${{ runner.os }}-pip-${{ matrix.python-version }}-${{ hashFiles(’requirements/*.txt’) }}
restore-keys: |
${{ runner.os }}-pip-${{ matrix.python-version }}-
- name: Install dependencies
run: |
python -m pip install --upgrade pip
pip install -r requirements/testing.txt
- name: Run migrations
env:
DATABASE_URL: postgres://testuser:testpass@localhost:5432/testdb
REDIS_URL: redis://localhost:6379/0
SECRET_KEY: test-secret-key
run: python manage.py migrate --noinput
- name: Run tests with coverage
env:
DATABASE_URL: postgres://testuser:testpass@localhost:5432/testdb
REDIS_URL: redis://localhost:6379/0
SECRET_KEY: test-secret-key
run: |
pytest --cov=apps --cov-report=xml --cov-report=html -v
- name: Upload coverage to Codecov
uses: codecov/codecov-action@v4
with:
token: ${{ secrets.CODECOV_TOKEN }}
files: ./coverage.xml
fail_ci_if_error: false
- name: Upload coverage report
uses: actions/upload-artifact@v4
with:
name: coverage-report-${{ matrix.python-version }}
path: htmlcov/
# ==========================================
# Job 4: Build Docker Image
# ==========================================
build:
name: Build Docker Image
runs-on: ubuntu-latest
needs: [test, security]
if: github.event_name == ‘push’ && github.ref == ‘refs/heads/main’
outputs:
image_tag: ${{ steps.meta.outputs.tags }}
steps:
- name: Checkout code
uses: actions/checkout@v4
- name: Set up Docker Buildx
uses: docker/setup-buildx-action@v3
- name: Login to Container Registry
uses: docker/login-action@v3
with:
registry: ghcr.io
username: ${{ github.actor }}
password: ${{ secrets.GITHUB_TOKEN }}
- name: Extract metadata
id: meta
uses: docker/metadata-action@v5
with:
images: ghcr.io/${{ github.repository }}
tags: |
type=sha,prefix=
type=ref,event=branch
type=raw,value=latest,enable=${{ github.ref == ‘refs/heads/main’ }}
- name: Build and push Docker image
uses: docker/build-push-action@v5
with:
context: .
target: production
push: true
tags: ${{ steps.meta.outputs.tags }}
labels: ${{ steps.meta.outputs.labels }}
cache-from: type=gha
cache-to: type=gha,mode=max
# ==========================================
# Job 5: Deploy to Staging
# ==========================================
deploy-staging:
name: Deploy to Staging
runs-on: ubuntu-latest
needs: [build]
if: github.event_name == ‘push’ && github.ref == ‘refs/heads/main’
environment:
name: staging
url: https://staging.yourdomain.com
steps:
- name: Checkout code
uses: actions/checkout@v4
- name: Deploy to staging server
uses: appleboy/ssh-action@v1.0.3
with:
host: ${{ secrets.STAGING_HOST }}
username: ${{ secrets.STAGING_USER }}
key: ${{ secrets.STAGING_SSH_KEY }}
script: |
cd /opt/myapp
docker compose -f docker-compose.staging.yml pull
docker compose -f docker-compose.staging.yml up -d --remove-orphans
docker compose -f docker-compose.staging.yml exec -T web python manage.py migrate --noinput
docker image prune -f
- name: Verify deployment
run: |
sleep 10
curl -f https://staging.yourdomain.com/health/ || exit 1
- name: Notify Slack on success
if: success()
uses: slackapi/slack-github-action@v1.25.0
with:
payload: |
{
“text”: “✅ Staging deployment successful”,
“blocks”: [
{
“type”: “section”,
“text”: {
“type”: “mrkdwn”,
“text”: “✅ *Staging Deployment Successful*\n*Commit:* ${{ github.sha }}\n*Author:* ${{ github.actor }}”
}
}
]
}
env:
SLACK_WEBHOOK_URL: ${{ secrets.SLACK_WEBHOOK_URL }}
- name: Notify Slack on failure
if: failure()
uses: slackapi/slack-github-action@v1.25.0
with:
payload: |
{
“text”: “❌ Staging deployment failed”,
“blocks”: [
{
“type”: “section”,
“text”: {
“type”: “mrkdwn”,
“text”: “❌ *Staging Deployment Failed*\n*Commit:* ${{ github.sha }}\n*Author:* ${{ github.actor }}\n*Check logs:* ${{ github.server_url }}/${{ github.repository }}/actions/runs/${{ github.run_id }}”
}
}
]
}
env:
SLACK_WEBHOOK_URL: ${{ secrets.SLACK_WEBHOOK_URL }}Production Deployment Pipeline
# .github/workflows/deploy-prod.yml
name: Deploy to Production
on:
release:
types: [published]
workflow_dispatch:
inputs:
tag:
description: ‘Tag to deploy’
required: true
env:
PYTHON_VERSION: “3.12”
jobs:
deploy:
name: Deploy to Production
runs-on: ubuntu-latest
environment:
name: production
url: https://yourdomain.com
steps:
- name: Checkout code
uses: actions/checkout@v4
with:
ref: ${{ github.event.release.tag_name || github.event.inputs.tag }}
- name: Login to Container Registry
uses: docker/login-action@v3
with:
registry: ghcr.io
username: ${{ github.actor }}
password: ${{ secrets.GITHUB_TOKEN }}
- name: Set up Docker Buildx
uses: docker/setup-buildx-action@v3
- name: Build and push production image
uses: docker/build-push-action@v5
with:
context: .
target: production
push: true
tags: |
ghcr.io/${{ github.repository }}:${{ github.event.release.tag_name || github.event.inputs.tag }}
ghcr.io/${{ github.repository }}:production
cache-from: type=gha
cache-to: type=gha,mode=max
- name: Deploy to production server
uses: appleboy/ssh-action@v1.0.3
with:
host: ${{ secrets.PRODUCTION_HOST }}
username: ${{ secrets.PRODUCTION_USER }}
key: ${{ secrets.PRODUCTION_SSH_KEY }}
script: |
cd /opt/myapp
# Pull new image
docker compose -f docker-compose.prod.yml pull
# Create backup point
docker compose -f docker-compose.prod.yml exec -T db pg_dump -U $DB_USER $DB_NAME > /backups/pre-deploy-$(date +%Y%m%d-%H%M%S).sql
# Deploy with zero downtime
docker compose -f docker-compose.prod.yml up -d --remove-orphans --scale web=2
sleep 10
# Run migrations
docker compose -f docker-compose.prod.yml exec -T web python manage.py migrate --noinput
# Scale back down
docker compose -f docker-compose.prod.yml up -d --scale web=1
# Cleanup
docker image prune -f
- name: Verify deployment
run: |
sleep 15
response=$(curl -s -o /dev/null -w “%{http_code}” https://yourdomain.com/health/)
if [ “$response” != “200” ]; then
echo “Health check failed with status $response”
exit 1
fi
echo “Health check passed”
- name: Notify team
uses: slackapi/slack-github-action@v1.25.0
with:
payload: |
{
“text”: “🚀 Production deployment complete”,
“blocks”: [
{
“type”: “section”,
“text”: {
“type”: “mrkdwn”,
“text”: “🚀 *Production Deployment Complete*\n*Version:* ${{ github.event.release.tag_name || github.event.inputs.tag }}\n*Deployed by:* ${{ github.actor }}”
}
}
]
}
env:
SLACK_WEBHOOK_URL: ${{ secrets.SLACK_WEBHOOK_URL }}
rollback:
name: Rollback (if needed)
runs-on: ubuntu-latest
needs: [deploy]
if: failure()
environment: production
steps:
- name: Rollback to previous version
uses: appleboy/ssh-action@v1.0.3
with:
host: ${{ secrets.PRODUCTION_HOST }}
username: ${{ secrets.PRODUCTION_USER }}
key: ${{ secrets.PRODUCTION_SSH_KEY }}
script: |
cd /opt/myapp
# Get previous image
PREVIOUS_IMAGE=$(docker images ghcr.io/${{ github.repository }} --format “{{.Tag}}” | grep -v production | head -2 | tail -1)
# Rollback
docker compose -f docker-compose.prod.yml down
docker tag ghcr.io/${{ github.repository }}:$PREVIOUS_IMAGE ghcr.io/${{ github.repository }}:production
docker compose -f docker-compose.prod.yml up -d
- name: Notify rollback
uses: slackapi/slack-github-action@v1.25.0
with:
payload: |
{
“text”: “⚠️ Production rollback executed”,
“blocks”: [
{
“type”: “section”,
“text”: {
“type”: “mrkdwn”,
“text”: “⚠️ *Production Rollback Executed*\n*Failed version:* ${{ github.event.release.tag_name || github.event.inputs.tag }}\n*Action required:* Investigate failure”
}
}
]
}
env:
SLACK_WEBHOOK_URL: ${{ secrets.SLACK_WEBHOOK_URL }}Configuration Files
pyproject.toml
# pyproject.toml
[tool.ruff]
target-version = “py312”
line-length = 120
exclude = [
“.git”,
“.venv”,
“venv”,
“__pycache__”,
“migrations”,
“staticfiles”,
]
[tool.ruff.lint]
select = [
“E”, # pycodestyle errors
“W”, # pycodestyle warnings
“F”, # Pyflakes
“I”, # isort
“B”, # flake8-bugbear
“C4”, # flake8-comprehensions
“UP”, # pyupgrade
“DJ”, # flake8-django
]
ignore = [
“E501”, # line too long (handled by formatter)
“B008”, # do not perform function calls in argument defaults
]
[tool.ruff.lint.isort]
known-first-party = [”apps”, “config”]
[tool.ruff.format]
quote-style = “single”
indent-style = “space”
[tool.pytest.ini_options]
DJANGO_SETTINGS_MODULE = “config.settings.testing”
python_files = [”test_*.py”, “*_test.py”]
addopts = [
“--strict-markers”,
“--tb=short”,
“-ra”,
]
markers = [
“slow: marks tests as slow”,
“integration: marks tests as integration tests”,
]
[tool.coverage.run]
source = [”apps”]
branch = true
omit = [
“*/migrations/*”,
“*/tests/*”,
“*/__init__.py”,
]
[tool.coverage.report]
exclude_lines = [
“pragma: no cover”,
“def __repr__”,
“raise NotImplementedError”,
“if TYPE_CHECKING:”,
“if __name__ == .__main__.:”,
]
fail_under = 80
show_missing = true
[tool.mypy]
python_version = “3.12”
plugins = [”mypy_django_plugin.main”]
ignore_missing_imports = true
strict = false
[tool.django-stubs]
django_settings_module = “config.settings.testing”Testing Settings
# config/settings/testing.py
from .base import *
DEBUG = False
SECRET_KEY = ‘test-secret-key-not-for-production’
# Use faster password hasher for tests
PASSWORD_HASHERS = [
‘django.contrib.auth.hashers.MD5PasswordHasher’,
]
# In-memory cache for tests
CACHES = {
‘default’: {
‘BACKEND’: ‘django.core.cache.backends.locmem.LocMemCache’,
}
}
# Faster email backend
EMAIL_BACKEND = ‘django.core.mail.backends.locmem.EmailBackend’
# Disable migrations for faster tests (optional)
# class DisableMigrations:
# def __contains__(self, item):
# return True
# def __getitem__(self, item):
# return None
# MIGRATION_MODULES = DisableMigrations()
# Database
DATABASES = {
‘default’: {
‘ENGINE’: ‘django.db.backends.postgresql’,
‘NAME’: ‘testdb’,
‘USER’: ‘testuser’,
‘PASSWORD’: ‘testpass’,
‘HOST’: ‘localhost’,
‘PORT’: ‘5432’,
}
}
# Celery: run tasks synchronously in tests
CELERY_TASK_ALWAYS_EAGER = True
CELERY_TASK_EAGER_PROPAGATES = TrueTesting Requirements
# requirements/testing.txt
-r base.txt
# Testing
pytest>=8.0.0
pytest-django>=4.8.0
pytest-cov>=4.1.0
pytest-xdist>=3.5.0 # Parallel test execution
pytest-mock>=3.12.0
factory-boy>=3.3.0
faker>=22.0.0
# Code quality
ruff>=0.2.0
mypy>=1.8.0
django-stubs>=4.2.7
# Security scanning
bandit>=1.7.7
safety>=3.0.0
pip-audit>=2.7.0Pytest Configuration with Fixtures
# conftest.py
import pytest
from django.contrib.auth import get_user_model
from rest_framework.test import APIClient
User = get_user_model()
@pytest.fixture
def api_client():
“”“Return an API client for making requests.”“”
return APIClient()
@pytest.fixture
def user(db):
“”“Create and return a test user.”“”
return User.objects.create_user(
email=’test@example.com’,
password=’testpass123’,
first_name=’Test’,
last_name=’User’,
)
@pytest.fixture
def authenticated_client(api_client, user):
“”“Return an authenticated API client.”“”
api_client.force_authenticate(user=user)
return api_client
@pytest.fixture
def admin_user(db):
“”“Create and return an admin user.”“”
return User.objects.create_superuser(
email=’admin@example.com’,
password=’adminpass123’,
)
@pytest.fixture
def admin_client(api_client, admin_user):
“”“Return an authenticated admin API client.”“”
api_client.force_authenticate(user=admin_user)
return api_client
# Factories
@pytest.fixture
def user_factory(db):
“”“Factory for creating users.”“”
def create_user(**kwargs):
defaults = {
‘email’: f’user{User.objects.count()}@example.com’,
‘password’: ‘testpass123’,
}
defaults.update(kwargs)
return User.objects.create_user(**defaults)
return create_userFactory Boy Examples
# apps/users/tests/factories.py
import factory
from faker import Faker
from apps.users.models import User
fake = Faker()
class UserFactory(factory.django.DjangoModelFactory):
class Meta:
model = User
email = factory.LazyAttribute(lambda _: fake.email())
first_name = factory.LazyAttribute(lambda _: fake.first_name())
last_name = factory.LazyAttribute(lambda _: fake.last_name())
is_active = True
@factory.post_generation
def password(self, create, extracted, **kwargs):
password = extracted or ‘defaultpass123’
self.set_password(password)
if create:
self.save()
# apps/orders/tests/factories.py
import factory
from decimal import Decimal
from apps.orders.models import Order, OrderItem
from apps.users.tests.factories import UserFactory
from apps.products.tests.factories import ProductFactory
class OrderFactory(factory.django.DjangoModelFactory):
class Meta:
model = Order
customer = factory.SubFactory(UserFactory)
status = ‘pending’
total = factory.LazyAttribute(lambda _: Decimal(’99.99’))
class OrderItemFactory(factory.django.DjangoModelFactory):
class Meta:
model = OrderItem
order = factory.SubFactory(OrderFactory)
product = factory.SubFactory(ProductFactory)
quantity = factory.LazyAttribute(lambda _: fake.random_int(min=1, max=5))
unit_price = factory.LazyAttribute(lambda _: Decimal(fake.random_int(min=10, max=100)))GitHub Secrets Setup
Configure these secrets in your repository (Settings → Secrets and variables → Actions):
# Container Registry
GITHUB_TOKEN # Automatically available
# Codecov
CODECOV_TOKEN # From codecov.io
# Staging Environment
STAGING_HOST # staging.yourdomain.com
STAGING_USER # deploy
STAGING_SSH_KEY # Private SSH key
# Production Environment
PRODUCTION_HOST # yourdomain.com
PRODUCTION_USER # deploy
PRODUCTION_SSH_KEY # Private SSH key
# Notifications
SLACK_WEBHOOK_URL # Slack incoming webhook URL
# Application Secrets (for environments)
SECRET_KEY # Django secret key
DATABASE_URL # Production database URLGitHub Environments
Set up environments for staging and production:
Go to Settings → Environments
Create
stagingandproductionenvironmentsFor production, add:
Required reviewers
Wait timer (optional)
Deployment branches: only
mainor tags
Local Development Workflow
# Install pre-commit hooks
pip install pre-commit
pre-commit install
# Run linting locally
ruff check .
ruff format .
# Run tests locally
pytest
# Run tests with coverage
pytest --cov=apps --cov-report=html
# Run specific test file
pytest apps/users/tests/test_views.py -v
# Run tests in parallel
pytest -n autoPre-commit Configuration
# .pre-commit-config.yaml
repos:
- repo: https://github.com/astral-sh/ruff-pre-commit
rev: v0.2.0
hooks:
- id: ruff
args: [--fix]
- id: ruff-format
- repo: https://github.com/pre-commit/pre-commit-hooks
rev: v4.5.0
hooks:
- id: trailing-whitespace
- id: end-of-file-fixer
- id: check-yaml
- id: check-added-large-files
- id: check-merge-conflict
- repo: local
hooks:
- id: django-check
name: Django check
entry: python manage.py check
language: system
pass_filenames: false
always_run: trueMakefile for CI Commands
# Makefile
.PHONY: lint test coverage security
# Linting
lint:
ruff check .
ruff format --check .
lint-fix:
ruff check . --fix
ruff format .
# Type checking
typecheck:
mypy apps/
# Testing
test:
pytest
test-fast:
pytest -x -q
test-coverage:
pytest --cov=apps --cov-report=html --cov-report=term
test-parallel:
pytest -n auto
# Security
security:
bandit -r apps/ -ll
pip-audit -r requirements/base.txt
# All checks (mirrors CI)
ci: lint typecheck test security
@echo “All CI checks passed!”Key Takeaways
1. Automate everything — If you do it twice, automate it.
2. Fail fast — Run linting before tests. Don’t waste CI minutes on broken code.
3. Use caching — Cache pip dependencies to speed up pipelines.
4. Test in parallel — Use matrix strategy for multiple Python versions.
5. Separate staging and production — Different pipelines, different approval processes.
6. Add health checks — Verify deployments actually work.
7. Notify on failure — Slack/email alerts prevent silent failures.
8. Enable rollbacks — Production deployments should be reversible.
9. Run security scans — Catch vulnerabilities before they reach production.
10. Use pre-commit locally — Catch issues before they hit CI.
CI/CD transforms deployment from a risky manual process into a boring automated one. Boring is good. Boring means reliable. Build your pipeline once, and every deployment becomes a non-event.




Fantastic practical guide here. The 45-minute manual deploy ritual is way too relatable. One thing worth calling out is how the rollback job isolates failure recovery from the main deploy flow rather than trying to handle both in one massive script. When I was setting up similar pipelines, cramming rollback logic into the deploy job itself made debugging a nightmare beacuse failures would leave the system in half-reverted states.