Deployment
CI/CD pipelines

Deploy Django with GitLab CI/CD

Build a GitLab CI/CD pipeline for Django: PostgreSQL tests, Docker image builds, SSH deploys with protected variables, and one-click rollbacks.

Problem statement

Manual deploys depend on whoever runs them remembering every step. A GitLab CI/CD pipeline turns the release into code: every merge runs the same tests, builds the same kind of artifact, and deploys it the same way.

A pipeline that only runs ssh server "git pull && restart" automates the mess rather than fixing it. A production-grade Django pipeline should:

  • test against the same database engine as production
  • build an immutable, versioned artifact once and deploy exactly that artifact
  • keep secrets in protected CI/CD variables, not in the repository
  • prevent two deploys from running at the same time
  • make rollback a single, known action

This guide builds a .gitlab-ci.yml with test, build, and deploy stages for a Dockerized Django app running with Docker Compose on a server.

GitLab CI/CD pipelinetest stagePostgres servicebuild stageimage tagged by SHAdeploy stageSSH, resource groupServermigrate, restartProtected variables keep deploy credentials off feature branch pipelines.

Quick answer

  1. test stage: run Django tests in a Python image with a postgres service, plus check --deploy and makemigrations --check.
  2. build stage: build the image with Docker-in-Docker and push it to the GitLab Container Registry tagged with $CI_COMMIT_SHORT_SHA.
  3. deploy stage: SSH to the server with a protected, file-type private key variable, pull the tagged image, run migrations with it, then restart the web service.
  4. Limit deploys to the default branch, use resource_group: production to serialize them, and optionally require a manual click.
  5. Roll back by re-running the deploy job of an earlier pipeline.

Step-by-step solution

1. Server preparation

On the server, create a deploy user that can run Docker and owns the application directory:

sudo adduser --disabled-password deploy
sudo usermod -aG docker deploy
sudo install -d -o deploy -g deploy /srv/myapp

Membership in the docker group is effectively root access on that host, so dedicate the key and user to deployments.

Place a Compose file and the production environment file on the server once:

# /srv/myapp/compose.yaml
services:
  web:
    image: ${APP_IMAGE}
    env_file: /srv/myapp/.env
    command: gunicorn config.wsgi:application --bind 0.0.0.0:8000 --workers 3
    ports:
      - "127.0.0.1:8000:8000"
    restart: unless-stopped

The .env file holds DJANGO_SECRET_KEY, DATABASE_URL, and other runtime settings with mode 600. The CI pipeline never needs to see them.

Create an SSH key pair for CI, add the public key to /home/deploy/.ssh/authorized_keys, and record the server's host key:

ssh-keyscan -t ed25519 server.example.com

2. Add CI/CD variables in GitLab

In Settings → CI/CD → Variables, add:

KeyTypeFlags
SSH_PRIVATE_KEYFileProtected
SSH_KNOWN_HOSTSVariableProtected
DEPLOY_HOSTVariableProtected
how each deploy variable is storedTypeProtectedMaskedSSH_PRIVATE_KEYFile✓yesn/a: fileSSH_KNOWN_HOSTSFile✓yesnoDEPLOY_HOSTVariable✓yesoptionalProtected variables only reach pipelines on protected branchesdjango-deployment.com
A merge request from a fork never receives protected variables.

Protected variables are only exposed to pipelines on protected branches and tags, so a merge request from a feature branch cannot read the deploy key. A file-type variable writes the value to a temporary file and puts the file's path in the variable.

Protect the default branch so only maintainers can push or merge to it.

3. Test stage

stages:
  - test
  - build
  - deploy

variables:
  PIP_CACHE_DIR: "$CI_PROJECT_DIR/.cache/pip"

test:
  stage: test
  image: python:3.13-slim
  services:
    - name: postgres:16
      alias: db
  variables:
    POSTGRES_DB: test_db
    POSTGRES_USER: test
    POSTGRES_PASSWORD: test
    DATABASE_URL: postgres://test:test@db:5432/test_db
    DJANGO_SECRET_KEY: ci-only-not-secret
    DJANGO_ALLOWED_HOSTS: localhost
  cache:
    key:
      files:
        - requirements.txt
    paths:
      - .cache/pip
  script:
    - pip install -r requirements.txt
    - python manage.py makemigrations --check --dry-run
    - python manage.py check --deploy --fail-level WARNING
    - python manage.py test --noinput
test, build, deploytestpytest with PostgreSQLbuildimage:short-shadeploy (manual)default branch onlyremote scriptpull, migrate, up -dverifyhealth URLA failed stage stops everything after itdjango-deployment.com
The image tag ties every deploy back to the commit and the pipeline that tested it.
  • The postgres service runs alongside the job and is reachable at the alias db.
  • makemigrations --check fails the pipeline when model changes were committed without migrations.
  • check --deploy --fail-level WARNING turns deployment warnings into failures. It requires production-like settings; if CI settings intentionally differ, run it with a dedicated settings module that mirrors production security options.

4. Build stage

build:
  stage: build
  image: docker:27
  services:
    - docker:27-dind
  variables:
    DOCKER_TLS_CERTDIR: "/certs"
  rules:
    - if: $CI_COMMIT_BRANCH == $CI_DEFAULT_BRANCH
  script:
    - echo "$CI_REGISTRY_PASSWORD" | docker login -u "$CI_REGISTRY_USER" --password-stdin "$CI_REGISTRY"
    - docker build --pull -t "$CI_REGISTRY_IMAGE:$CI_COMMIT_SHORT_SHA" .
    - docker push "$CI_REGISTRY_IMAGE:$CI_COMMIT_SHORT_SHA"

CI_REGISTRY, CI_REGISTRY_USER, CI_REGISTRY_PASSWORD, and CI_REGISTRY_IMAGE are predefined by GitLab for the project's container registry. Docker-in-Docker needs a runner with privileged mode; on GitLab.com shared runners this is available. The image tag is the commit SHA, so each image maps to exactly one commit.

5. Deploy stage

deploy_production:
  stage: deploy
  image: alpine:3.20
  resource_group: production
  environment:
    name: production
    url: https://www.example.com
  rules:
    - if: $CI_COMMIT_BRANCH == $CI_DEFAULT_BRANCH
      when: manual
  before_script:
    - apk add --no-cache openssh-client
    - install -d -m 700 ~/.ssh
    - echo "$SSH_KNOWN_HOSTS" > ~/.ssh/known_hosts
    - chmod 600 "$SSH_PRIVATE_KEY"
  script:
    - |
      ssh -i "$SSH_PRIVATE_KEY" deploy@"$DEPLOY_HOST" \
        CI_REGISTRY="$CI_REGISTRY" \
        CI_DEPLOY_USER="$CI_REGISTRY_USER" \
        CI_DEPLOY_TOKEN="$CI_JOB_TOKEN" \
        APP_IMAGE="$CI_REGISTRY_IMAGE:$CI_COMMIT_SHORT_SHA" \
        'sh -s' < deploy/remote-deploy.sh
the deploy job, line by linedeploy_production:resource_group:productionenvironment: {name: production}rules:- if: $CI_COMMIT_BRANCH ==$CI_DEFAULT_BRANCHwhen:manual12341Two pipelines never deploy at the same time.2Tracks what runs where and enables one-click rollback.3Only main can deploy to production.4A person presses the button.The script only connects and runs remote-deploy.shdjango-deployment.com
Protect the production environment too, so only maintainers can press deploy.
  • resource_group: production ensures only one job in that group runs at a time, so two quick merges cannot deploy concurrently.
  • environment records each deployment in Operate → Environments, which is where rollbacks happen.
  • when: manual requires a person to press the deploy button; remove it for continuous deployment once you trust the pipeline.
  • Known hosts are pinned instead of disabling host key checking.

SSH joins the arguments into one command string that the remote shell runs, so the VAR=value prefixes become environment variables for sh -s, which reads the script from standard input. No AcceptEnv change on the server is needed. The job token is visible in the server's process list while the command runs, which is acceptable because it expires when the job ends.

6. The remote deploy script

deploy/remote-deploy.sh is read from the repository and executed on the server:

set -eu

cd /srv/myapp

echo "$CI_DEPLOY_TOKEN" | docker login -u "$CI_DEPLOY_USER" --password-stdin "$CI_REGISTRY"
docker pull "$APP_IMAGE"

# Run migrations with the new image before switching the web container
docker run --rm --env-file /srv/myapp/.env "$APP_IMAGE" \
  python manage.py migrate --noinput

echo "APP_IMAGE=$APP_IMAGE" > /srv/myapp/release.env
docker compose --env-file /srv/myapp/release.env up -d --no-deps web

docker logout "$CI_REGISTRY"

for i in 1 2 3 4 5 6 7 8 9 10; do
  if curl -fsS -o /dev/null http://127.0.0.1:8000/healthz/; then
    echo "Healthy: $APP_IMAGE"
    exit 0
  fi
  sleep 3
done

echo "Health check failed for $APP_IMAGE" >&2
docker compose --env-file /srv/myapp/release.env logs --tail=100 web >&2
exit 1
remote-deploy.sh on the server1Log in with the job tokenshort-lived, pull only2Pull image:short-shaexact tested build3One-off migrate containerdocker run --rm4Recreate only webcompose up -d --no-deps5Poll /healthz/ or faillogs printed on failurerelease.env records which image is live, for the next rollbackdjango-deployment.com
Rolling back is rerunning the script with the previous image tag.

Details that matter:

  • CI_JOB_TOKEN is short-lived and scoped to the job, so no long-lived registry password is stored on the server.
  • Migrations run with the new image before the web container changes. If they fail, set -e stops the script and the old container keeps serving.
  • release.env records the running image tag on the server, which is useful during incidents.
  • The health check loop fails the job if the new container never becomes healthy, so a red pipeline means a broken release.

The container restart causes a few seconds of downtime on a single container. For gapless releases, run two web containers behind Nginx and replace them one at a time.

7. Verify

After the first deploy:

ssh deploy@server.example.com 'cat /srv/myapp/release.env; docker compose -f /srv/myapp/compose.yaml ps'
curl -I https://www.example.com/

Verification check:

  • the pipeline shows test, build, and deploy jobs passing
  • Operate → Environments → production lists the deployment with its commit
  • release.env matches the deployed commit SHA
  • a merge request pipeline runs tests but not build or deploy

Rollback

Open Operate → Environments → production, find the previous successful deployment, and select Rollback (re-deploy). GitLab re-runs that older pipeline's deploy job, which deploys its image tag. Because images are tagged by commit and kept in the registry, nothing has to be rebuilt.

Migrations are not reversed. Keep schema changes backward compatible for at least one release so rolling back the code is always safe.

Explanation

The pipeline separates three concerns. Tests decide whether a commit is releasable. The build produces one immutable image per commit. The deploy job changes which image runs, with migrations first and a health gate afterwards. GitLab's protected variables, protected branches, and resource groups add the controls a shared team needs: only trusted branches can deploy, and deploys never overlap.

Edge cases / notes

Registry images accumulate

Configure a cleanup policy for the container registry that keeps recent tags, so old images do not grow storage forever while recent rollback targets remain available.

Self-managed runners

On your own runners, prefer a dedicated runner for deploy jobs, and do not run untrusted merge request pipelines on it.

Staging first

Add a deploy_staging job that deploys automatically, and make production depend on it with needs, so every production image has run in staging.

Troubleshooting the pipeline

Job and errorCauseFix
test: could not translate host name "db"service alias missing or job image cannot resolve itadd alias: db to the service; use the alias in DATABASE_URL
test: connection refused right after starttests start before Postgres is readyadd a short wait loop, e.g. until pg_isready -h db; do sleep 1; done with postgresql-client installed
build: Cannot connect to the Docker daemonrunner not privileged or DOCKER_HOST wronguse a runner with privileged mode, or switch to a rootless builder such as Kaniko or Buildah
deploy: Host key verification failedSSH_KNOWN_HOSTS missing or outdatedrefresh with ssh-keyscan and update the variable
deploy: Load key ... bad permissionsfile variable not restrictedchmod 600 "$SSH_PRIVATE_KEY" before ssh
deploy: variable emptyjob running on an unprotected branchprotected variables are only available on protected branches and tags
deploy: unauthorized on docker pulljob token expired or no registry accesspull while the job runs; check project access token scope for cross-project images
pipeline failures and their causesErrorUsual causeHost key verification failedSSH_KNOWN_HOSTS missing or stalePermission denied (publickey)key not in the deployuser’s authorized_keysVariable is empty in the jobprotected variable onan unprotected branchdenied: access forbidden (pull)server cannot log in to the registryMost first-run failures are about SSH or variable scope, not Djangodjango-deployment.com
Run the same ssh command from your machine with the same key to separate CI from server issues.

When a variable seems empty, print only whether it is set, never its value:

[ -n "$DEPLOY_HOST" ] && echo "DEPLOY_HOST is set" || echo "DEPLOY_HOST is empty"

Mark sensitive variables as masked as well as protected, so GitLab hides their values if they are ever printed accidentally.

Speeding up the pipeline

  • Pip cache: the cache block keyed on requirements.txt reuses downloaded wheels across pipelines.
  • Docker layer cache: with Docker-in-Docker, each job starts with an empty daemon. Pull the previous image and use it as a cache source:
docker pull "$CI_REGISTRY_IMAGE:latest-main" || true
docker build --cache-from "$CI_REGISTRY_IMAGE:latest-main" \
  --build-arg BUILDKIT_INLINE_CACHE=1 \
  -t "$CI_REGISTRY_IMAGE:$CI_COMMIT_SHORT_SHA" \
  -t "$CI_REGISTRY_IMAGE:latest-main" .
docker push "$CI_REGISTRY_IMAGE:$CI_COMMIT_SHORT_SHA"
docker push "$CI_REGISTRY_IMAGE:latest-main"

The latest-main tag is only a cache source; deployments still use the immutable commit tag.

  • Interruptible jobs: add interruptible: true to test and build jobs so a newer push to the same branch cancels outdated pipelines. Do not mark deploy jobs interruptible.

Merge request pipelines

Run tests for merge requests without building or deploying by adding a workflow rule:

workflow:
  rules:
    - if: $CI_PIPELINE_SOURCE == "merge_request_event"
    - if: $CI_COMMIT_BRANCH == $CI_DEFAULT_BRANCH

This avoids duplicate branch and merge request pipelines while keeping build and deploy rules limited to the default branch.

Next steps

FAQ

How do I run Django tests with PostgreSQL in GitLab CI?

Add postgres as a service in the test job, set POSTGRES_DB, POSTGRES_USER, and POSTGRES_PASSWORD variables, and point DATABASE_URL at the service alias.

How should GitLab CI connect to my server for deployment?

Use a dedicated SSH key stored as a protected, file-type CI/CD variable, pin the server's host key with a known hosts variable, and log in as a deploy-only user.

How do I stop two GitLab deploys from running at once?

Set resource_group: production on the deploy job. GitLab runs jobs in the same resource group one at a time.

Where should Django migrations run in a GitLab pipeline?

On the server, with the newly built image, immediately before switching the web container. Running them in the test stage would only migrate the temporary CI database.

Keep going