Why CI/CD Changed My Development Process

Before I set up proper CI/CD, deploying a new feature meant: SSH into the server, pull the latest code, hope nothing breaks, restart Gunicorn. If something broke at 11pm, it was a bad night.

Now every push to main goes through automated tests, gets built, and deploys itself. Here's the workflow.

The GitHub Actions Workflow

# .github/workflows/deploy.yml
name: Test and Deploy

on:
  push:
    branches: [main]

jobs:
  test:
    runs-on: ubuntu-latest
    services:
      postgres:
        image: postgres:15
        env:
          POSTGRES_DB: testdb
          POSTGRES_PASSWORD: testpass
        options: >-
          --health-cmd pg_isready --health-interval 10s

    steps:
      - uses: actions/checkout@v4
      - uses: actions/setup-python@v5
        with: { python-version: "3.11" }
      - run: pip install -r requirements.txt
      - run: python manage.py test
        env:
          DATABASE_URL: postgres://postgres:testpass@localhost/testdb

  deploy:
    needs: test
    runs-on: ubuntu-latest
    steps:
      - uses: actions/checkout@v4

      - name: Deploy via SSH
        uses: appleboy/ssh-action@v1
        with:
          host: ${{ secrets.SERVER_HOST }}
          username: ${{ secrets.SERVER_USER }}
          key: ${{ secrets.SSH_PRIVATE_KEY }}
          script: |
            cd /var/www/myapp
            git pull origin main
            source venv/bin/activate
            pip install -r requirements.txt --quiet
            python manage.py migrate --no-input
            python manage.py collectstatic --no-input
            sudo systemctl reload gunicorn

Zero-Downtime with systemctl reload

The key is reload not restart. Gunicorn handles the reload gracefully - in-flight requests complete before workers are replaced.

Secrets to Set in GitHub

I've been running this setup for 8 months across 4 client projects. Total production incidents from bad deploys: zero.