← Blog

Eine Rails-App auf Hetzner deployen

Railyard

4. April 2026 · 11 Min. Lesezeit

Folge dem auf deinem eigenen Server. Kostenlos starten, keine Karte nötig

Hetzners Cloud-Server gehören zu den günstigsten echten Rechenleistungen, die man im Monat mieten kann, aber ein günstiger Server betreibt deine App nicht von sich aus. Du musst ihn trotzdem provisionieren, Docker installieren, Postgres neben deiner App laufen lassen, HTTPS vor beide stellen, ein Backup irgendwo aufbewahren, das nicht der Server selbst ist, und einen Weg haben, dein nächstes Release zu verschicken, ohne jedes Mal neu anzufangen. Am Ende dieser Anleitung hast du eine Rails-App, gebaut aus ihrem eigenen generierten Dockerfile, die auf einem Hetzner-Server hinter Caddy mit einem echten Zertifikat läuft, mit Postgres im Rücken, mit nächtlichen Backups und einem kurzen, wiederholbaren Weg, eine neue Version zu deployen.

Was du brauchst

  • Ein Hetzner-Cloud-Konto und einen Server. Der kostenoptimierte CX23 (2 vCPU, 4 GB Speicher, 40 GB NVMe) kostet ab 5,99 €/Monat exkl. MwSt., die je nach Rechnungsort anfällt. Prüfe aktuelle Preise und Verfügbarkeit in der Hetzner Cloud Console vor der Bestellung, da sich Cloud-Angebote und Preise ändern können.
  • Einen Domainnamen, bei dem du einen DNS-Eintrag hinzufügen kannst.
  • Ein SSH-Schlüsselpaar auf deinem Rechner (ssh-keygen -t ed25519, falls du noch keins hast).
  • Eine Rails-App, oder eine neue: Diese Anleitung baut eine von Grund auf mit rails new, sodass du auch ohne bestehende App mitmachen kannst.
  • Etwa fünfzehn Minuten, bis sich Server und DNS eingependelt haben, hauptsächlich Warten statt Tippen.

Schritt 1: Den Server erstellen und seine IP notieren

Öffne (oder erstelle) in der Hetzner Cloud Console ein Projekt und wähle dann Add Server. Wähle einen Standort: Nürnberg, Falkenstein oder Helsinki halten deine Daten in der EU, während Ashburn, Hillsboro oder Singapur ebenfalls angeboten werden, falls deine Nutzer anderswo sind. Wähle Ubuntu 24.04 als Image, und wähle dann unter Server-Typ den Shared-vCPU-CX23 (2 vCPU, 4 GB Speicher, 40 GB NVMe). Füge unter SSH-Schlüssel den öffentlichen Schlüssel von deinem Rechner hinzu, statt dich auf ein generiertes Passwort zu verlassen, und erstelle dann den Server.

Sobald er gebootet ist, zeigt die Console seine öffentliche IPv4-Adresse. Verbinde dich, um den Zugriff zu bestätigen:

ssh root@YOUR_SERVER_IP

Du solltest das Ubuntu-Willkommensbanner sehen und in einer Root-Shell landen. Falls die Verbindung abgelehnt wird, prüfe, ob der am Server hinterlegte SSH-Schlüssel zum privaten Schlüssel auf deinem Rechner passt.

Schritt 2: Deine Domain auf den Server zeigen lassen

Füge bei deinem DNS-Anbieter einen A-Record für den gewünschten Hostnamen hinzu, der auf die IP des Servers zeigt:

Type: A
Name: myapp (oder @ für die nackte Domain)
Value: YOUR_SERVER_IP
TTL: 300

Die DNS-Propagierung kann einige Minuten dauern. Prüfe sie von deinem eigenen Rechner, bevor du weitermachst, da Caddy das in einem späteren Schritt bereits korrekt auflösen muss:

dig +short myapp.example.com

Du solltest die IP deines Servers zurückgemeldet sehen. Falls nichts kommt, warte ein paar Minuten und versuch es erneut, bevor du weitermachst.

Schritt 3: Docker auf dem Server installieren

Das von Rails generierte Dockerfile braucht Docker Engine und das Compose-Plugin auf dem Server. Dockers eigenes Installationsskript erledigt beides auf einer frischen Ubuntu-Box:

apt-get update
curl -fsSL https://get.docker.com -o get-docker.sh
sh get-docker.sh
systemctl enable --now docker

Hetzner stellt standardmäßig keine Firewall vor einen neuen Server, also sperre den Host mit ufw auf genau das, was du brauchst:

ufw allow OpenSSH
ufw allow 80
ufw allow 443
ufw --force enable

Prüfe die Installation:

docker --version
docker compose version

Du solltest eine Docker-Version und eine Compose-Version ausgegeben sehen, keinen “command not found”-Fehler.

Schritt 4: Die Rails-App mit Dockerfile und Postgres generieren

Generiere auf deinem eigenen Rechner eine neue App, die für Postgres statt für die SQLite-Voreinstellung konfiguriert ist:

gem install rails -v 8.1.4
rails new myapp --database=postgresql
cd myapp

rails new erstellt standardmäßig ein Dockerfile, aufgeführt unter den generierten Dateien zusammen mit .dockerignore, und es ist auf Produktion abgestimmt: Es installiert deine Gems und kompiliert Assets vorab in einer Build-Stage, und kopiert dann nur das Nötige in ein schlankes Runtime-Image, das als Non-Root-Nutzer läuft. Rails 8 bringt außerdem Thruster mit, einen kleinen HTTP-Proxy vor Puma, der HTTP/2, Kompression und Asset-Auslieferung übernimmt, weshalb das generierte Image intern Port 80 exponiert und mit ./bin/thrust ./bin/rails server startet statt Puma direkt zu starten. Du erreichst diesen Port nicht direkt aus dem Internet, Caddy sitzt in Schritt 7 davor.

Das Entrypoint-Skript des Dockerfiles führt außerdem automatisch ./bin/rails db:prepare aus, jedes Mal, wenn der Befehl des Containers ein Rails-Server ist, bevor der Server tatsächlich startet. Das bedeutet, du brauchst später in dieser Anleitung keinen separaten Migrationsschritt: Der erste Boot erstellt die Datenbank, und jeder spätere Boot wendet an, was auch immer an Migrationen mit diesem Release kam.

Weil du --database=postgresql übergeben hast, ist config/database.yml für das pg-Gem statt für SQLite eingerichtet. Wenn du auf Rails 8 bist, verdrahtet dieselbe Datei auch separate cache-, queue- und cable-Datenbankeinträge für Solid Cache, Solid Queue und Solid Cable, Rails’ eigene Standard-Backends für Background Jobs und Caching. Alle vier, die primäre Datenbank plus diese drei, lesen von derselben DATABASE_URL und unterscheiden sich nur im Datenbanknamen, den jede anfordert (myapp_production, myapp_production_cache und so weiter), sodass ein einzelner Postgres-Container und ein Passwort alle abdecken; db:prepare erstellt alle vier beim ersten Lauf.

Prüfe, dass die Dateien da sind:

ls Dockerfile .dockerignore config/database.yml

Du solltest alle drei Pfade fehlerfrei ausgegeben sehen.

Schritt 5: Deine Secrets von der App auf den Server bringen

Rails verschlüsselt config/credentials.yml.enc mit config/master.key, einer Datei, die absichtlich in .gitignore steht und nie zum Server verschickt wird. Lies ihren Wert einmal auf deinem eigenen Rechner aus:

cat config/master.key

Erstelle auf dem Server ein Verzeichnis für die App und eine .env-Datei mit diesem Key und einem Datenbank-Passwort, das du selbst generierst:

mkdir -p /opt/myapp
cat > /opt/myapp/.env <<'EOF'
RAILS_MASTER_KEY=paste-the-value-from-config-master-key-here
POSTGRES_PASSWORD=a-long-random-password
EOF
chmod 600 /opt/myapp/.env

Prüfe es:

cat /opt/myapp/.env

Du solltest beide Variablen mit echten Werten sehen, nicht mit Platzhaltern, und die Datei sollte nicht weltlesbar sein (-rw------- bei ls -l).

Schritt 6: Die Docker-Compose-Datei schreiben

Kopiere deine App auf den Server (scp, rsync, oder ein git clone deines Repositorys nach /opt/myapp), und füge dann compose.yml daneben hinzu:

services:
  app:
    build: .
    image: myapp:latest
    restart: unless-stopped
    env_file: .env
    environment:
      RAILS_ENV: production
      DATABASE_URL: postgres://myapp:${POSTGRES_PASSWORD}@db:5432/myapp_production
    depends_on:
      db:
        condition: service_healthy
    volumes:
      - storage:/rails/storage
    expose:
      - "80"

  db:
    image: postgres:16.4-alpine
    restart: unless-stopped
    environment:
      POSTGRES_USER: myapp
      POSTGRES_PASSWORD: ${POSTGRES_PASSWORD}
      POSTGRES_DB: myapp_production
    volumes:
      - db_data:/var/lib/postgresql/data
    healthcheck:
      test: ["CMD-SHELL", "pg_isready -U myapp"]
      interval: 5s
      timeout: 5s
      retries: 10

  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:
  storage:
  caddy_data:
  caddy_config:

Der app-Service erreicht db über dessen Servicenamen, nicht über localhost, da Compose beide Container in dasselbe private Netzwerk setzt. depends_on: condition: service_healthy verhindert, dass die App startet, bevor Postgres tatsächlich Verbindungen annehmen kann, nicht nur bevor sein Container existiert. Named Volumes halten deine Datenbank- und Active-Storage-Dateien über Neustarts und Rebuilds hinweg auf Disk; nur caddy veröffentlicht Ports zum Host, sodass Postgres von außerhalb dieses Servers überhaupt nicht erreichbar ist.

Prüfe, dass die Datei sauber geparst wird, bevor du weitermachst:

docker compose config

Du solltest die vollständig aufgelöste Konfiguration ohne Fehlerzeilen ausgegeben sehen.

Schritt 7: Caddy als Reverse-Proxy mit HTTPS hinzufügen

Füge ein Caddyfile neben compose.yml hinzu:

myapp.example.com {
	reverse_proxy app:80
}

Ersetze myapp.example.com durch die Domain, die du in Schritt 2 auf den Server gelegt hast. Caddy macht aus diesem einen Block von sich aus zwei Dinge: Es proxyt jeden Request zu Port 80 des app-Containers, und es fordert ein kostenloses TLS-Zertifikat von Let’s Encrypt für genau diesen Hostnamen an, das es automatisch erneuert, bevor es abläuft. Das funktioniert nur, wenn die Domain bereits auf diesen Server auflöst, weshalb DNS in dieser Anleitung vor Caddy kam.

Schritt 8: Den Stack booten

Von /opt/myapp aus:

docker compose up -d --build

Dank des Entrypoint-Verhaltens aus Schritt 4 baut dieser einzelne Befehl das Image, startet Postgres, wartet, bis es als gesund gemeldet wird, und startet dann die App, die die Datenbank erstellt und Migrationen von sich aus ausführt, bevor Puma (hinter Thruster) tatsächlich startet.

Prüfe den Stack:

docker compose ps
curl -I https://myapp.example.com

Du solltest alle drei Services als Up sehen (und db als healthy), und curl sollte HTTP/2 200 ohne Zertifikatswarnung zurückgeben.

Schritt 9: Postgres sichern

Ein Dump, der außerhalb des Datenvolumes gezogen wird, ist das, was dich tatsächlich schützt, falls der Server verschwindet. Füge ein Backup-Skript hinzu:

cat > /opt/myapp/backup.sh <<'EOF'
#!/bin/sh
cd /opt/myapp
TIMESTAMP=$(date +%Y%m%d-%H%M%S)
docker compose exec -T db pg_dump -U myapp myapp_production | gzip > /opt/myapp/backups/$TIMESTAMP.sql.gz
find /opt/myapp/backups -name '*.sql.gz' -mtime +14 -delete
EOF
mkdir -p /opt/myapp/backups
chmod +x /opt/myapp/backup.sh

Plane es mit cron, täglich um 3 Uhr:

(crontab -l 2>/dev/null; echo "0 3 * * * /opt/myapp/backup.sh") | crontab -

Führe es einmal manuell aus und prüfe, ob es funktioniert hat:

/opt/myapp/backup.sh
ls -lh /opt/myapp/backups

Du solltest eine .sql.gz-Datei mit dem heutigen Zeitstempel und einer Größe über null sehen. Für alles jenseits eines Hobbyprojekts kopiere diese Dateien auch vom Server weg, auf eine andere Maschine oder einen Object Store, damit der Verlust des Servers nicht auch den Verlust des Backups bedeutet.

Schritt 10: Ein Upgrade verschicken

Jede spätere Änderung folgt derselben Form: den neuen Code auf den Server bringen, das Image neu bauen, den laufenden Container ersetzen.

git pull
docker compose build app
docker compose up -d app

Das Entrypoint-Skript führt db:prepare auf diesem neuen Container erneut aus, bevor der Server startet, sodass jede Migration, die mit diesem Release kam, automatisch angewendet wird. docker compose up -d app erstellt nur den app-Container neu, nicht db oder caddy, sodass Postgres weiterläuft und Caddys Zertifikatsstatus unberührt bleibt. Es gibt eine kurze Lücke, ein oder zwei Sekunden, während der alte Container stoppt und der neue startet, da dieses manuelle Setup einen einzelnen App-Container betreibt. Prüfe, ob es angekommen ist:

docker compose logs --tail=50 app
curl -I https://myapp.example.com

Du solltest deine App sauber in den Logs hochfahren sehen und curl weiterhin 200 auf der neuen Version zurückgeben.

Die Abkürzung: mit Railyard

Alles oben, der Server, Docker, Postgres, Caddy, Backups, ein wiederholbares Release, ist auch das, was Railyard für eine Rails-App auf Hetzner automatisiert, ohne dass du eine der oben genannten Dateien von Hand schreibst.

Füge die URL deines Repositorys bei https://app.railyard.run/deploy?repo= ein und wähle Hetzner als Provider. Beim ersten Mal generierst du in der Hetzner Cloud Console ein Token (öffne dein Projekt, dann Security, dann API-Tokens, und generiere eines mit Lese- und Schreibrechten), damit Railyard den Server für dich provisionieren kann. Für eine Rails-App auf Hetzner ist Railyards Standard ein 2-vCPU-/4-GB-Server, dieselbe Größe, die oben manuell verwendet wurde, und du kannst jede Hetzner-Region wählen, einschließlich Nürnberg, Falkenstein oder Helsinki, falls dir EU-Datenresidenz wichtig ist.

Ab da liest Railyard deine App auf die gleiche Weise, wie diese Anleitung es von Hand getan hat: Es sieht pg in deiner Gemfile.lock und provisioniert eine gemanagte Postgres-Datenbank statt SQLite, und es führt bin/rails db:prepare vor jedem Release aus, genauso wie das eigene Entrypoint des Dockerfiles es in Schritt 8 manuell tat. Wenn du auf Rails 8 bist, gibt es den Cache-, Queue- und Cable-Datenbanken ihre eigenen Connection-Strings, statt dass du selbst darüber nachdenken musst, wie Schritt 4 es erklärt hat. Active Storage auf Disk bekommt automatisch ein persistentes Volume, passend zum storage-Volume aus Schritt 6, und SECRET_KEY_BASE wird einmal generiert und behalten, sodass du es nie von Hand verwalten musst. Wenn deine Gemfile.lock Sidekiq, Solid Queue, GoodJob, Resque oder Delayed Job enthält, bietet Railyard es als Worker-Prozess neben deinem Web-Prozess an, kein separater Compose-Service zu schreiben. HTTPS und der Reverse-Proxy aus Schritt 7 werden auf die gleiche Weise gehandhabt, sobald du deine Domain hinzufügst.

Mehr dazu in Deployment aus einem Repository, Datenbanken und Backups, dem One-Click-Deploy-Button, Erste Schritte, dem Rails-auf-Hetzner-Referenzguide, und Hosting-Kostenvergleichen, wenn du sehen willst, wie die Nur-Server-Kosten hier mit einem gemanagten Weg vergleichen. Railyards Free-Plan deckt einen Server plus einen Datenbankserver und drei Apps ohne Kreditkarte ab, Pro kostet 19 $/Monat pro Team, und Team kostet 49 $/Monat pro Team.

Troubleshooting

Port bereits belegt. Falls docker compose up fehlschlägt, weil etwas an Port 80 oder 443 gebunden ist, läuft möglicherweise bereits Ubuntus Standard-Apache oder -Nginx. Prüfe mit ss -tlnp | grep -E ':80|:443' und stoppe oder entferne den Service, der sie belegt, bevor Caddy versucht zu binden.

Falscher RAILS_MASTER_KEY oder Credentials. Ein Fehler wie ActiveSupport::MessageEncryptor::InvalidMessage beim Boot bedeutet fast immer, dass der RAILS_MASTER_KEY in /opt/myapp/.env nicht zu dem Key passt, der config/credentials.yml.enc verschlüsselt hat. Führe cat config/master.key lokal erneut aus und vergleiche ihn Zeichen für Zeichen mit dem, was in .env auf dem Server steht.

Postgres-Verbindung abgelehnt. Falls die App-Logs could not connect to server: Connection refused zeigen, prüfe, ob DATABASE_URL db als Hostnamen verwendet, nicht localhost oder 127.0.0.1. Innerhalb von Compose’ Netzwerk ist db die einzige Adresse, die zum Postgres-Container aufgelöst wird; localhost meint den app-Container selbst, der keine Datenbank laufen hat.

HTTPS wird nicht ausgestellt. Falls docker compose logs caddy wiederholte Zertifikatsfehler oder NXDOMAIN zeigt, hat sich dein DNS-Eintrag aus Schritt 2 entweder noch nicht propagiert oder zeigt auf die falsche IP. Führe dig +short myapp.example.com erneut aus, bestätige, dass es zum Server passt, und starte Caddy mit docker compose restart caddy neu, sobald es passt.

Speichermangel beim Asset-Precompile. Die 4 GB eines CX23 reichen normalerweise, aber docker compose build kann bei größeren Apps trotzdem mitten in der Asset-Kompilierung abgebrochen werden, besonders wenn Build und Betrieb gleichzeitig laufen. Füge vor dem Build eine 2-GB-Swap-Datei hinzu: fallocate -l 2G /swapfile && chmod 600 /swapfile && mkswap /swapfile && swapon /swapfile, und mache sie dann dauerhaft, indem du sie zu /etc/fstab hinzufügst.

FAQ

Reicht ein CX23 für Produktion? Für eine kleine bis mittelgroße Rails-App mit moderatem Traffic, ja, da Puma, Postgres und Caddy zusammen bequem in 4 GB passen, und der Swap-Fix oben den einen Fall abdeckt, große Asset-Builds, in dem der Speicher knapp wird.

Kann ich Postgres überspringen und bei SQLite bleiben? Du kannst; SQLite funktioniert auf einem einzelnen Server problemlos, solange du seine Datenbankdatei auf einem persistenten Volume mountest, damit sie Container-Neustarts überlebt, aber Postgres gibt dir einen saubereren Weg, falls du jemals einen zweiten App-Server hinzufügst.

Wie füge ich Redis oder Sidekiq hinzu? Füge compose.yml einen redis-Service mit dem offiziellen redis-Image und eigenem Named Volume hinzu, füge apps Umgebung REDIS_URL hinzu, das auf redis://redis:6379/0 zeigt, und füge einen worker-Service hinzu, der bundle exec sidekiq gegen dasselbe Image statt den Rails-Server-Befehl ausführt.

Überlebt das einen Server-Reboot? Ja, restart: unless-stopped bei jedem Service bedeutet, dass Docker den gesamten Stack automatisch wieder hochfährt, nachdem der Server neu gestartet ist, solange Dockers eigener systemd-Service aktiviert ist, was das Installationsskript in Schritt 3 standardmäßig tut.

Was sind Solid Cache, Solid Queue und Solid Cable? Das sind Rails 8s Standard-Backends für Caching, Background Jobs und Action Cable, allesamt auf einer Datenbank statt auf einem separaten Service wie Redis aufbauend. Deshalb braucht eine frische Rails-8-App vier Datenbanknamen statt einen, und deshalb deckt der einzelne Postgres-Container und ein Passwort in dieser Anleitung alle ab.

Das ist ein vollständiges Produktions-Setup: ein Server, Docker Compose, das deine App und Postgres ausführt, Caddy, das HTTPS übernimmt, ein nächtliches Backup und ein Zwei-Befehle-Release. Behalte dieses compose.yml und Caddyfile in deinem Repository neben der App, und jedes zukünftige Deployment ist derselbe git pull, Build, Restart, den du gerade in Schritt 10 ausgeführt hast.

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.

Überspring das Server-Setup

Füge die URL deines Repositorys bei Railyard ein und bekomme Postgres, HTTPS und Backups automatisch auf Hetzner konfiguriert.

Kostenloser Plan, keine Kreditkarte, und 14 Tage Pro für jedes neue Team. Ein fester Preis pro Team.