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: Deploy
on:
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/bash
set -e
cd ~/expense-app
git pull origin main
docker compose build web
docker compose run --rm web python manage.py migrate --noinput
docker compose run --rm web python manage.py collectstatic --noinput
docker compose up -d web
curl -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

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