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
SERVER_HOST- your VPS IPSERVER_USER- usuallyubuntuorrootSSH_PRIVATE_KEY- private key (add the public key to~/.ssh/authorized_keyson the server)
I've been running this setup for 8 months across 4 client projects. Total production incidents from bad deploys: zero.