Episode 16 packaged the expense-tracker app into a Docker image that runs as a non-root user with a healthcheck. This episode picks up exactly there: automating the path from pushing code to running it on the server. Every push to the main branch runs tests first in GitHub Actions, then deploys automatically — running migrations, collectstatic, and restarting the service. The result: one push becomes one deploy, no more manual steps on the server, and a failing test blocks the deploy before it ever touches production.
What Steps Make Up the Pipeline?
A standard deploy pipeline for a Django app has four steps in sequence. The test step runs pytest and checks for missing migrations. The deploy step pulls the new code onto the server, rebuilds the Docker image, then runs migrations, collectstatic, and a restart. The final check calls the /health endpoint — built in episode 8 — to confirm the new app is running before the deploy counts as done.
- Test: pytest, ruff, makemigrations –check –dry-run.
- Build: docker compose build a new image from the code just pulled.
- Deploy: SSH into the server, run migrations, collectstatic, then restart the container.
- Verify: curl /health/ and watch the logs for the first 5 minutes.
Writing the Test and Deploy Workflow
The .github/workflows/deploy.yml file below runs on every push to main. The test job uses Python 3.15 and a PostgreSQL service container — the same production stack chosen in episode 4. Only when the tests pass does the deploy job run, using appleboy/ssh-action to SSH into the server and call the deploy script.
name: Deployon: push: branches: [main]jobs: test: runs-on: ubuntu-latest services: postgres: image: postgres:16 env: POSTGRES_DB: expense_test POSTGRES_USER: postgres POSTGRES_PASSWORD: postgres ports: ["5432:5432"] options: >- --health-cmd pg_isready --health-interval 10s --health-timeout 5s --health-retries 5 env: DATABASE_URL: postgres://postgres:postgres@localhost:5432/expense_test steps: - uses: actions/checkout@v4 - uses: actions/setup-python@v5 with: python-version: "3.15" - run: pip install -r requirements.txt - run: python manage.py makemigrations --check --dry-run - run: python manage.py test deploy: needs: test runs-on: ubuntu-latest steps: - uses: appleboy/ssh-action@v1 with: host: ${{ secrets.SERVER_HOST }} username: ${{ secrets.SERVER_USER }} key: ${{ secrets.SERVER_SSH_KEY }} script: bash ~/deploy.sh
The Deploy Script on the Server
The deploy.sh script lives on the server and does exactly four things, in this order: pull the new image, run migrations, collectstatic, then restart the container. Migrations run before the new app takes requests — following the backward-compatibility principle from episode 6 — so the deploy has no downtime.
#!/bin/bashset -ecd ~/expense-appgit pull origin maindocker compose build webdocker compose run --rm web python manage.py migrate --noinputdocker compose run --rm web python manage.py collectstatic --noinputdocker compose up -d webcurl -fsS http://localhost:8000/health/
Three Common Pitfalls
The three most common mistakes when first setting up CI/CD can each be avoided with a single line of configuration.
- Forgetting –noinput on collectstatic: the command stops waiting for manual input and the job hangs until it times out. Always add –noinput.
- Secrets leaking into logs: never echo environment variables or print the .env file. GitHub masks registered secrets automatically, but only when the value matches exactly.
- Deploying even when tests fail: the deploy job must have needs: test, otherwise the two jobs run in parallel and broken code still reaches production.
Putting It Into Practice
For the expense-tracker app, start from the minimal pipeline: just test and deploy, without building a separate image if the server is still running code from git. Create one workflow that runs pytest on every pull request first — your team will see the value the first time a red test blocks a bad deploy. Once the pipeline is stable, add the image build and health check as above, and only then think about blue-green in episode 18.
Related Posts
- Django in Production #16: Docker for Production — Multi-Stage Builds, Non-Root Runtime, Healthchecks
- Django Production Operations #6: Safe Migrations in Production — Backward-Compatible Patterns, Zero-Downtime Deploys
- Django Running in Production #8: Monitoring and Health Checks — /health Endpoint, Uptime, Metrics with django-prometheus
FAQ
If tests fail, does the deploy still run?
No. The deploy job has needs: test, so GitHub Actions skips the deploy when tests are red. Broken code stops at the test step and production is never touched — that is the biggest value of this pipeline.
When do migrations run during a deploy?
Migrations run on the server after the new image is pulled and before the container restarts. This order keeps the old app working with the new schema during the switch, following the backward-compatible pattern from episode 6. Never run migrations on your local machine and commit the db.sqlite3 file.
Do I need to build a new image on every deploy?
Yes, if the server runs on Docker as in episode 16. Every deploy rebuilds the image from the latest code so you know exactly which code is running, and keeps the old image around for a rollback by running it again. If the server runs code directly from git, a git pull and restart is enough — but you should move to images for consistent deploys.
Sources
- GitHub Actions — https://docs.github.com/en/actions
- Docker — https://docs.docker.com
- appleboy/ssh-action — https://github.com/appleboy/ssh-action
