Eine Rails-App Schritt für Schritt von Heroku nach Hetzner umziehen
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, genau deshalb ist das Verlassen aufwendiger als git push zu etwas anderem. Eine Rails-App auf Heroku braucht einen Webserver, einen Queue-Worker, falls vorhanden, eine Postgres-Datenbank mit den tatsächlichen Daten darin, jede Config-Var auf eine echte Umgebung abgebildet, und eine Domain, die ohne eine Lücke umgeschaltet wird, in der keiner der beiden Server aktuelle Daten hat. Diese Anleitung macht das alles gegen einen Hetzner-Cloud-Server, in Docker, und endet mit derselben App, die auf Infrastruktur läuft, die du kontrollierst.
Was du brauchst
- Einen Hetzner-Cloud-Server. Ein CX23 (2 vCPU, 4 GB Speicher, 5,99 €/Monat exkl. MwSt.) passt zu dem, was viele kleine Rails-Apps bereits auf einem einzigen Heroku-Dyno plus einem
hobby-dev- oderstandard-0-Postgres-Add-on laufen lassen; steige auf einen CX33 (4 vCPU, 8 GB, 8,99 €/Monat) um, falls die App neben dem Web-Prozess einen Queue-Worker betreibt. - Die Heroku CLI, bereits authentifiziert, um Config-Vars und einen Datenbank-Dump zu holen.
- Eine Domain, die aktuell auf Heroku zeigt, mit Zugriff, um ihre DNS zu ändern.
- Den Quellcode der App in einem Git-Repository. Diese Anleitung verwendet Rails 8.1, das auf Ruby 4.0 oder neuer läuft; das Dockerfile unten pinnt Ruby 4.0.8.
- Docker und das Compose-Plugin, entweder lokal, um den Build zuerst zu testen, oder auf dem Server.
Schritt 1: Bestandsaufnahme, was auf Heroku tatsächlich läuft
Herokus API legt dein Repository nicht direkt offen, dieser Schritt ist also manuell, und ihn zu überspringen ist der einfachste Weg, etwas auf dem neuen Server zu übersehen. Hole 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 (web, worker, jeden weiteren Prozesstyp und deren Dyno-Anzahl); heroku addons zeigt, was tatsächlich provisioniert ist (Postgres-Plan, Redis, alles andere separat Abgerechnete), das ein Äquivalent auf dem neuen Server braucht.
Schritt 2: Den Server erstellen
In der Hetzner Cloud Console, Add Server: Ubuntu 24.04, CX23, ein Standort, dein SSH-Key.
ssh root@your-server-ip
curl -fsSL https://get.docker.com | sh
Schritt 3: Das Dockerfile schreiben
FROM ruby:4.0.8-slim-bookworm AS build
RUN apt-get update -qq \
&& apt-get install -y --no-install-recommends build-essential libpq-dev git pkg-config \
&& rm -rf /var/lib/apt/lists/*
WORKDIR /rails
COPY Gemfile Gemfile.lock ./
RUN bundle config set --local without 'development test' \
&& bundle install --jobs 4 --retry 3
COPY . .
RUN SECRET_KEY_BASE_DUMMY=1 bin/rails assets:precompile
FROM ruby:4.0.8-slim-bookworm
RUN apt-get update -qq \
&& apt-get install -y --no-install-recommends libpq5 curl \
&& rm -rf /var/lib/apt/lists/*
WORKDIR /rails
COPY --from=build /usr/local/bundle /usr/local/bundle
COPY --from=build /rails /rails
ENV RAILS_ENV=production
ENV RAILS_LOG_TO_STDOUT=1
ENV RAILS_SERVE_STATIC_FILES=1
EXPOSE 3000
CMD ["bin/rails", "server", "-b", "0.0.0.0", "-p", "3000"]
SECRET_KEY_BASE_DUMMY=1 während der Asset-Precompilation vermeidet, dass das echte Secret der App zur Build-Zeit benötigt wird, derselbe Workaround, den Rails’ eigenes Standard-Dockerfile (seit 7.1 von rails new generiert) verwendet, da die Asset-Kompilierung das echte Secret nicht braucht, aber ohne eines vorhanden fehlschlägt.
Schritt 4: Die Compose-Datei schreiben
services:
app:
build: .
restart: unless-stopped
env_file:
- .env
depends_on:
db:
condition: service_healthy
redis:
condition: service_started
expose:
- "3000"
worker:
build: .
restart: unless-stopped
env_file:
- .env
depends_on:
db:
condition: service_healthy
redis:
condition: service_started
command: ["bundle", "exec", "sidekiq"]
db:
image: postgres:18-alpine
restart: unless-stopped
environment:
POSTGRES_DB: ${POSTGRES_DB}
POSTGRES_USER: ${POSTGRES_USER}
POSTGRES_PASSWORD: ${POSTGRES_PASSWORD}
volumes:
- db-data:/var/lib/postgresql/data
healthcheck:
test: ["CMD-SHELL", "pg_isready -U ${POSTGRES_USER} -d ${POSTGRES_DB}"]
interval: 5s
timeout: 5s
retries: 5
redis:
image: redis:7.4-alpine
restart: unless-stopped
caddy:
image: caddy:2
restart: unless-stopped
ports:
- "80:80"
- "443:443"
volumes:
- ./Caddyfile:/etc/caddy/Caddyfile
- caddy-data:/data
- caddy-config:/config
depends_on:
- app
volumes:
db-data:
caddy-data:
caddy-config:
Lasse den worker-Service komplett weg, wenn die heroku ps-Ausgabe aus Schritt 1 keinen Worker-Dyno gezeigt hat; eine Rails-App ohne Background-Job-Verarbeitung braucht Sidekiq oder Redis überhaupt nicht laufen zu haben.
Schritt 5: Heroku-Config-Vars auf .env abbilden
Öffne heroku-config.txt aus Schritt 1 und übersetze es, Variable für Variable, in die .env des Servers:
POSTGRES_DB=app_production
POSTGRES_USER=app
POSTGRES_PASSWORD=change-this
DATABASE_URL=postgres://app:change-this@db:5432/app_production
REDIS_URL=redis://redis:6379/0
RAILS_MASTER_KEY=paste-from-config-or-config-master-key
RAILS_MASTER_KEY ist die eine, die Heroku fast sicher bereits als RAILS_MASTER_KEY in seinen eigenen Config-Vars hatte (Rails-Credentials werden damit verschlüsselt, und Herokus Config-Vars sind genau dort, wo dieser Key normalerweise in der Produktion liegt); kopiere ihn exakt mit, statt ihn neu zu generieren, sonst wird jedes verschlüsselte Credential in config/credentials.yml.enc unlesbar. Jede andere App-spezifische Variable aus heroku-config.txt (API-Keys, Feature-Flags, alles, was ENV.fetch in deinem eigenen Code liest) gehört ebenfalls in diese Datei; DATABASE_URL und REDIS_URL speziell bekommen neue Werte, die auf die Container aus Schritt 4 zeigen, statt auf Herokus eigene.
Schritt 6: Die Datenbank migrieren
Während die App auf dem neuen Server noch nicht läuft, hole 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@your-server-ip:/opt/app/
docker compose up -d db
docker compose exec -T db pg_restore -U app -d app_production --no-owner --role=app /tmp/latest.dump
--no-owner ist hier besonders wichtig: Herokus Dump gehört einer Rollenbezeichnung, die auf deinem neuen Postgres-Container nicht existiert, und ohne --no-owner schlägt die Wiederherstellung mittendrin bei jedem Objekt fehl, das nicht zugewiesen werden kann.
Schritt 7: Den gesamten Stack hochfahren und verifizieren
docker compose up -d
docker compose exec app bin/rails db:migrate
docker compose ps
Du solltest app, worker (falls vorhanden), db, redis und caddy alle als running sehen. Teste gegen die rohe IP des Servers mit einem Host-Header, bevor du DNS umschaltest, um zu bestätigen, dass die App tatsächlich echte Seiten mit echten Daten ausliefert:
curl -H "Host: yourdomain.com" http://your-server-ip
Schritt 8: Die Caddyfile schreiben und DNS umschalten
yourdomain.com {
reverse_proxy app:3000
}
Senke die DNS-TTL deiner Domain ein, zwei Tage vor der Umstellung, falls sie aktuell hoch ist, und zeige dann den A-Record auf die IP des neuen Servers. Heroku bedient weiter die alten Daten, bis DNS propagiert, was auch das eigentliche Risikofenster ist: Jeder Write, der auf Heroku nach dem Dump aus Schritt 6, aber bevor der Traffic vollständig zum neuen Server wechselt, passiert, ist eine Zeile, die diese Migration nicht hat. Ein kurzes Wartungsfenster oder ein letzter inkrementeller Dump direkt vor dem DNS-Wechsel schließt diese Lücke für alles, das es sich nicht leisten kann, ein paar Minuten Writes zu verlieren.
Die Abkürzung: mit Railyard
Den Namen einer Heroku-App einzufügen (oder Heroku direkt zu verbinden) bei import from another platform liest dieselben drei Dinge, die Schritt 1 von Hand geholt hat: Config-Vars, Formation (web- und worker-Prozesstypen mit ihrer Anzahl) und Add-ons, und ordnet heroku-postgresql und heroku-redis automatisch provisioniertem Postgres und Redis zu. Heroku legt dein Repository über seine API ohnehin nicht offen, du fügst also trotzdem die Git-URL ein, von der du pushst, den einen ehrlichen manuellen Schritt, den auch Railyard nicht überspringen kann. Die Datenbankmigration aus den Schritten 6 und 7 wird zu einem einzigen Import: zeige auf das lebende DATABASE_URL oder lade einen Dump hoch, und es sichert zuerst, was da ist, stellt wieder her und führt dann automatisch Migrationen aus, dasselbe pg_restore --no-owner-Verhalten unter der Haube, ohne das manuelle Flag merken zu müssen. Siehe database imports für den genauen Ablauf.
Troubleshooting
pg_restore: error: could not execute query ... role "..." does not exist:--no-ownerwurde beim Restore-Befehl in Schritt 6 weggelassen; füge es hinzu, und dieselben Objekte stellen sich stattdessen erfolgreich unter der verbindenden Rolle wieder her.- App bootet, aber jede Seite liefert 500 bei verschlüsselten Credentials:
RAILS_MASTER_KEYpasst nicht zu dem, wasconfig/credentials.yml.encverschlüsselt hat, meist weil es neu generiert statt aus Herokus eigenen Config-Vars in Schritt 5 kopiert wurde. - Background-Jobs werden nie verarbeitet: bestätige, dass der
worker-Service indocker compose pstatsächlich vorhanden ist undREDIS_URLzwischenappundworkerübereinstimmt; ein Job, der gegen eine Redis-Instanz eingereiht wird, während ein Worker eine andere abfragt, trifft sich nie. - HTTPS wird nach der DNS-Umstellung nie ausgestellt: DNS-Propagation kann je nach vorheriger TTL länger dauern als erwartet;
dig +short yourdomain.comsollte konsistent von mehreren Standorten mit der IP des neuen Servers übereinstimmen, bevor man annimmt, Caddy sei schuld. - Daten sehen nach der Umstellung veraltet aus: das ist die Write-Lücke aus Schritt 8; falls das für diese App wichtig ist, wiederhole den Dump aus Schritt 6 und die Wiederherstellung aus Schritt 7 unmittelbar vor dem DNS-Umschalten, mit pausierten Writes auf Heroku in der Zwischenzeit.
FAQ
Kann ich das mit null Downtime machen? Nicht mit einem einzelnen Dump-und-Restore, wie diese Anleitung es macht; ein logisches Replikations-Setup von Heroku Postgres zum neuen Server entfernt die Write-Lücke vollständig, aber das ist deutlich mehr Aufwand, als die meisten kleinen Apps für die wenigen Minuten Risiko brauchen, die ein Wartungsfenster abdeckt.
Was ist mit Herokus flüchtigem Dateisystem, hängt irgendetwas davon ab? Wenn die App Uploads direkt auf die lokale Festplatte schreibt und erwartet, dass sie bestehen bleiben, war das auf Heroku zwischen Dyno-Neustarts schon kaputt; diese Migration ist tatsächlich ein guter Moment, um die Dateispeicherung in einen Object Store oder ein Docker-Volume zu verschieben, falls das nicht schon passiert ist.
Muss ich auch geplante Jobs migrieren? Heroku-Scheduler-Einträge werden nicht von heroku config oder heroku ps erfasst; prüfe das Dashboard des Heroku-Scheduler-Add-ons separat und erstelle sie als Cron-Einträge oder einen eigenen Railyard-Cron-Zeitplan neu, da nichts in den Schritten 1 bis 8 oben sie automatisch aufdeckt.
Ist ein CX23 wirklich mit einem Heroku-Dyno vergleichbar? Die tatsächliche CPU und der Speicher eines standard-1x- oder standard-2x-Dynos sind im Vergleich bescheiden; die 4 GB eines CX23 übertreffen meist einen einzelnen kleinen Dyno bei gleichem oder niedrigerem Monatspreis, wobei der echte Test der tatsächliche Speicherbedarf deiner eigenen App unter echtem Traffic ist, nicht ein Datenblatt.
Was, wenn die Migration schiefgeht, nachdem DNS schon umgeschaltet hat? Lass die Heroku-App mindestens ein paar Tage nach der Umstellung weiterlaufen (nicht auf null skaliert), speziell damit DNS sofort zurück darauf gezeigt werden kann, falls auf dem neuen Server etwas nicht stimmt; das ist der tatsächliche Rollback-Plan, keine Backup-Datei.
Eine Rails-App, die jeden Monat einen Heroku-Dyno und ein Postgres-Add-on gekostet hat, läuft jetzt auf einem Hetzner-Server für 5,99 €, mit denselben Postgres-Daten, denselben verschlüsselten Credentials und denselben Background-Jobs, nur dort, wo du es 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.