Django mit Celery auf Hetzner deployen
Folge dem auf deinem eigenen Server. Kostenlos starten, keine Karte nötig
Eine Django-App mit Hintergrundjobs ist nicht ein Prozess, sondern mindestens drei: gunicorn, das Requests bedient, ein Celery-Worker, der die Queue abarbeitet, und Redis, das diese Queue dazwischen hält. Die meisten Tutorials hören bei runserver auf und lassen den Worker, den Broker und das HTTPS-Zertifikat als Übung für später. Dieser Guide baut das Ganze auf einem Hetzner-Server: gunicorn hinter Caddy, ein Celery-Worker und ein optionaler Beat-Scheduler, Postgres und Redis, alles als Docker-Compose-Services mit echten Backups und einem echten Upgrade-Pfad. Die Abkürzung am Ende pusht dieselbe App statt dessen mit einem git push.
Dieser Guide nutzt Postgres 16 und Redis 8.
Was du brauchst
- Einen Hetzner-Cloud-Server. Ein CPX31 (4 vCPU, 8 GB) betreibt gunicorn, einen Celery-Worker, Postgres und Redis zusammen ohne Swapping, für 62,99 €/Monat exkl. MwSt. Ein kleinerer CX23 (2 vCPU, 4 GB) für 5,99 €/Monat exkl. MwSt. funktioniert für eine App mit wenig Traffic und einer leichten Queue, es ist nur enger, sobald Postgres’ Shared Buffers und Celerys Worker-Prozesse beide resident sind.
- Eine Domain, auf die IP des Servers gerichtet, bevor der HTTPS-Schritt kommt.
- Einen Hetzner-Cloud-Account und ein API-Token (Security → API Tokens, Read & Write), falls du die Servererstellung skripten willst; die Hetzner Cloud Console funktioniert auch gut, wenn du dich lieber einmal durchklickst.
Schritt 1: Den Server erstellen
Erstelle über die Hetzner Cloud Console einen Server auf Ubuntu 24.04, deiner gewählten Größe und einer Region nahe deinen Nutzern. Wenn du es lieber skriptest:
curl -X POST "https://api.hetzner.cloud/v1/servers" \
-H "Authorization: Bearer $HETZNER_TOKEN" \
-H "Content-Type: application/json" \
-d '{
"name": "django-app",
"server_type": "cpx31",
"image": "ubuntu-24.04",
"location": "nbg1",
"ssh_keys": ["your-ssh-key-name"]
}'
Du solltest eine JSON-Antwort mit einer server.id und einer server.public_net.ipv4.ip zurückbekommen. SSH dich mit dieser IP ein, sobald der Server fertig gebootet ist, normalerweise unter einer Minute:
ssh root@YOUR_SERVER_IP
Schritt 2: Docker installieren und DNS setzen
curl -fsSL https://get.docker.com | sh
docker compose version
Du solltest einen Compose-v2.x-Versionsstring sehen. Während das installiert, erstelle den DNS-A-Record für deine Domain und bestätige, dass er auflöst:
dig +short app.example.com
Er muss die IP des Servers zurückgeben, bevor Caddy später ein Zertifikat ausstellen kann, also erledige diesen Schritt früh und lass ihn im Hintergrund propagieren.
Schritt 3: Das Django-Image schreiben
Django liefert kein offizielles Image wie Postgres oder Redis, da sich die Abhängigkeiten jedes Projekts unterscheiden, also baust du dein eigenes:
FROM python:3.12-slim
WORKDIR /app
RUN apt-get update && apt-get install -y --no-install-recommends \
libpq-dev gcc \
&& rm -rf /var/lib/apt/lists/*
COPY requirements.txt .
RUN pip install --no-cache-dir -r requirements.txt
COPY . .
RUN python manage.py collectstatic --noinput
CMD ["gunicorn", "myproject.wsgi:application", "--bind", "0.0.0.0:8000"]
libpq-dev und gcc sind für psycopg2 (den Non-Binary-Build) da; lass sie weg, wenn deine requirements.txt stattdessen psycopg2-binary pinnt, das sein eigenes kompiliertes libpq mitbringt. collectstatic zum Build-Zeitpunkt auszuführen bedeutet, dass eine defekte statische Datei nie Produktions-Traffic erreicht, da der Build fehlschlägt, bevor das Image überhaupt gepusht wird.
Schritt 4: Die Docker-Compose-Datei schreiben
Fünf Services: die Django-App als web, dasselbe Image erneut als worker und beat mit unterschiedlichen Befehlen, Postgres, Redis und Caddy davor.
services:
web:
build: .
restart: unless-stopped
command: gunicorn myproject.wsgi:application --bind 0.0.0.0:8000 --workers 3
env_file: .env
depends_on:
db:
condition: service_healthy
redis:
condition: service_started
networks:
- app_net
worker:
build: .
restart: unless-stopped
command: celery -A myproject worker --loglevel=info --concurrency=2
env_file: .env
depends_on:
db:
condition: service_healthy
redis:
condition: service_started
networks:
- app_net
beat:
build: .
restart: unless-stopped
command: celery -A myproject beat --loglevel=info
env_file: .env
depends_on:
db:
condition: service_healthy
redis:
condition: service_started
networks:
- app_net
db:
image: postgres:16-alpine
restart: unless-stopped
environment:
POSTGRES_DB: myproject
POSTGRES_USER: myproject
POSTGRES_PASSWORD: ${POSTGRES_PASSWORD:?set POSTGRES_PASSWORD in .env}
volumes:
- postgres_data:/var/lib/postgresql/data
healthcheck:
test: ["CMD-SHELL", "pg_isready -U myproject"]
interval: 5s
start_period: 10s
retries: 20
networks:
- app_net
redis:
image: redis:8-alpine
restart: unless-stopped
volumes:
- redis_data:/data
networks:
- app_net
caddy:
image: caddy:2
restart: unless-stopped
depends_on:
- web
ports:
- "80:80"
- "443:443"
volumes:
- ./Caddyfile:/etc/caddy/Caddyfile
- caddy_data:/data
- caddy_config:/config
networks:
- app_net
networks:
app_net:
volumes:
postgres_data:
redis_data:
caddy_data:
caddy_config:
Lass den beat-Service komplett weg, wenn du keine periodischen Tasks planst. worker und beat bauen aus exakt demselben Dockerfile wie web, das ist der 12-Factor-Punkt am Prozessmodell: eine Codebasis, drei Rollen, gestartet mit drei verschiedenen Befehlen statt drei Repos, die synchron gehalten werden müssen.
Schreibe die .env daneben:
POSTGRES_PASSWORD=a-long-random-string
DJANGO_SETTINGS_MODULE=myproject.settings
SECRET_KEY=a-different-long-random-string
DEBUG=False
ALLOWED_HOSTS=app.example.com
DATABASE_URL=postgres://myproject:a-long-random-string@db:5432/myproject
CELERY_BROKER_URL=redis://redis:6379/0
CELERY_RESULT_BACKEND=redis://redis:6379/0
SECRET_KEY und das Postgres-Passwort sollten jeweils aus openssl rand -hex 32 kommen, nicht etwas von Hand Eingetipptes.
Schritt 5: Migrationen ausführen und den Stack hochfahren
docker compose up -d --build
docker compose run --rm web python manage.py migrate
Du solltest Djangos Migrationsausgabe sehen, die mit einer Liste angewendeter Migrationen endet, nicht mit einem Fehler über eine fehlende Tabelle oder eine abgelehnte Verbindung zu db. Prüfe dann, ob der Worker den Broker aufgenommen hat:
docker compose logs worker --tail=20
Du solltest eine Zeile wie celery@worker ready. sehen, die bestätigt, dass er sich mit Redis verbunden hat und auf Tasks wartet.
Schritt 6: Das Caddyfile schreiben
app.example.com {
reverse_proxy web:8000
handle_path /static/* {
root * /srv/static
file_server
}
}
Caddy fordert und erneuert sein eigenes Zertifikat über Let’s Encrypt, sobald es auf Port 80 für eine Domain antworten kann, die bereits auf diesen Server auflöst – kein separater Certbot-Schritt nötig. Wenn du statische Dateien direkt über Caddy statt über WhiteNoise auslieferst, mounte dasselbe statische Verzeichnis, in das der web-Container schreibt, auch in den caddy-Service als /srv/static.
Schritt 7: Backups
Postgres hält die Daten, die tatsächlich wichtig sind; sichere sie mit pg_dump nach einem Cron-Zeitplan, statt dich allein auf das Volume zu verlassen:
#!/usr/bin/env bash
set -euo pipefail
STAMP=$(date +%Y%m%d-%H%M%S)
mkdir -p backups
docker compose exec -T db pg_dump -U myproject myproject | gzip > "backups/django-db-$STAMP.sql.gz"
find backups -name '*.sql.gz' -mtime +14 -delete
Kopiere backups/ nach demselben Zeitplan vom Server weg, in Object Storage oder auf eine andere Maschine. Ein Dump, der den Server, auf dem er erstellt wurde, nie verlässt, schützt nicht gegen den Ausfall genau dieses Servers.
Schritt 8: Upgrades
Ziehe deinen eigenen neuen Image-Build, wende Migrationen an, und erstelle dann die App-Container nacheinander neu:
docker compose build
docker compose run --rm web python manage.py migrate
docker compose up -d web worker beat
Die Migration als einmaliges run --rm auszuführen, bevor die langlaufenden Container berührt werden, bedeutet, dass eine fehlgeschlagene Migration den Deploy stoppt, statt web teilweise gegen ein Schema neu zu starten, das es nicht erwartet.
Die Abkürzung: mit Railyard machen
Railyards Repo-Inspektor liest requirements.txt und manage.py, um Django selbst zu erkennen, kein Dockerfile oder Procfile nötig. Er setzt den Release-Befehl auf python manage.py migrate --no-input, startet web mit gunicorn, gebunden an den zugewiesenen Port, und führt collectstatic automatisch im Build-Schritt aus. Eine psycopg/psycopg2-Abhängigkeit stellt Postgres bereit und injiziert DATABASE_URL; ein Redis-Client (redis, django-redis, rq) macht dasselbe für REDIS_URL. Wenn Celery in deinen Abhängigkeiten ist, wird es als eigenständiger Worker-Prozess angeboten, und Celery Beat wird ebenfalls hinzugefügt, wenn django-celery-beat vorhanden ist oder deine Settings einen Beat-Schedule definieren, also kein Procfile von Hand für beide zu schreiben.
railyard create my-app https://github.com/me/my-app --addons postgres,redis
oder dieselbe Git-URL ins Dashboard einfügen, sobald GitHub verbunden ist. Skaliere den Worker unabhängig, sobald er läuft:
railyard scale my-app worker=2
Hetzner ist einer der Provider, bei denen Railyard direkt in deinem eigenen Account einen Server erstellen kann, abgerechnet zu Hetzners Preis ohne Aufschlag; siehe Server hinzufügen. Vollständige Framework-Referenz unter Django, und Von einem Repository deployen dafür, was sonst noch aus dem Repo gelesen wird. Siehe den programmatischen Walkthrough unter Django auf Hetzner für Größen- und Regionsdetails, und Datenbanken und Backups dafür, wie geplante Postgres-Backups und Point-in-Time-Restore funktionieren, sobald es angehängt ist.
Troubleshooting
Port 80 oder 443 schon belegt. Eine Standard-Webserver-Installation greift sich diese oft zuerst. sudo ss -tlnp | grep -E ':80|:443' zeigt den Prozess; stoppe oder konfiguriere ihn um, bevor Caddy startet.
ALLOWED_HOSTS lehnt jeden Request ab. Django gibt ein 400 mit einer klaren Nachricht zurück, die den erwarteten Host nennt, in Logs, die du mit docker compose logs web lesen kannst. Füge die exakte Domain (und die www.-Variante, falls du beide bedienst) zu ALLOWED_HOSTS in .env hinzu, und starte dann den web-Service neu.
Celery-Worker startet, aber nichts läuft je. Das ist fast immer CELERY_BROKER_URL, das irgendwohin zeigt, das der Worker nicht erreichen kann, häufig localhost, übrig geblieben von lokaler Entwicklung statt des Compose-Servicenamens redis. Bestätige mit docker compose exec worker env | grep CELERY.
Zertifikat wird nie ausgestellt. Prüfe zuerst, dass dig +short app.example.com auf den Server auflöst; Caddy kann eine HTTP-01-Challenge für eine Domain, die noch nicht hierhin zeigt, nicht abschließen. Prüfe auch, dass deine Cloud-Firewall eingehend 80 und 443 erlaubt, nicht nur die Ports, gegen die du gerade aktiv testest.
Speicher geht auf einem kleinen Server zur Neige. Postgres, Redis, gunicorn und ein Celery-Worker summieren sich auf einer 4-GB-Maschine schnell, wenn gunicorns Worker-Anzahl zu hoch bleibt. --workers 3 hier geht von einer 4-vCPU-Box aus; halbiere es auf einem CX23, oder gib Postgres einen eigenen Server, sobald der Traffic das tatsächlich erfordert.
FAQ
Brauche ich sowohl einen Worker als auch einen Beat-Prozess? Nur Beat, wenn du periodische Tasks mit @shared_task und einem CELERYBEAT_SCHEDULE oder django-celery-beat planst. Eine App, die Tasks nur bei Bedarf auslöst (etwa aus einer View), braucht nur den Worker.
Kann ich Celery und Django im selben Container laufen lassen? Du kannst beide Prozesse mit einem Prozess-Supervisor in einem Container starten, aber ein Absturz des einen reißt dann den anderen mit, und du verlierst die Fähigkeit, den Worker unabhängig vom Web-Traffic zu skalieren. Separate Container aus demselben Image, wie oben, vermeiden beide Probleme.
Warum Caddy statt nginx? Caddy fordert und erneuert Let’s-Encrypt-Zertifikate von sich aus aus einer sechszeiligen Config; nginx braucht Certbot separat verdrahtet und einen Cron-Job zum Erneuern. Beides funktioniert, Caddy ist nur weniger zu pflegen.
Was, wenn meine App eine bestimmte Python-Version braucht? Ändere die FROM python:3.12-slim-Zeile entsprechend; Django und Celery unterstützen beide die derzeit gepflegten Python-Versionen, prüfe deine eigenen Abhängigkeiten, falls eine eine ältere pinnt.
Beide Wege enden mit denselben drei Prozessen, die mit demselben Postgres und Redis sprechen – die Compose-Datei, wenn du jede Zeile sehen willst, der Push, wenn du lieber zurück zum eigentlichen Django-Code willst.
Warum Leute sich dafür Railyard entscheiden
- Dein eigenes Cloud-Konto. Deploye auf DigitalOcean, Hetzner, Vultr, AWS, OVHcloud oder jedem Linux-Server per SSH, direkt von deinem Anbieter abgerechnet, ohne Aufschlag.
- Ein fester Preis pro Team. Free ist für immer kostenlos: ein Server plus ein Datenbankserver, drei Apps, keine Karte. Pro kostet 19 $ im Monat für bis zu fünf Server und unbegrenzte Apps. Team kostet 49 $ im Monat mit unbegrenzten Servern. Keine Kosten pro App, pro Deployment, pro Sitz oder pro Gigabyte.
- Templates mit einem Klick. Deploye Open-Source-Apps, wie Datenbanken, Analytics und Dev-Tools, mit einem Klick auf deinem eigenen Server.
- Verwaltete Datenbanken. Postgres, MySQL und Redis mit geplanten, wiederherstellungsgeprüften Backups und Point-in-Time-Recovery.
- Push-to-Deploy. Deploye von GitHub, GitLab oder der CLI, mit automatischem HTTPS und Releases ohne Ausfallzeit.