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.
Quick answer
teststage: run Django tests in a Python image with apostgresservice, pluscheck --deployandmakemigrations --check.buildstage: build the image with Docker-in-Docker and push it to the GitLab Container Registry tagged with$CI_COMMIT_SHORT_SHA.deploystage: 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.- Limit deploys to the default branch, use
resource_group: productionto serialize them, and optionally require a manual click. - 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:
| Key | Type | Flags |
|---|---|---|
SSH_PRIVATE_KEY | File | Protected |
SSH_KNOWN_HOSTS | Variable | Protected |
DEPLOY_HOST | Variable | Protected |
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
- The
postgresservice runs alongside the job and is reachable at the aliasdb. makemigrations --checkfails the pipeline when model changes were committed without migrations.check --deploy --fail-level WARNINGturns 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
resource_group: productionensures only one job in that group runs at a time, so two quick merges cannot deploy concurrently.environmentrecords each deployment in Operate → Environments, which is where rollbacks happen.when: manualrequires 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
Details that matter:
CI_JOB_TOKENis 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 -estops the script and the old container keeps serving. release.envrecords 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.envmatches 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 error | Cause | Fix |
|---|---|---|
test: could not translate host name "db" | service alias missing or job image cannot resolve it | add alias: db to the service; use the alias in DATABASE_URL |
test: connection refused right after start | tests start before Postgres is ready | add 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 daemon | runner not privileged or DOCKER_HOST wrong | use a runner with privileged mode, or switch to a rootless builder such as Kaniko or Buildah |
deploy: Host key verification failed | SSH_KNOWN_HOSTS missing or outdated | refresh with ssh-keyscan and update the variable |
deploy: Load key ... bad permissions | file variable not restricted | chmod 600 "$SSH_PRIVATE_KEY" before ssh |
| deploy: variable empty | job running on an unprotected branch | protected variables are only available on protected branches and tags |
deploy: unauthorized on docker pull | job token expired or no registry access | pull while the job runs; check project access token scope for cross-project images |
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
cacheblock keyed onrequirements.txtreuses 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: trueto 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
- Compare with Deploy Django with GitHub Actions CI/CD.
- Add a pre-production environment with Set Up a Staging Environment for Django.
- Practice recovery with How to Roll Back a Django Release Safely.
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.