GitLab CI für Wagtail/Django: Build, Migrationen, Deploy
Vom Push zur Produktion: Lint & Test, Image-Build, sichere Migrationen und Rollout per Docker
Warum überhaupt eine Pipeline?
Ein Django-Projekt „mal eben” auf den Server zu deployen, geht eine Weile gut – bis man eine Migration vergisst, collectstatic überspringt oder die halbe Konfiguration nur lokal existiert. Eine CI/CD-Pipeline macht den Weg vom git push zur Produktion wiederholbar und überprüfbar: testen, Image bauen, ausrollen – immer gleich, ohne Handarbeit.
Dieser Artikel zeigt mein Setup mit GitLab CI für eine Wagtail/Django-App, die als Docker-Image deployt wird. Secrets, Hostnamen und Registry sind maskiert – die Struktur ist 1:1 übertragbar.
Der Branch-Flow
Ich fahre einen einfachen, aber disziplinierten Flow:
develop– Arbeitsbranch, hier läuft die Test-Stage bei jedem Push.main– Merge nachmainlöst den kompletten Build + Deploy auf Produktion aus.
Kein Direkt-Push auf main, kein Deploy ohne grüne Tests. Das allein verhindert die meisten „Freitagabend-Unfälle”.
Die Pipeline im Überblick
Drei Stages: test → build → deploy. Nur main baut und deployt; develop testet nur.
stages:
- test
- build
- deploy
variables:
IMAGE: $CI_REGISTRY_IMAGE:production
DOCKER_BUILDKIT: "1"
Stage 1: Lint & Test
Bevor irgendetwas gebaut wird, muss der Code testbar sauber sein. Linting (ruff) und die Test-Suite (pytest) laufen bei jedem Push – auf develop wie auf main.
test:
stage: test
image: python:3.11-slim
services:
- postgres:16
variables:
DATABASE_URL: "postgres://postgres:postgres@postgres:5432/test"
before_script:
- pip install -r requirements.txt -r requirements-dev.txt
script:
- ruff check .
- python manage.py makemigrations --check --dry-run # vergessene Migration = Fehler
- pytest -q
Der Schritt makemigrations --check --dry-run bricht die Pipeline ab, wenn Modelländerungen ohne passende Migration committet wurden. Das hat mir schon mehr als einen kaputten Deploy erspart.
Stage 2: Image bauen
Der Kern meiner Strategie: Der Code wird ins Image gebacken. Das Image ist damit ein unveränderliches Artefakt – exakt das, was getestet wurde, läuft später in Produktion. Kein git pull auf dem Server, keine Überraschungen.
build:
stage: build
image: docker:27
services:
- docker:27-dind
rules:
- if: $CI_COMMIT_BRANCH == "main"
before_script:
- echo "$CI_REGISTRY_PASSWORD" | docker login -u "$CI_REGISTRY_USER" --password-stdin "$CI_REGISTRY"
script:
- docker build -t "$IMAGE" .
- docker push "$IMAGE"
Im Dockerfile wird neben dem Code auch collectstatic ausgeführt – die fertigen Static-Files liegen also schon im Image:
FROM python:3.11-slim
WORKDIR /app
COPY requirements.txt .
RUN pip install --no-cache-dir -r requirements.txt
COPY . .
RUN python manage.py collectstatic --noinput
CMD ["./entrypoint.sh"]
Stage 3: Deploy
Der Deploy-Job verbindet sich per SSH mit dem Produktiv-Host, zieht das frische Image und startet die Container neu. Die eigentliche Logik (Migrationen!) steckt im Container-Start, nicht im CI-Job – dazu gleich mehr.
Anzeige · Affiliate-Link – kaufst du darüber, erhalte ich ggf. eine Provision. Für dich ändert sich am Preis nichts.
deploy:
stage: deploy
image: alpine:3
rules:
- if: $CI_COMMIT_BRANCH == "main"
before_script:
- apk add --no-cache openssh-client
- eval $(ssh-agent -s); echo "$DEPLOY_SSH_KEY" | ssh-add -
script:
- ssh $DEPLOY_USER@$DEPLOY_HOST "cd /srv/app && docker compose pull && docker compose up -d"
Migrationen sicher fahren
Die wichtigste Frage bei jedem Django-Deploy: Wer führt die Migrationen aus – und wann? Ich mache es im Entrypoint des Containers, beim Start, bevor der Webserver hochfährt:
#!/bin/sh
set -e
python manage.py migrate --noinput
exec gunicorn myproject.wsgi:application --bind 0.0.0.0:8000 --workers 3
Migrationen müssen abwärtskompatibel sein: Während des Rollouts laufen kurz alte und neue Container parallel. Eine Spalte zu löschen, die der alte Code noch liest, crasht ihn. Regel: erst additive Migration + neuer Code, im nächsten Deploy das Aufräumen. Spalten umbenennen = niemals in einem Schritt.
Secrets & CI-Variablen
Keine Passwörter im Repo. Alles Sensible kommt aus den CI/CD-Variablen in GitLab (Settings → CI/CD → Variables), als protected + masked markiert:
# nur als Referenz – Werte stehen in GitLab, nicht im Repo
DEPLOY_SSH_KEY # privater Key fuer den Deploy-User (protected)
DEPLOY_HOST # Produktiv-Host
CI_REGISTRY_* # von GitLab automatisch bereitgestellt
DJANGO_SECRET_KEY, DATABASE_URL # via .env.production auf dem Host
Zero-Downtime – die ehrliche Variante
Ein einzelner Host mit docker compose up -d ist nicht echtes Zero-Downtime – es gibt ein kurzes Fenster beim Container-Tausch. Für meinen Anwendungsfall (eine Hobby-/Nebenprojekt-Site) ist das völlig okay: ein paar Sekunden, abgefedert durch einen Healthcheck und restart: unless-stopped.
Wer echtes Zero-Downtime braucht, kommt um Rolling- oder Blue-Green-Deployments nicht herum (zwei parallele Stacks, Umschalten per Reverse Proxy). Das ist ein eigenes Thema – und für die meisten Maker-Projekte Overkill.
Was ich weggelassen habe
Bewusst nicht behandelt: Caching der pip-Layer (spart Build-Zeit, lenkt hier aber ab), Review-Apps pro Merge Request, Container-Registry-Cleanup (alte Images stapeln sich), und echte Blue-Green-Deployments. Alles sinnvoll – aber nicht nötig, um sauber und wiederholbar zu deployen.
Fazit
Eine schlanke test → build → deploy-Pipeline nimmt dir den Stress aus dem Deployen: getesteter Code wird zum unveränderlichen Image, Migrationen laufen deterministisch beim Start, Secrets bleiben außerhalb des Repos. Bewusst pragmatisch, nicht überengineered – genau das richtige Maß für ein selbst gehostetes Django/Wagtail-Projekt.
Anzeige · Affiliate-Link – kaufst du darüber, erhalte ich ggf. eine Provision. Für dich ändert sich am Preis nichts.