Eine Django-App von Heroku zu DigitalOcean verschieben
Folge dem auf deinem eigenen Server. Kostenlos starten, keine Karte nötig
Heroku-Dynos, Config Vars und heroku pg:backups verstecken eine echte Menge Infrastrukturarbeit hinter einer CLI, und genau deshalb ist der Weggang aufwendiger als einfach woanders hin zu pushen. Eine Django-App auf Heroku braucht einen Server, der oben bleibt, einen Prozessmanager, der gunicorn ausführt, einen Celery-Worker, falls die App einen hat, eine Postgres-Datenbank mit den echten Daten drin, jede Config Var auf eine echte Umgebung gemappt, und eine Domain, die ohne Lücke umgeschaltet wird, in der keiner der beiden Server aktuelle Daten hat. Diese Anleitung macht das alles gegen einen DigitalOcean-Droplet, in Docker, und endet mit derselben App, die auf Infrastruktur läuft, die du kontrollierst.
Was du brauchst
- Einen DigitalOcean-Droplet. Der Basic-Plan mit 1 vCPU / 2 GB RAM betreibt Postgres, gunicorn und Caddy bequem zusammen und kostet aktuell 12 $/Monat; der 1-GB-Plan (6 $/Monat) ist zu knapp, sobald Postgres neben allem anderen auch noch läuft.
- Die Heroku-CLI, bereits authentifiziert, um Config Vars und einen Datenbank-Dump zu ziehen.
- Eine Domain, die aktuell auf Heroku zeigt, mit Zugriff, um ihr DNS zu ändern.
- Den Source-Code der App in einem Git-Repository, mit einer
requirements.txt, aus der Heroku schon baut. Diese Anleitung nutzt Django 6.1.2, gunicorn 26.2.0 als WSGI-Server, und psycopg2-binary 2.9.13, um mit Postgres zu sprechen. - Docker und das Compose-Plugin, entweder lokal, um den Build erst zu testen, oder auf dem Server.
Schritt 1: Inventur, was auf Heroku tatsächlich läuft
Herokus API zeigt dein Repository nicht direkt an, also ist dieser Schritt manuell, und ihn zu überspringen ist der einfachste Weg, etwas auf dem neuen Server zu verpassen. Zieh die drei Dinge, die du brauchst:
heroku config -a your-app-name > heroku-config.txt
heroku ps -a your-app-name
heroku addons -a your-app-name
heroku ps zeigt deine aktuelle Formation. Für eine Django-App ist das typischerweise ein web-Dyno, der gunicorn ausführt, und vielleicht ein worker-Dyno, der Celery ausführt, falls die App Background-Jobs verarbeitet. heroku addons zeigt, was tatsächlich provisioniert ist, fast immer heroku-postgresql auf irgendeinem Plan, was ein äquivalentes Postgres-Container auf dem neuen Server braucht.
Schritt 2: Den Droplet erstellen
Erstelle im DigitalOcean-Dashboard einen neuen Droplet: wähle das Ubuntu 24.04 LTS-Image, den 2-GB-Basic-Plan, eine Region nah an deinen Nutzern, und deinen SSH-Key. Sobald er läuft, notiere seine öffentliche IP-Adresse und richte den A-Eintrag deiner Domain darauf aus:
myproject.example.com. A 203.0.113.10
DNS-Propagation kann ein paar Minuten bis ein paar Stunden dauern; du kannst in der Zwischenzeit mit dem Server-Setup weitermachen. Logge dich als root per SSH ein und installiere Docker:
ssh root@203.0.113.10
curl -fsSL https://get.docker.com | sh
apt-get install -y docker-compose-plugin
Prüf, dass es funktioniert hat:
docker compose version
Schritt 3: Die Django-App für Produktion vorbereiten
Herokus Procfile und flüchtige Dynos verstecken ein paar Einstellungen, die Django explizit auf einem Server braucht, den du selbst verwaltest. In settings.py:
import os
DEBUG = False
ALLOWED_HOSTS = os.environ.get("DJANGO_ALLOWED_HOSTS", "").split(",")
DATABASES = {
"default": {
"ENGINE": "django.db.backends.postgresql",
"NAME": os.environ["POSTGRES_DB"],
"USER": os.environ["POSTGRES_USER"],
"PASSWORD": os.environ["POSTGRES_PASSWORD"],
"HOST": "db",
"PORT": "5432",
}
}
STATIC_URL = "/static/"
STATIC_ROOT = "/app/staticfiles"
Falls die App aktuell DATABASE_URL als einzelnen String über django-environ oder dj-database-url auf Heroku liest, funktioniert das weiter; die Umgebungsvariablen oben sind nur die einfachste Version, sobald die Datenbank ein Container namens db statt Herokus eigenem Postgres-Host ist. Bestätige, dass die exakten in requirements.txt gepinnten Versionen dem entsprechen, was diese Anleitung annimmt:
django==6.1.2
gunicorn==26.2.0
psycopg2-binary==2.9.13
Schritt 4: Das Dockerfile schreiben
FROM python:3.13-slim
ENV PYTHONDONTWRITEBYTECODE=1 \
PYTHONUNBUFFERED=1
WORKDIR /app
COPY requirements.txt .
RUN pip install --no-cache-dir -r requirements.txt
COPY . .
RUN python manage.py collectstatic --noinput
CMD ["sh", "-c", "python manage.py migrate --no-input && gunicorn myproject.wsgi:application --bind 0.0.0.0:8000 --workers 3"]
Ersetze myproject.wsgi durch dein tatsächliches Projekt-Package. migrate vor dem Start von gunicorn auszuführen bedeutet, dass jedes Deploy automatisch ausstehende Migrationen anwendet, was ersetzt, was auch immer für ein Release-Schritt Heroku ausgeführt hat (höchstwahrscheinlich eine Zeile release: python manage.py migrate im Procfile). Drei gunicorn-Worker sind ein vernünftiger Startpunkt für einen 2-GB-Droplet; beobachte den Speicher unter Last und passe an.
Schritt 5: docker-compose.yml schreiben
services:
db:
image: postgres:17-alpine
restart: unless-stopped
environment:
POSTGRES_DB: myproject
POSTGRES_USER: myproject
POSTGRES_PASSWORD: ${POSTGRES_PASSWORD}
volumes:
- postgres_data:/var/lib/postgresql/data
healthcheck:
test: ["CMD-SHELL", "pg_isready -U myproject"]
interval: 5s
timeout: 5s
retries: 5
web:
build: .
restart: unless-stopped
env_file: .env
depends_on:
db:
condition: service_healthy
volumes:
- static_files:/app/staticfiles
expose:
- "8000"
caddy:
image: caddy:2
restart: unless-stopped
ports:
- "80:80"
- "443:443"
volumes:
- ./Caddyfile:/etc/caddy/Caddyfile
- static_files:/srv/static
- caddy_data:/data
- caddy_config:/config
depends_on:
- web
volumes:
postgres_data:
static_files:
caddy_data:
caddy_config:
Füge einen worker-Service hinzu, gebaut aus demselben Image, der celery -A myproject worker --loglevel=info ausführt, falls Schritt 1s heroku ps-Output einen Worker-Dyno zeigte. Er braucht dieselbe .env-Datei und einen Broker (Redis oder RabbitMQ) als weiteren Container, genauso wie Postgres hier einer ist. Nur Caddy veröffentlicht Ports nach außen; web und db bleiben nur von Containern im selben Compose-Netzwerk erreichbar.
Schritt 6: Heroku-Config-Vars auf .env mappen
Öffne heroku-config.txt aus Schritt 1 und übersetze es, Variable für Variable, in die .env des Servers, außerhalb der Versionskontrolle gehalten:
POSTGRES_DB=myproject
POSTGRES_USER=myproject
POSTGRES_PASSWORD=change-this
DJANGO_ALLOWED_HOSTS=myproject.example.com
SECRET_KEY=paste-from-heroku-config-vars
SECRET_KEY ist hier die eine Variable, die besondere Sorgfalt braucht. Djangos SECRET_KEY signiert Sessions und andere signierte Cookies; er ist kein Verschlüsselungsschlüssel für gespeicherte Daten, so wie Rails’ RAILS_MASTER_KEY das ist. Dieser Unterschied zählt dafür, was passiert, wenn du es falsch machst: SECRET_KEY neu zu generieren statt den Wert zu kopieren, den Heroku schon in seinen Config Vars hatte, macht nichts in der Datenbank unlesbar, aber die Session jedes eingeloggten Nutzers und jedes von der App ausgestellte signierte Cookie wird stillschweigend invalidiert, in dem Moment, in dem der neue Wert wirksam wird, und loggt alle gleichzeitig aus. Kopiere ihn exakt aus heroku-config.txt rüber, statt einen neuen zu generieren, außer alle Nutzer zum erneuten Einloggen zu zwingen ist für diesen Cutover tatsächlich ein akzeptabler Preis. Jede andere app-spezifische Variable aus heroku-config.txt (API-Keys, Feature-Flags, alles, was os.environ.get in deinem eigenen Code liest) gehört ebenfalls in diese Datei; DATABASE_URL, falls die App sie als einzelnen String nutzt, bekommt einen neuen Wert, der auf den db-Container zeigt statt auf Herokus eigenen Postgres-Host.
Schritt 7: Die Datenbank migrieren
Während die App auf dem neuen Server noch nicht läuft, ziehe einen frischen Dump von Heroku:
heroku pg:backups:capture -a your-app-name
heroku pg:backups:download -a your-app-name -o latest.dump
Kopiere ihn auf den neuen Server und stelle ihn im frisch erstellten (leeren) Postgres-Container wieder her:
scp latest.dump root@203.0.113.10:/opt/app/
docker compose up -d db
docker compose exec -T db pg_restore -U myproject -d myproject --no-owner --role=myproject /tmp/latest.dump
--no-owner zählt hier besonders: Herokus Dump gehört einer Rolle, die auf deinem neuen Postgres-Container nicht existiert, und ohne --no-owner schlägt die Wiederherstellung bei jedem Objekt fehl, das sie nicht neu zuweisen kann.
Schritt 8: Den vollen Stack hochfahren und verifizieren
docker compose up -d --build
docker compose ps
Du solltest web (und worker, falls vorhanden), db und caddy alle als running sehen. Bevor du DNS umschaltest, teste gegen die rohe IP des Droplets mit einem Host-Header, um zu bestätigen, dass die App tatsächlich echte Seiten mit echten Daten ausliefert:
curl -H "Host: myproject.example.com" http://203.0.113.10
Ein 400 Bad Request hier bedeutet fast immer, dass DJANGO_ALLOWED_HOSTS in .env nicht zur Domain im Host-Header passt; behebe das und deploye web neu, bevor du weitermachst.
Schritt 9: Das Caddyfile schreiben und DNS umschalten
myproject.example.com {
encode gzip
handle_path /static/* {
root * /srv/static
file_server
}
reverse_proxy web:8000
}
Statische Dateien unter /static/ werden direkt von Caddy aus dem gemeinsamen Volume ausgeliefert; alles andere wird an gunicorn weitergeleitet. Senke den DNS-TTL deiner Domain ein, zwei Tage vor dem Cutover, falls er aktuell hoch ist, und richte dann den A-Eintrag auf die IP des neuen Droplets aus. Heroku liefert weiter die alten Daten, bis DNS propagiert, was auch das eigentliche Risikofenster ist: Jeder Write, der auf Heroku nach Schritt 7s Dump, aber bevor der Traffic vollständig auf den neuen Server umgezogen ist, passiert, ist eine Zeile, die diese Migration nicht hat. Ein kurzes Maintenance-Window, oder ein finaler inkrementeller Dump direkt vor dem DNS-Wechsel, schließt diese Lücke für alles, was es nicht verträgt, ein paar Minuten an Writes zu verlieren.
Die Abkürzung: mit Railyard
Den Namen einer Heroku-App einzufügen (oder Heroku direkt zu verbinden) in Import von einer anderen Plattform liest dieselben drei Dinge, die Schritt 1 von Hand gezogen hat: Config Vars, Formation (web- und worker-Prozesstypen mit ihrer Anzahl), und Add-ons, wobei heroku-postgresql automatisch auf eine provisionierte Postgres-Datenbank gemappt wird. Heroku zeigt sein Repository über die API in keinem Fall an, also fügst du weiterhin die Git-URL ein, von der du pushst, der eine ehrliche manuelle Schritt, den auch Railyard nicht überspringen kann. Die Datenbankmigration aus Schritt 7 und 8 wird zu einem einzigen Import: ihn auf die Live-Datenbank richten oder einen Dump hochladen, und es sichert erst, was da ist, stellt wieder her, dann führt automatisch Migrationen aus, dasselbe pg_restore --no-owner-Verhalten unter der Haube, ohne das manuelle Flag, an das man sich erinnern muss. Siehe Datenbank-Importe für den exakten Ablauf, und Django auf DigitalOcean deployen dafür, wie Railyard eine Django-App aus requirements.txt erkennt und gunicorn, Migrationen und Postgres einbindet, ohne das Dockerfile und die Compose-Datei, die diese Anleitung von Hand schreibt.
Railyards Free-Plan deckt einen Server plus einen Datenbank-Server und drei Apps ab, ohne Kreditkarte; Pro kostet 19 $/Monat pro Team, und Team kostet 49 $/Monat pro Team für größere Projekte. Siehe Migration von Heroku für das vollständige Bild, was umzieht und was nicht.
Troubleshooting
pg_restore: error: could not execute query ... role "..." does not exist:--no-ownerfehlte im Restore-Befehl aus Schritt 7; füge es hinzu, und dieselben Objekte stellen stattdessen erfolgreich unter der verbindenden Rolle wieder her.- „Bad Request (400)” von Django:
DJANGO_ALLOWED_HOSTSpasst nicht zur Domain, die du besuchst. Bestätige es in.env, deploye mitdocker compose up -d webneu, und prüfedocker compose logs web. - Jeder Nutzer wird nach dem Cutover ausgeloggt:
SECRET_KEYin.envpasst nicht zum Wert, den Herokus Config Vars hatten, meist weil er in Schritt 6 frisch generiert statt rüberkopiert wurde; jede Session und jedes signierte Cookie war durch den Mismatch schon invalidiert, und den Originalwert zu kopieren behebt Sessions, die nach dem falschen Key live ging, nicht rückwirkend. - Background-Jobs werden nie verarbeitet: bestätige, dass der
worker-Service tatsächlich indocker compose psvorhanden ist und dass seine Broker-URL zu der desweb-Services passt; ein Job, der gegen einen Broker eingereiht wird, während ein Worker einen anderen pollt, trifft sich nie. - HTTPS wird nach dem DNS-Cutover nie ausgestellt: DNS-Propagation kann je nach vorherigem TTL länger dauern als erwartet;
dig +short myproject.example.comsollte konsistent von mehreren Standorten aus auf die IP des neuen Droplets zeigen, bevor man annimmt, Caddy wäre schuld. - Port 80 oder 443 schon belegt: ein anderer Webserver, oft ein von einem DigitalOcean-One-Click-Image installiertes nginx, ist schon daran gebunden. Stoppe und deaktiviere es:
systemctl stop nginx && systemctl disable nginx. - Daten wirken nach dem Cutover veraltet: das ist Schritt 9s Write-Lücke; falls das für diese App wichtig ist, wiederhole Schritt 7s Dump und Schritt 8s Restore direkt vor dem DNS-Wechsel, mit auf Heroku dazwischen pausierten Writes.
FAQ
Kann ich das mit null Downtime machen? Nicht mit einem einzelnen Dump-and-Restore, so wie diese Anleitung es macht; ein Logical-Replication-Setup von Heroku Postgres zum neuen Droplet entfernt die Write-Lücke vollständig, aber das ist deutlich mehr Setup, als die meisten kleinen Apps für die wenigen Minuten Risiko brauchen, die ein Maintenance-Window abdeckt.
Was ist mit Herokus flüchtigem Dateisystem, hängt irgendetwas davon ab? Falls die App User-Uploads direkt auf lokale Disk schreibt, in der Erwartung, dass sie bestehen bleiben, war das auf Heroku zwischen Dyno-Neustarts schon kaputt; diese Migration ist ein guter Moment, Media-Storage in einen Object-Store oder ein Docker-Volume zu verschieben, falls noch nicht geschehen.
Muss ich auch geplante Jobs migrieren? Heroku-Scheduler-Einträge werden von heroku config oder heroku ps nicht abgedeckt; prüfe das Dashboard des Heroku-Scheduler-Add-ons separat und erstelle sie als Cron-Einträge oder einen dedizierten Railyard-Cron-Schedule neu, da nichts in den Schritten 1 bis 9 oben sie automatisch sichtbar macht.
Ist ein 2-GB-Droplet wirklich vergleichbar mit einem Heroku-Dyno? Die tatsächliche CPU und der Speicher eines standard-1x- oder standard-2x-Dynos sind im Vergleich bescheiden; ein 2-GB-Droplet betreibt Postgres, gunicorn und Caddy zusammen generell für weniger als die Kosten des äquivalenten Dynos plus heroku-postgresql-Add-on, obwohl der echte Test der Speicherverbrauch deiner eigenen App unter echtem Traffic ist, nicht ein Datenblatt.
Was, wenn die Migration schiefgeht, nachdem DNS schon umgeschaltet ist? Lass die Heroku-App mindestens ein paar Tage nach dem Cutover weiterlaufen (nicht auf null skaliert), speziell damit DNS sofort wieder darauf zurückgerichtet werden kann, falls auf dem neuen Droplet etwas nicht stimmt; das ist der tatsächliche Rollback-Plan, keine Backup-Datei.
Eine Django-App, die jeden Monat einen Heroku-Dyno und ein heroku-postgresql-Add-on gekostet hat, läuft jetzt auf einem DigitalOcean-Droplet für 12 $, mit denselben Postgres-Daten, denselben signierten Sessions und denselben Background-Jobs, einfach irgendwo, das du provisioniert hast und vollständig verstehst.
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.