Filter concepts by levelShowing all levels.

Django · CI/CD

CI/CD

Cheapest gates first, build once, and keep a way back

  • Order the gates by costa two-second lint failure should never wait behind a five-minute test run
  • Test on the real engineSQLite in CI is green exactly when the schema is wrong
  • The runner is privilegedit holds credentials and runs third-party code on every push
  • Build once, promote the digesta rebuild per environment is a different artefact
  • Rollback is code, not schemaredeploying the old image only works if the migration expanded

1.The pipeline, and the gates in the right order

A pipeline is a fixed sequence that runs on every push: lint, type check, unit tests, integration tests, security scan, build, deploy, migrate, health check, smoke test. **GitHub Actions** and **GitLab CI** both describe it as YAML in your repository — jobs made of steps, running on a fresh machine each time. A **test environment** is what those tests run against: a real PostgreSQL and Redis started for the job, not mocks and not production.

Key features

  • The order is cheapest-first: lint → types → unit → integration → scan → build
  • Build once; every later stage acts on the artefact, never on the source again
  • Each gate names a different class of failure — that is what makes it a pipeline
  • Test against real PostgreSQL and Redis service containers, not SQLite and not mocks
  • `makemigrations --check --dry-run` catches the model change with no migration
  • `check --deploy` must run against the *production* settings module to mean anything

The roadmap's pipeline, with what each stage actually catches

The roadmap's pipeline, with what each stage actually catches
StageTypical durationThe failure it exists to catch
lintsecondsstyle and obvious errors, before anything costly runs
type checksecondsa signature that no longer matches its callers
unit testsa minute or twologic, with no services involved
integration testslonger — services startthe parts that only fail against a real database
security scanseconds to a minutea known-vulnerable dependency, **before** the build
build → imageminutesa build that does not reproduce, or a missing file
deploy → migrate → health → smokeminuteseverything that only exists in a real environment

GitHub Actions and GitLab CI — the same shape, two vocabularies

GitHub Actions and GitLab CI — the same shape, two vocabularies
ConceptGitHub ActionsGitLab CI
file`.github/workflows/ci.yml``.gitlab-ci.yml`
unit of worka `job`, made of `steps`a `job`, made of `script` lines
ordering`needs:``stage:` and `needs:`
services`services:` on the job`services:` on the job
reusable stepan `action` (`uses:`)an `include:` or a template
secrets`secrets.NAME`, masked in logsCI/CD variables, masked and protected
One CI job, part by part

jobs: test: runs-on: ubuntu-latest permissions: { contents: read } services: db: { image: postgres:16 } steps: - uses: actions/checkout@v4 - run: python -m pytest -q

jobs:

Jobs run in parallel by default — Independent jobs start together; `needs:` is what imposes order. Splitting lint and tests into separate jobs gives you both results at once instead of stopping at the first.

runs-on: ubuntu-latest

A fresh machine, every run — Nothing survives from the last run unless you cache it deliberately. That is what makes a green pipeline meaningful — and why "works on my machine" cannot happen here.

permissions: { contents: read }

The token this job gets — Narrow it explicitly. A job that only runs tests has no reason to be able to write to the repository, and a compromised dependency inherits exactly these permissions.

services:

Real backing services — A PostgreSQL container started for this job. Django creates and destroys its test database inside it, so constraints, transactions and migrations run against the engine production uses.

actions/checkout@v4

A pinned third-party step — A version, not a moving branch. An action runs arbitrary code in a context that holds your secrets, so what it resolves to should not change between runs without you choosing it.

python -m pytest -q

The step that is just a command — It runs the same thing you run locally. When the pipeline gets clever here — bespoke flags, CI-only paths — a green pipeline stops predicting a green machine.

  • Whole: jobs: test: runs-on: ubuntu-latest permissions: { contents: read } services: db: { image: postgres:16 } steps: - uses: actions/checkout@v4 - run: python -m pytest -q
  • jobs: — Jobs run in parallel by default: Independent jobs start together; `needs:` is what imposes order. Splitting lint and tests into separate jobs gives you both results at once instead of stopping at the first.
  • runs-on: ubuntu-latest — A fresh machine, every run: Nothing survives from the last run unless you cache it deliberately. That is what makes a green pipeline meaningful — and why "works on my machine" cannot happen here.
  • permissions: { contents: read } — The token this job gets: Narrow it explicitly. A job that only runs tests has no reason to be able to write to the repository, and a compromised dependency inherits exactly these permissions.
  • services: — Real backing services: A PostgreSQL container started for this job. Django creates and destroys its test database inside it, so constraints, transactions and migrations run against the engine production uses.
  • actions/checkout@v4 — A pinned third-party step: A version, not a moving branch. An action runs arbitrary code in a context that holds your secrets, so what it resolves to should not change between runs without you choosing it.
  • python -m pytest -q — The step that is just a command: It runs the same thing you run locally. When the pipeline gets clever here — bespoke flags, CI-only paths — a green pipeline stops predicting a green machine.

Command

bash
python manage.py makemigrations --check --dry-run   # fails if a migration is missing

Run the gates cheapest-first — lint, types, unit, integration, security scan — so a two-second failure never waits behind a five-minute one, and build once so everything afterwards acts on the artefact. Both GitHub Actions and GitLab CI are the same shape: YAML in the repository, jobs of steps, a fresh machine each run. Test against a real PostgreSQL service container, because SQLite is green exactly when the schema is wrong. Install from a lock file so a red build means your change. And add the two Django gates a test suite does not give you: `makemigrations --check --dry-run`, and `check --deploy` run against the production settings module.

2.Secrets in the pipeline, and scanning what you depend on

CI needs credentials — a registry login, a deploy key, sometimes a database password — and every one of them is handled by a machine that also runs code from your dependencies. So secrets live in the provider's masked store, are injected per job, and are never printed. **Dependency scanning** is the step that checks your locked dependency tree against known vulnerabilities, and it belongs *before* the build, so a vulnerable package never becomes an image.

Key features

  • The runner holds credentials *and* runs third-party code — treat it as privileged
  • Inject secrets per job, and narrow the job's token; a test job needs no deploy key
  • Masking protects the exact string, not a base64 of it or a fragment in a traceback
  • A secret in an `ENV` layer or a build argument is permanent and distributable
  • Scan the lock file, not the manifest — the resolved tree is what ships
  • Grade the policy: fail on critical runtime findings, or a blanket ignore appears

Which job needs which secret — and which do not

Which job needs which secret — and which do not
JobNeedsMust not have
lint / type checknothingevery credential in the store
unit + integration testsa throwaway CI database passwordproduction database, registry, deploy keys
dependency + secret scannothing (or a scanner token)anything that can deploy
build + push imageregistry credentialsproduction settings and database
deploydeploy credentials, scoped to one environmentthe ability to write to the repository

Four ways a secret escapes a pipeline that "uses the secrets store"

Four ways a secret escapes a pipeline that "uses the secrets store"
RouteHow it happensWhat stops it
the log`echo $TOKEN`, `set -x`, a debugging line left innever print; masking is a backstop, not a control
a traceback`DEBUG=True` renders settings on errorproduction settings in CI too; `check --deploy`
an image layer`ENV SECRET_KEY=…` or `--build-arg`inject at run time only; `env_file`, never a layer
a forka pull request from outside running with secretsrestricted triggers; no secrets on untrusted runs
Where a secret can leave the pipeline

The secrets store is one control, not four. Each of these routes bypasses masking entirely, and three of them leave a copy that outlives the run.

  • A central box labelled "secrets store, masked" sits at the top, with an arrow down into a box labelled "the CI job".
  • Four arrows leave the job box, each ending in a red box naming one escape route.
  • The first is the build log, reached by an echo or a set -x, and noted as retained and searchable.
  • The second is a traceback, reached when DEBUG is True and settings are rendered on error.
  • The third is an image layer, reached by an ENV instruction or a build argument, and noted as permanent and distributable.
  • The fourth is a pull request from a fork, running with the same secrets, and noted as third-party code.
  • A footer states that masking only hides the exact string, so a base64 of the value or a fragment inside a traceback passes through unmasked.

Command

yaml
env:
  REGISTRY_TOKEN: ${{ secrets.REGISTRY_TOKEN }}   # on the push job only

The runner holds credentials and runs third-party code, so scope secrets per job and narrow each job's token — the scan and test jobs need no deploy key at all. Assume masking protects only the exact string: never print a secret, keep `DEBUG` off so a traceback cannot render settings, never write one into an image layer or a build argument, and do not let a fork's pull request run with them. Scan the lock file rather than the manifest, before the build, so a vulnerable package never becomes an image. And grade the policy — fail on critical runtime findings, report development-only ones — because a gate that fails on everything is a gate that gets switched off.

3.Build once, promote, approve — and be able to go back

Build the image **once**, tag it with the commit, and promote that exact artefact through staging and production — never rebuild per environment. **Artifact management** is keeping those images (and their provenance) long enough to redeploy an old one. **Approvals** are a human gate before a protected environment. A **rollback** is redeploying the previous tag, and it only works when the database schema still accepts the old code.

Key features

  • Build once, tag with the commit SHA, promote that digest — never rebuild per environment
  • Moving tags like `latest` make "what is running?" and "redeploy yesterday" unanswerable
  • Retention is a rollback requirement: you cannot redeploy an image you deleted
  • Approvals belong on protected environments only, and should name the digest
  • Code rollback is fast; schema rollback usually is not — they are not symmetrical
  • Every release plan states whether a rollback exists, or that the fix is forward-only

Which schema changes leave you a rollback

Which schema changes leave you a rollback
MigrationOld code still works?Rollback plan
add a nullable columnyes — it ignores the columnredeploy the old tag; leave the column
add a table or an indexyesredeploy; the extra object is harmless
add a NOT NULL column with a defaultyes, if the default is setredeploy; new rows still get the default
**drop** a column the old code reads**no** — every query on it errorsno rollback: forward fix, or restore the column first
**rename** a column**no**expand/contract instead: add, backfill, switch, drop later
narrow a constraint (add UNIQUE)maybe — old code may write duplicatesdrop the constraint before rolling code back

Tagging that answers the questions you will actually ask

Tagging that answers the questions you will actually ask
TagAnswersKeep for
`app:8f3c2ad`exactly which commit is runningas long as it might need redeploying
`app:v1.14.0`which release this is, for humansthe life of the release series
`app:production`nothing — it movesconvenience only; never a rollback target
`app:latest`**nothing at all**do not use it in a deployment
One artefact, three environments, and the way back
CI
registry
staging
approver
production
  1. 1push app:8f3c2ad (built once)tagged by commit; v1.14.0 points at the same digest
  2. 2deploy 8f3c2ad
  3. 3smoke tests passthe artefact is now tested, not the source
  4. 4production requires approval of digest 8f3c2ad
  5. 5approved — deploy the same digestno rebuild: promotion, not reconstruction
  6. 6migrate (expand only)additive, so 8f3c2ac still runs against this schema
  7. 7health + smoke pass
  8. 8if not: redeploy app:8f3c2acpossible only because the previous image still exists and the schema is compatible
  1. CI → registry: push app:8f3c2ad (built once) (tagged by commit; v1.14.0 points at the same digest)
  2. registry → staging: deploy 8f3c2ad
  3. staging → CI: smoke tests pass (the artefact is now tested, not the source)
  4. CI → approver: production requires approval of digest 8f3c2ad
  5. approver → production: approved — deploy the same digest (no rebuild: promotion, not reconstruction)
  6. production → production: migrate (expand only) (additive, so 8f3c2ac still runs against this schema)
  7. production → CI: health + smoke pass
  8. CI → production: if not: redeploy app:8f3c2ac (possible only because the previous image still exists and the schema is compatible)

Command

bash
docker build -t app:$GIT_SHA . && docker push app:$GIT_SHA
# every later stage deploys that tag; nothing rebuilds

Build once, tag by commit, and promote the digest — a rebuild per environment produces a different artefact and quietly invalidates whatever staging told you. Keep images long enough to redeploy any release you might have to go back to, because a rollback that needs a build is not a rollback. Put approvals on protected environments and record which digest was approved. And keep code and schema rollback separate in your head: redeploying the previous image is fast, but it only works if the migration was expand-shaped. When it is not, say so in the release plan — the deployment is forward-fix-only.

Remember

  • `makemigrations --check --dry-run` is the gate a green test suite does not give you.
  • `check --deploy` only means something against the *production* settings module.
  • Masking hides the exact string. A base64 of it, or a fragment in a traceback, goes straight through.
  • You cannot redeploy an image you deleted — retention is a rollback requirement.

Pro tip

  • Give the scan and test jobs no deploy credentials at all. Scoping secrets per job is what stops a compromised dependency in one job reaching another job's keys.
  • Write the rollback plan into the release before it goes out: which image tag, and whether the migration leaves the previous version runnable. "Forward-fix only" is a valid answer, but only if it is said in advance.

Next up

  • Deployment Strategies
  • Safe Django Upgrades
  • Architecture Styles

Sources

  • Django deployment checklist (check --deploy) — docs.djangoproject.com/en/5.2/howto/deployment/checklist/
  • Django migrations topic guide — docs.djangoproject.com/en/5.2/topics/migrations/
  • Every Django claim checked against Django 5.2 LTS docs · checked 2026-09-05

Spotted a mistake on this page? Tell us.