Build a complete CI/CD pipeline: automated tests, linting, security scans, Docker builds, and zero-downtime deploys. Reusable workflows, matrix builds, and deployment strategies.
A good CI/CD pipeline catches bugs before merge and deploys without drama. Here's a production-grade setup for Django.
Push → Lint → Test → Security Scan → Build Image → Deploy (staging) → Deploy (prod)
Each step gates the next. If tests fail, nothing ships.
.github/
workflows/
ci.yml # runs on every push/PR
deploy.yml # runs on main branch / tags
scheduled.yml # nightly security scans
# .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
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 }}.*"
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
# .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
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.
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
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.
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'
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
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).
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.
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.
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 }}
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
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
ubuntu-latest (free) over macos (expensive)paths filters to skip workflows when irrelevant files change:on:
push:
paths-ignore:
- 'docs/**'
- '*.md'
A well-tuned pipeline makes shipping boring. That's the goal.