Laravel auf Hetzner deployen
Folge dem auf deinem eigenen Server. Kostenlos starten, keine Karte nötig
php artisan serve ist auf deinem Laptop völlig in Ordnung. In Produktion braucht eine Laravel-App einen echten Webserver, eine Datenbank, einen Queue-Worker, der nach einem Absturz wiederkommt, den Scheduler, der jede Minute läuft, und HTTPS. Diese Anleitung baut das alles auf einem einzigen Hetzner-Cloud-Server mit Docker Compose, unter Verwendung von FrankenPHP, dem Application-Server, den Laravels eigene Deployment-Docs direkt neben Nginx aufführen. Am Ende hast du die App live auf deiner Domain, eine Drei-Befehle-Deploy-Routine und ein Datenbank-Backup, das du tatsächlich wiederhergestellt hast.
Was du brauchst
- Einen Hetzner-Cloud-Server. Der kosteneffiziente CX23 (2 vCPU, 4 GB Speicher, 40 GB NVMe, 20 TB Traffic) kostet 5,99 € im Monat exkl. MwSt., mit einer inkludierten IPv4-Adresse. Er betreibt eine Laravel-App mit ihrer MySQL-Datenbank. Der CX33 (4 vCPU, 8 GB, 80 GB) kostet 8,99 € im Monat, falls die Queue schwere Arbeit erledigt. Preise stammen von Hetzners kosteneffizienten Plänen.
- Eine Domain, deren DNS du bearbeiten kannst.
- Einen Hetzner-Cloud-Account mit hochgeladenem SSH-Key.
- Eine Laravel-App in einem Git-Repository. Diese Anleitung verwendet Laravel 13, das PHP 8.3 oder neuer benötigt, und betreibt es auf PHP 8.4 mit dem offiziellen
dunglas/frankenphp:1-php8.4-Image.
Schritt 1: Den Server erstellen und DNS darauf richten
Öffne in der Hetzner-Cloud-Konsole dein Projekt und wähle Add Server. Wähle einen Standort (Nürnberg, Falkenstein und Helsinki halten die Daten in der EU), Ubuntu 24.04, den CX23-Typ unter kosteneffizient, und deinen SSH-Key. Erstelle ihn und kopiere seine IPv4-Adresse.
Erstelle bei deinem DNS-Provider einen A-Record für deine Domain (zum Beispiel app.example.com), der auf diese Adresse zeigt. Mach das jetzt, denn FrankenPHP fordert das HTTPS-Zertifikat in dem Moment an, in dem es in Schritt 6 startet, und das kann nur gelingen, sobald der Name auf diesen Server auflöst.
Verbinde dich:
ssh root@your-server-ip
Du solltest einen root@-Prompt ohne Passwort bekommen, da dein Key beim Erstellen des Servers hinzugefügt wurde.
Schritt 2: Docker installieren
curl -fsSL https://get.docker.com | sh
docker compose version
Dockers offizielles Komfort-Skript installiert die Engine und das Compose-Plugin. Du solltest eine Compose-Version ausgegeben sehen.
Schritt 3: Das Dockerfile schreiben
FrankenPHP ist ein auf Caddy aufgebauter PHP-Application-Server. Ein Container bedient PHP und deine statischen Dateien und bekommt sein eigenes Let’s-Encrypt-Zertifikat, also brauchst du kein separates Nginx-und-PHP-FPM-Paar. Das Image basiert auf dem offiziellen PHP-Image, und seine Standardseite bedient das public/-Verzeichnis von /app, genau dort, wo Laravels Front-Controller lebt.
Füge dieses Dockerfile zum Root des Repositorys hinzu:
FROM node:24-slim AS assets
WORKDIR /app
COPY package.json package-lock.json* ./
RUN if [ -f package-lock.json ]; then npm ci; else npm install; fi
COPY . .
RUN npm run build
FROM dunglas/frankenphp:1-php8.4
RUN install-php-extensions pdo_mysql intl zip opcache pcntl bcmath \
&& cp "$PHP_INI_DIR/php.ini-production" "$PHP_INI_DIR/php.ini"
COPY --from=composer:2 /usr/bin/composer /usr/bin/composer
WORKDIR /app
COPY composer.json composer.lock ./
RUN composer install --no-dev --no-scripts --no-autoloader --prefer-dist
COPY . .
COPY --from=assets /app/public/build ./public/build
RUN composer dump-autoload --optimize --no-dev
Was jeder Teil macht:
- Die
assets-Stufe baut deine Vite-Assets nachpublic/build, sodass das Produktionsimage kompiliertes CSS und JavaScript mitbringt. Committe einepackage-lock.json, damit die Installation wiederholbar ist. install-php-extensionsist ein im FrankenPHP-Image enthaltener Helfer. Laravels dokumentierte Anforderungen (Ctype, cURL, DOM, Fileinfo, Filter, Hash, Mbstring, OpenSSL, PCRE, PDO, Session, Tokenizer und XML) sind bereits im Basis-Image enthalten; das hier fügt den MySQL-Treiber und die von den meisten Apps und Queue-Workern verwendeten Erweiterungen hinzu.- Das Kopieren von
php.ini-productionan die richtige Stelle schaltet PHP auf seine Produktionsvorgaben um. - Composer installiert Abhängigkeiten, bevor der restliche Code kopiert wird, sodass Docker diese Layer wiederverwendet, wenn sich nur dein Code ändert.
Füge daneben eine .dockerignore hinzu, damit lokale Abhängigkeiten, Build-Output und deine .env aus dem Image draußen bleiben:
.env
.git
node_modules
vendor
public/build
storage/logs/*
storage/framework/cache/data/*
storage/framework/sessions/*
storage/framework/views/*
Schritt 4: Die Compose-Datei schreiben
Die App braucht vier langlaufende Prozesse: den Webserver, einen Queue-Worker, den Scheduler und MySQL. Die ersten drei laufen mit demselben Image, aber einem anderen Befehl. Füge compose.yaml hinzu:
x-laravel: &laravel
build: .
image: laravel-app
restart: unless-stopped
env_file: .env
volumes:
- storage:/app/storage
depends_on:
db:
condition: service_healthy
services:
app:
<<: *laravel
ports:
- "80:80"
- "443:443"
- "443:443/udp"
volumes:
- storage:/app/storage
- caddy-data:/data
- caddy-config:/config
queue:
<<: *laravel
command: php artisan queue:work --tries=3
healthcheck:
disable: true
scheduler:
<<: *laravel
command: php artisan schedule:work
healthcheck:
disable: true
db:
image: mysql:8.4
restart: unless-stopped
environment:
MYSQL_DATABASE: ${DB_DATABASE}
MYSQL_USER: ${DB_USERNAME}
MYSQL_PASSWORD: ${DB_PASSWORD}
MYSQL_RANDOM_ROOT_PASSWORD: "yes"
volumes:
- db-data:/var/lib/mysql
healthcheck:
test: ["CMD", "mysqladmin", "ping", "-h", "127.0.0.1", "--silent"]
interval: 5s
timeout: 5s
retries: 30
volumes:
storage:
db-data:
caddy-data:
caddy-config:
Einige Details leisten hier echte Arbeit:
condition: service_healthylässt die PHP-Container warten, bis MySQL Verbindungen akzeptiert, nicht nur bis sein Container existiert. Der Healthcheck verbindet sich über127.0.0.1, was erst gelingt, sobald MySQL seine Erstinitialisierung abgeschlossen hat.php artisan schedule:workführt Laravels Scheduler im Vordergrund aus und ruft ihn jede Minute auf, sodass der Container keinen Cron-Daemon braucht.- Das FrankenPHP-Image bringt einen Healthcheck mit, der seinen eingebauten Webserver abfragt. Die Queue- und Scheduler-Container führen diesen Server nicht aus, also sind ihre Healthchecks deaktiviert statt als fehlgeschlagen gemeldet.
storagewird von den drei PHP-Containern geteilt, sodass Logs und hochgeladene Dateien auf der lokalen Disk überall gleich sind.caddy-datahält die Zertifikate, sodass sie jedes Redeploy überleben.- MySQL veröffentlicht keinen Port. Nur der App-Container kann es erreichen, über den Hostnamen
db.
Schritt 5: Den Code klonen und .env erstellen
Auf dem Server:
git clone https://github.com/you/your-app.git /opt/your-app
cd /opt/your-app
Für ein privates Repository füge ihm erst einen Nur-Lese-Deploy-Key hinzu, oder klone über HTTPS mit einem Token. Laravels Standard-.gitignore schließt .env bereits aus, sodass die Datei, die du jetzt erstellst, über Deploys hinweg auf dem Server bleibt:
APP_NAME=YourApp
APP_ENV=production
APP_KEY=
APP_DEBUG=false
APP_URL=https://app.example.com
SERVER_NAME=app.example.com
LOG_CHANNEL=stderr
DB_CONNECTION=mysql
DB_HOST=db
DB_PORT=3306
DB_DATABASE=laravel
DB_USERNAME=laravel
DB_PASSWORD=replace-with-a-long-random-password
SERVER_NAME sagt FrankenPHP, welche Domain es bedienen und für die es ein Zertifikat anfordern soll. LOG_CHANNEL=stderr schickt Laravels Logs an docker compose logs. Erzeuge das Datenbankpasswort mit openssl rand -hex 24, und schütze die Datei dann mit chmod 600 .env. Compose liest dieselbe Datei, um ${DB_DATABASE} und die anderen Werte für MySQL zu füllen.
Laravel braucht einen Verschlüsselungs-Key. Baue das Image und lass Laravel einen erzeugen:
docker compose build
docker compose run --rm --no-deps app php artisan key:generate --show
Du solltest einen Wert sehen, der mit base64: beginnt. Füge ihn nach APP_KEY= in .env ein. Behalte eine Kopie an einem sicheren Ort: Wenn du ihn verlierst, können verschlüsselte Daten und bestehende Sessions nicht mehr gelesen werden.
Schritt 6: Alles starten und die Migrationen ausführen
docker compose up -d
docker compose exec app php artisan migrate --force
docker compose exec app php artisan optimize
--force ist erforderlich, da Laravel vor dem Migrieren in Produktion eine Bestätigung verlangt. optimize cacht Konfiguration, Events, Routen und Views, was Laravels Deployment-Docs für jeden Deploy empfehlen.
Prüfe die Container:
docker compose ps
Du solltest app, queue, scheduler und db mit Status Up sehen, und db als healthy markiert. Öffne https://app.example.com: Die App lädt über HTTPS mit einem gültigen Zertifikat. Laravels eingebaute Health-Route sollte auch antworten:
curl -I https://app.example.com/up
Du solltest HTTP/2 200 sehen.
Die Queue- und Scheduler-Container starten eventuell ein paar Mal neu, bevor die Migrationen fertig sind, weil die Datenbanktabellen, die sie verwenden, noch nicht existieren. Das ist erwartet, und sie beruhigen sich, sobald migrate gelaufen ist.
Schritt 7: Neue Versionen deployen
Jedes Release:
cd /opt/your-app
git pull
docker compose up -d --build
docker compose exec app php artisan migrate --force
docker compose exec app php artisan optimize
Compose baut das Image neu und erstellt die drei PHP-Container neu, sodass der Queue-Worker und der Scheduler den neuen Code übernehmen. Das ist, was Laravels Docs meinen, wenn sie sagen, langlaufende Prozesse müssen nach einem Deploy neu gestartet werden. MySQL läuft weiter, und die Zertifikate bleiben in ihrem Volume. Die Seite ist für die wenigen Sekunden nicht verfügbar, die der App-Container zum Neustart braucht.
Um zurückzurollen, führe git checkout auf den vorherigen Commit aus und führe dieselben Befehle aus. Falls das Release eine Migration enthielt, rolle sie erst zurück mit docker compose exec app php artisan migrate:rollback --force, während der neue Code noch läuft.
Schritt 8: Die Datenbank sichern
MySQL enthält die einzigen Daten, die du nicht aus Git rekonstruieren kannst. Speichere das als /opt/your-app/backup.sh:
#!/usr/bin/env bash
set -euo pipefail
cd /opt/your-app
mkdir -p backups
docker compose exec -T db sh -c 'exec mysqldump --no-tablespaces --single-transaction -u "$MYSQL_USER" -p"$MYSQL_PASSWORD" "$MYSQL_DATABASE"' | gzip > "backups/db-$(date +%F).sql.gz"
find backups -name 'db-*.sql.gz' -mtime +14 -delete
--single-transaction nimmt eine konsistente Momentaufnahme, ohne InnoDB-Tabellen zu sperren, und --no-tablespaces lässt den eigenen Datenbankbenutzer der App den Dump ausführen, ohne zusätzliche Berechtigungen. Mach sie ausführbar und plane sie mit crontab -e:
chmod +x /opt/your-app/backup.sh
30 3 * * * /opt/your-app/backup.sh >> /var/log/your-app-backup.log 2>&1
Ein Backup auf derselben Disk überlebt den Verlust des Servers nicht, also kopiere den backups/-Ordner auch irgendwohin anders: Hetzner Object Storage, eine andere Maschine, oder irgendeinen S3-kompatiblen Bucket. Beweise dann, dass der Dump funktioniert, indem du ihn in die Datenbank wiederherstellst:
gunzip -c backups/db-YYYY-MM-DD.sql.gz | docker compose exec -T db sh -c 'exec mysql -u "$MYSQL_USER" -p"$MYSQL_PASSWORD" "$MYSQL_DATABASE"'
Ersetze YYYY-MM-DD mit dem Datum der Datei. Das ersetzt die Tabellen mit dem Inhalt des Backups, also probier es direkt nach einem Backup aus, oder gegen eine Kopie des Stacks. Du solltest keine Fehler sehen, und die App sollte wie zuvor funktionieren.
Die Abkürzung: mit Railyard
Alles oben ist ein Rezept, das Railyard für dich ausführt, ohne das Dockerfile, die Compose-Datei oder das Backup-Skript. Siehe Laravel auf Hetzner deployen für die Provider-Anleitung.
- Öffne in der Hetzner-Cloud-Konsole dein Projekt, dann Security, dann API tokens, und erzeuge ein Token mit Read & Write-Berechtigung.
- Geh in Railyard zu Servers → Add a server → Create on your cloud, wähle Hetzner, einen Standort und eine Größe. Der Server wird in deinem eigenen Hetzner-Account erstellt, und Hetzner rechnet direkt mit dir ab, ohne Aufschlag. Du kannst auch einen Server verbinden, den du schon hast: Ubuntu 22.04 oder 24.04, passwortloses
sudo, offene Ports 22, 80 und 443. - Deploye das Repository, oder öffne
https://app.railyard.run/deploy?repo=https://github.com/you/your-app.gitmit deiner eigenen Repository-URL.
Railyard führt php artisan migrate --force vor jedem Release aus. Es verdrahtet DB_HOST, DB_DATABASE und die anderen Datenbankvariablen aus deiner .env.example mit der Datenbank, die es bereitstellt, erzeugt APP_KEY einmal in Laravels Base64-Format, und setzt APP_URL auf die Adresse der App. php artisan queue:work wird als Worker angeboten, oder horizon, wenn laravel/horizon installiert ist. Datenbanken bekommen geplante Backups, die durch Wiederherstellung verifiziert werden, mit Ein-Klick-Wiederherstellung; siehe Datenbanken und Backups.
Der Free-Plan deckt einen Server plus einen Datenbankserver und drei Apps ab, keine Karte erforderlich. Pro kostet 19 $ im Monat pro Team und Team kostet 49 $ im Monat pro Team. Die Laravel-Docs listen die Erkennungsregeln.
Troubleshooting
bind: address already in use auf Port 80 oder 443. Etwas anderes auf dem Server hält den Port, oft ein zuvor installiertes Nginx oder Apache. sudo ss -tlnp | grep -E ':(80|443) ' zeigt, welcher Prozess. Stoppe und deaktiviere ihn, zum Beispiel sudo systemctl disable --now nginx, und führe dann docker compose up -d aus.
Kein Zertifikat, und die App-Logs zeigen ACME-Fehler. DNS zeigt noch nicht auf den Server, SERVER_NAME stimmt nicht mit der Domain überein, oder eine Firewall blockiert die Ports 80 und 443. dig +short app.example.com muss die IP des Servers zurückgeben. Falls du eine Hetzner-Cloud-Firewall hinzugefügt hast, erlaube eingehendes TCP 80 und 443 (und UDP 443 für HTTP/3). Der Server versucht es selbstständig erneut, sobald DNS stimmt.
SQLSTATE[HY000] [2002] oder „Access denied” von MySQL. Falls es ein Verbindungsfehler ist, prüfe DB_HOST=db. Falls der Zugriff verweigert wird, hat sich das Passwort in .env geändert, nachdem MySQL zum ersten Mal gestartet ist: MySQL liest MYSQL_USER und MYSQL_PASSWORD nur, wenn es ein leeres Daten-Volume initialisiert. Setze das ursprüngliche Passwort zurück, oder ändere es innerhalb von MySQL.
Links und Weiterleitungen verwenden http:// oder den falschen Host. APP_URL ist falsch, oder der Konfigurations-Cache hält einen alten Wert. Korrigiere .env, führe dann docker compose up -d und docker compose exec app php artisan optimize aus, damit die Container und der Cache ihn übernehmen.
Vite manifest not found at: /app/public/build/manifest.json. Die kompilierten Assets sind nicht im Image. Prüfe, dass npm run build in der assets-Stufe erfolgreich war (docker compose --progress plain build app zeigt dessen Ausgabe) und dass deine Vite-Konfiguration weiterhin nach public/build schreibt, und baue dann neu.
„Permission denied” beim Schreiben nach storage. Das FrankenPHP-Image läuft standardmäßig als root, also bedeutet das meist, dass Dateien von Hand ins Volume kopiert wurden, oder ein Image wurde geändert, um als anderer Benutzer zu laufen. Mach das Volume beschreibbar für den Benutzer, als der PHP läuft, zum Beispiel docker compose exec app chown -R www-data:www-data storage, falls du zu www-data gewechselt bist.
Der Build wird beim Kompilieren von Assets oder Installieren von Abhängigkeiten gekillt. Ein CX23 hat 4 GB Speicher, was meist reicht, aber ein großer Frontend-Build kann ihn neben einem laufenden Stack erschöpfen. Füge eine 2-GB-Swap-Datei hinzu (fallocate -l 2G /swapfile && chmod 600 /swapfile && mkswap /swapfile && swapon /swapfile), oder steig auf einen CX33 um.
FAQ
Kann ich stattdessen Nginx und PHP-FPM verwenden? Ja. Laravels Deployment-Docs enthalten eine Nginx-Konfiguration für PHP-FPM. Du würdest ein PHP-FPM-Image, einen Nginx-Container mit dieser Konfiguration und etwas für Zertifikate betreiben. FrankenPHP macht alle drei in einem Container, weshalb diese Anleitung es verwendet.
Was ist mit Laravel Octane? FrankenPHP ist einer der unterstützten Server von Octane. Installiere laravel/octane, führe php artisan octane:install --server=frankenphp aus, und starte den App-Container mit php artisan octane:frankenphp. Lies zuerst Octanes Dokumentation: Es hält die App zwischen Requests im Speicher, was ändert, wie du mit Zustand umgehen musst.
Sollte ich Postgres statt MySQL verwenden? Beide funktionieren mit Laravel. Tausche den db-Service gegen das offizielle Postgres-Image, setze DB_CONNECTION=pgsql und DB_PORT=5432, und installiere die Erweiterung pdo_pgsql statt pdo_mysql.
Wohin gehen hochgeladene Dateien? Mit den Standard-Disks local und public nach storage/app, das im storage-Volume liegt. Sichere auch dieses Volume, oder verschiebe Uploads in S3-kompatiblen Object Storage, was es dir auch erlaubt, später einen zweiten Server hinzuzufügen.
Brauche ich Redis? Nicht zum Starten. Dieses Setup verwendet Laravel 13s Standardwerte, die Cache, Sessions und Queue in der Datenbank halten. Füge einen Redis-Service hinzu, wenn du zu Horizon wechselst oder einen schnelleren Cache brauchst.
Das ist der gesamte Stack: ein Image für den Webserver, den Worker und den Scheduler, MySQL daneben, und Zertifikate für dich erledigt. Nach dem ersten Setup ist jedes Release ein git pull, ein Compose-Befehl und eine Migration.
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.