By KFSys
System Administrator
A comprehensive guide to building a production-ready CI/CD pipeline for Django applications using GitHub Actions.
A complete CI/CD pipeline that:
This textbox defaults to using Markdown to format your answer.
You can type !ref in this text area to quickly search our full set of tutorials, documentation & marketplace offerings and insert the link!
Accepted Answer
your-django-project/
├── .github/
│ └── workflows/
│ ├── ci.yml
│ ├── cd-staging.yml
│ └── cd-production.yml
├── app/
├── config/
├── requirements/
│ ├── base.txt
│ ├── development.txt
│ ├── production.txt
│ └── testing.txt
├── tests/
├── manage.py
├── pytest.ini
├── .coveragerc
└── docker-compose.yml
requirements/base.txt
Django>=4.2,<5.0
psycopg2-binary>=2.9
python-decouple>=3.8
gunicorn>=21.2
whitenoise>=6.6
requirements/testing.txt
-r base.txt
pytest>=7.4
pytest-django>=4.5
pytest-cov>=4.1
factory-boy>=3.3
faker>=20.0
coverage>=7.3
requirements/development.txt
-r testing.txt
black>=23.11
flake8>=6.1
isort>=5.12
pylint>=3.0
django-debug-toolbar>=4.2
Create .env.example:
DEBUG=False
SECRET_KEY=your-secret-key-here
DATABASE_URL=postgresql://user:password@localhost:5432/dbname
ALLOWED_HOSTS=localhost,127.0.0.1
DJANGO_SETTINGS_MODULE=config.settings.production
Create .github/workflows/ci.yml:
name: Django CI
on:
push:
branches: [ main, develop ]
pull_request:
branches: [ main, develop ]
jobs:
test:
name: Test Suite
runs-on: ubuntu-latest
services:
postgres:
image: postgres:15
env:
POSTGRES_USER: postgres
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
options: >-
--health-cmd "redis-cli ping"
--health-interval 10s
--health-timeout 5s
--health-retries 5
strategy:
matrix:
python-version: ['3.10', '3.11', '3.12']
steps:
- name: Checkout code
uses: actions/checkout@v4
- name: Set up Python ${{ matrix.python-version }}
uses: actions/setup-python@v4
with:
python-version: ${{ matrix.python-version }}
cache: 'pip'
- name: Install dependencies
run: |
python -m pip install --upgrade pip
pip install -r requirements/testing.txt
- name: Run migrations
env:
DATABASE_URL: postgresql://postgres:postgres@localhost:5432/test_db
SECRET_KEY: test-secret-key-for-ci
DEBUG: 'True'
run: |
python manage.py migrate --no-input
- name: Run tests with coverage
env:
DATABASE_URL: postgresql://postgres:postgres@localhost:5432/test_db
SECRET_KEY: test-secret-key-for-ci
DEBUG: 'True'
run: |
pytest --cov=. --cov-report=xml --cov-report=html --cov-report=term
- name: Upload coverage to Codecov
uses: codecov/codecov-action@v3
with:
file: ./coverage.xml
fail_ci_if_error: false
- name: Archive coverage report
uses: actions/upload-artifact@v3
with:
name: coverage-report
path: htmlcov/
lint:
name: Code Quality
runs-on: ubuntu-latest
steps:
- name: Checkout code
uses: actions/checkout@v4
- name: Set up Python
uses: actions/setup-python@v4
with:
python-version: '3.11'
cache: 'pip'
- name: Install dependencies
run: |
python -m pip install --upgrade pip
pip install -r requirements/development.txt
- name: Run Black
run: black --check .
- name: Run isort
run: isort --check-only --profile black .
- name: Run flake8
run: flake8 . --max-line-length=88 --extend-ignore=E203,W503
- name: Run pylint
run: pylint **/*.py --disable=C0111,R0903 || true
security:
name: Security Checks
runs-on: ubuntu-latest
steps:
- name: Checkout code
uses: actions/checkout@v4
- name: Set up Python
uses: actions/setup-python@v4
with:
python-version: '3.11'
- name: Install dependencies
run: |
python -m pip install --upgrade pip
pip install safety bandit
pip install -r requirements/base.txt
- name: Run Safety check
run: safety check --json || true
- name: Run Bandit
run: bandit -r . -f json -o bandit-report.json || true
- name: Upload security reports
uses: actions/upload-artifact@v3
with:
name: security-reports
path: |
bandit-report.json
build:
name: Build Check
runs-on: ubuntu-latest
needs: [test, lint, security]
steps:
- name: Checkout code
uses: actions/checkout@v4
- name: Set up Python
uses: actions/setup-python@v4
with:
python-version: '3.11'
- name: Install dependencies
run: |
python -m pip install --upgrade pip
pip install -r requirements/production.txt
- name: Collect static files
env:
SECRET_KEY: test-secret-key
DEBUG: 'False'
DATABASE_URL: sqlite:///db.sqlite3
run: |
python manage.py collectstatic --no-input
- name: Check deployment settings
env:
SECRET_KEY: test-secret-key
DEBUG: 'False'
run: |
python manage.py check --deploy --fail-level WARNING
Create pytest.ini:
[pytest]
DJANGO_SETTINGS_MODULE = config.settings.testing
python_files = tests.py test_*.py *_tests.py
addopts =
--strict-markers
--strict-config
--cov=.
--cov-branch
--cov-report=term-missing:skip-covered
--cov-report=html
--cov-report=xml
--no-cov-on-fail
-ra
--showlocals
--tb=short
testpaths = tests
markers =
slow: marks tests as slow
integration: marks tests as integration tests
unit: marks tests as unit tests
Create .coveragerc:
[run]
source = .
omit =
*/migrations/*
*/tests/*
*/test_*.py
*/__pycache__/*
*/venv/*
*/virtualenv/*
manage.py
*/wsgi.py
*/asgi.py
[report]
exclude_lines =
pragma: no cover
def __repr__
raise AssertionError
raise NotImplementedError
if __name__ == .__main__.:
if TYPE_CHECKING:
@abstractmethod
Create .github/workflows/cd-staging.yml:
name: Deploy to Staging
on:
push:
branches: [ develop ]
workflow_dispatch:
jobs:
deploy-staging:
name: Deploy to Staging Environment
runs-on: ubuntu-latest
environment:
name: staging
url: https://your-app-staging.railway.app
steps:
- name: Checkout code
uses: actions/checkout@v4
- name: Deploy to Railway (Staging)
uses: bervProject/railway-deploy@main
with:
railway_token: ${{ secrets.RAILWAY_TOKEN_STAGING }}
service: your-app-staging
- name: Wait for deployment
run: sleep 30
- name: Run database migrations
run: |
# This depends on your platform's CLI
railway run python manage.py migrate --no-input
env:
RAILWAY_TOKEN: ${{ secrets.RAILWAY_TOKEN_STAGING }}
- name: Health check
run: |
curl --fail https://your-app-staging.railway.app/health/ || exit 1
- name: Notify deployment
if: always()
uses: 8398a7/action-slack@v3
with:
status: ${{ job.status }}
text: 'Staging deployment ${{ job.status }}'
webhook_url: ${{ secrets.SLACK_WEBHOOK }}
Create .github/workflows/cd-production.yml:
name: Deploy to Production
on:
push:
branches: [ main ]
tags:
- 'v*'
workflow_dispatch:
jobs:
deploy-production:
name: Deploy to Production
runs-on: ubuntu-latest
environment:
name: production
url: https://your-app.com
steps:
- name: Checkout code
uses: actions/checkout@v4
- name: Create deployment
uses: chrnorm/deployment-action@v2
id: deployment
with:
token: ${{ secrets.GITHUB_TOKEN }}
environment: production
- name: Deploy to Railway (Production)
uses: bervProject/railway-deploy@main
with:
railway_token: ${{ secrets.RAILWAY_TOKEN_PRODUCTION }}
service: your-app-production
- name: Wait for deployment
run: sleep 60
- name: Run database migrations
run: |
railway run python manage.py migrate --no-input
env:
RAILWAY_TOKEN: ${{ secrets.RAILWAY_TOKEN_PRODUCTION }}
- name: Collect static files
run: |
railway run python manage.py collectstatic --no-input
env:
RAILWAY_TOKEN: ${{ secrets.RAILWAY_TOKEN_PRODUCTION }}
- name: Health check
run: |
curl --fail https://your-app.com/health/ || exit 1
- name: Update deployment status (success)
if: success()
uses: chrnorm/deployment-status@v2
with:
token: ${{ secrets.GITHUB_TOKEN }}
deployment-id: ${{ steps.deployment.outputs.deployment_id }}
state: success
environment-url: https://your-app.com
- name: Update deployment status (failure)
if: failure()
uses: chrnorm/deployment-status@v2
with:
token: ${{ secrets.GITHUB_TOKEN }}
deployment-id: ${{ steps.deployment.outputs.deployment_id }}
state: failure
- name: Notify deployment
if: always()
uses: 8398a7/action-slack@v3
with:
status: ${{ job.status }}
text: 'Production deployment ${{ job.status }}'
webhook_url: ${{ secrets.SLACK_WEBHOOK }}
- name: Create Sentry release
if: success()
uses: getsentry/action-release@v1
env:
SENTRY_AUTH_TOKEN: ${{ secrets.SENTRY_AUTH_TOKEN }}
SENTRY_ORG: your-org
SENTRY_PROJECT: your-project
with:
environment: production
Speed up your workflows with caching:
- name: Cache pip packages
uses: actions/cache@v3
with:
path: ~/.cache/pip
key: ${{ runner.os }}-pip-${{ hashFiles('**/requirements*.txt') }}
restore-keys: |
${{ runner.os }}-pip-
yaml
strategy:
matrix:
python-version: ['3.10', '3.11', '3.12']
database: ['postgresql', 'mysql']
include:
- database: postgresql
db_port: 5432
db_image: postgres:15
- database: mysql
db_port: 3306
db_image: mysql:8
- name: Run tests in parallel
run: |
pytest -n auto --dist loadscope
docker:
name: Build and Push Docker Image
runs-on: ubuntu-latest
needs: [test, lint]
steps:
- name: Checkout code
uses: actions/checkout@v4
- name: Set up Docker Buildx
uses: docker/setup-buildx-action@v3
- name: Log in to Docker Hub
uses: docker/login-action@v3
with:
username: ${{ secrets.DOCKER_USERNAME }}
password: ${{ secrets.DOCKER_PASSWORD }}
- name: Extract metadata
id: meta
uses: docker/metadata-action@v5
with:
images: yourusername/django-app
tags: |
type=ref,event=branch
type=semver,pattern={{version}}
type=sha
- name: Build and push
uses: docker/build-push-action@v5
with:
context: .
push: true
tags: ${{ steps.meta.outputs.tags }}
labels: ${{ steps.meta.outputs.labels }}
cache-from: type=registry,ref=yourusername/django-app:buildcache
cache-to: type=registry,ref=yourusername/django-app:buildcache,mode=max
Set up these secrets in your GitHub repository (Settings → Secrets and variables → Actions):
RAILWAY_TOKEN_STAGING - Railway token for stagingRAILWAY_TOKEN_PRODUCTION - Railway token for productionDOCKER_USERNAME - Docker Hub usernameDOCKER_PASSWORD - Docker Hub password/tokenSLACK_WEBHOOK - Slack webhook for notificationsSENTRY_AUTH_TOKEN - Sentry authentication tokenCODECOV_TOKEN - Codecov token for coverage reportsCreate a health check view in your Django app:
# app/views.py
from django.http import JsonResponse
from django.db import connection
from django.core.cache import cache
import redis
def health_check(request):
"""
Health check endpoint for CI/CD pipelines
"""
health_status = {
'status': 'healthy',
'checks': {}
}
# Database check
try:
with connection.cursor() as cursor:
cursor.execute("SELECT 1")
health_status['checks']['database'] = 'ok'
except Exception as e:
health_status['status'] = 'unhealthy'
health_status['checks']['database'] = f'error: {str(e)}'
# Cache check
try:
cache.set('health_check', 'ok', 10)
cache.get('health_check')
health_status['checks']['cache'] = 'ok'
except Exception as e:
health_status['status'] = 'unhealthy'
health_status['checks']['cache'] = f'error: {str(e)}'
status_code = 200 if health_status['status'] == 'healthy' else 503
return JsonResponse(health_status, status=status_code)
Add to your URLs:
# config/urls.py
from django.urls import path
from app.views import health_check
urlpatterns = [
path('health/', health_check, name='health_check'),
# ... other urls
]
# tests/test_integration.py
import pytest
from django.test import Client
from django.urls import reverse
@pytest.mark.django_db
class TestHealthCheck:
def test_health_check_endpoint(self):
client = Client()
response = client.get('/health/')
assert response.status_code == 200
assert response.json()['status'] == 'healthy'
@pytest.mark.django_db
class TestDeploymentReadiness:
def test_database_migrations(self):
from django.core.management import call_command
# This will raise an exception if there are unapplied migrations
call_command('migrate', '--check')
def test_static_files_collection(self):
from django.core.management import call_command
call_command('collectstatic', '--no-input', '--dry-run')
Set up branch protection on GitHub:
For production environment:
- name: Monitor deployment
run: |
# Check error rates, response times, etc.
# If metrics exceed threshold, trigger rollback
- name: Rollback on failure
if: failure()
run: |
railway rollback
1. Tests fail in CI but pass locally
2. Deployment fails
3. Slow pipeline
You now have a production-ready CI/CD pipeline that:
Get paid to write technical tutorials and select a tech-focused charity to receive a matching donation.
Full documentation for every DigitalOcean product.
The Wave has everything you need to know about building a business, from raising funding to marketing your product.
Stay up to date by signing up for DigitalOcean’s Infrastructure as a Newsletter.
New accounts only. By submitting your email you agree to our Privacy Policy
Scale up as you grow — whether you're running one virtual machine or ten thousand.
Sign up and get $200 in credit for your first 60 days with DigitalOcean.*
*This promotional offer applies to new accounts only.