Déployer Laravel sur Hetzner
Suivez ça sur votre propre serveur. Commencer gratuitement, sans carte
php artisan serve convient très bien sur votre ordinateur portable. En production, une appli Laravel a besoin d’un vrai serveur web, d’une base de données, d’un worker de file d’attente qui revient après un plantage, du planificateur qui tourne chaque minute, et du HTTPS. Ce guide construit tout ça sur un seul serveur Hetzner Cloud avec Docker Compose, en utilisant FrankenPHP, le serveur d’application que la documentation de déploiement de Laravel liste aux côtés de Nginx. À la fin, vous aurez l’appli en ligne sur votre domaine, une routine de déploiement en trois commandes, et une sauvegarde de base de données que vous avez réellement restaurée.
Ce qu’il vous faut
- Un serveur Hetzner Cloud. Le CX23, de la catégorie cost-optimized (2 vCPU, 4 Go de mémoire, 40 Go NVMe, 20 To de trafic), est à 5,99 € par mois hors TVA, avec une adresse IPv4 incluse. Il fait tourner une appli Laravel avec sa base de données MySQL. Le CX33 (4 vCPU, 8 Go, 80 Go) est à 8,99 € par mois si la file d’attente fait un travail lourd. Les prix proviennent de la page des plans cost-optimized de Hetzner.
- Un domaine dont vous pouvez modifier le DNS.
- Un compte Hetzner Cloud avec votre clé SSH téléversée.
- Une appli Laravel dans un dépôt Git. Ce guide utilise Laravel 13, qui nécessite PHP 8.3 ou plus récent, et le fait tourner sur PHP 8.4 avec l’image officielle
dunglas/frankenphp:1-php8.4.
Étape 1 : Créer le serveur et pointer le DNS vers lui
Dans la Hetzner Cloud Console, ouvrez votre projet et choisissez Add Server. Choisissez un emplacement (Nuremberg, Falkenstein et Helsinki gardent les données dans l’UE), Ubuntu 24.04, le type CX23 de la catégorie cost-optimized, et votre clé SSH. Créez-le et copiez son adresse IPv4.
Chez votre fournisseur DNS, créez un enregistrement A pour votre domaine (par exemple app.example.com) pointant vers cette adresse. Faites-le maintenant, car FrankenPHP demande le certificat HTTPS dès qu’il démarre à l’étape 6, et ça ne peut réussir qu’une fois que le nom résout vers ce serveur.
Connectez-vous :
ssh root@your-server-ip
Vous devriez obtenir une invite root@ sans mot de passe, puisque votre clé a été ajoutée à la création du serveur.
Étape 2 : Installer Docker
curl -fsSL https://get.docker.com | sh
docker compose version
Le script d’installation officiel de Docker installe le moteur et le plugin Compose. Vous devriez voir une version de Compose s’afficher.
Étape 3 : Écrire le Dockerfile
FrankenPHP est un serveur d’application PHP construit sur Caddy. Un seul conteneur sert PHP et vos fichiers statiques et obtient son propre certificat Let’s Encrypt, donc vous n’avez pas besoin d’une paire Nginx et PHP-FPM séparée. L’image est basée sur l’image PHP officielle, et son site par défaut sert le dossier public/ de /app, qui est exactement là où vit le front controller de Laravel.
Ajoutez ce Dockerfile à la racine du dépôt :
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
Ce que fait chaque partie :
- L’étape
assetsbuild vos assets Vite danspublic/build, donc l’image de production embarque du CSS et du JavaScript compilés. Committez unpackage-lock.jsonpour que l’installation soit répétable. install-php-extensionsest un utilitaire inclus dans l’image FrankenPHP. Les prérequis documentés de Laravel (Ctype, cURL, DOM, Fileinfo, Filter, Hash, Mbstring, OpenSSL, PCRE, PDO, Session, Tokenizer et XML) sont déjà dans l’image de base ; ceci ajoute le driver MySQL et les extensions que la plupart des applis et workers de file d’attente utilisent.- Copier
php.ini-productionà sa place bascule PHP sur ses valeurs par défaut de production. - Composer installe les dépendances avant que le reste du code soit copié, donc Docker réutilise cette couche quand seul votre code change.
Ajoutez un .dockerignore à côté, pour que les dépendances locales, la sortie de build et votre .env restent hors de l’image :
.env
.git
node_modules
vendor
public/build
storage/logs/*
storage/framework/cache/data/*
storage/framework/sessions/*
storage/framework/views/*
Étape 4 : Écrire le fichier Compose
L’appli a besoin de quatre processus de longue durée : le serveur web, un worker de file d’attente, le planificateur et MySQL. Les trois premiers font tourner la même image avec une commande différente. Ajoutez compose.yaml :
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:
Quelques détails font vraiment le travail ici :
condition: service_healthyfait attendre les conteneurs PHP jusqu’à ce que MySQL accepte les connexions, pas seulement jusqu’à ce que son conteneur existe. Le contrôle de santé se connecte via127.0.0.1, ce qui ne réussit qu’une fois que MySQL a terminé son initialisation de premier démarrage.php artisan schedule:workfait tourner le planificateur de Laravel au premier plan et l’invoque chaque minute, donc le conteneur n’a pas besoin de démon cron.- L’image FrankenPHP embarque un contrôle de santé qui interroge son serveur web intégré. Les conteneurs queue et scheduler ne font pas tourner ce serveur, donc leurs contrôles de santé sont désactivés plutôt que signalés comme en échec.
storageest partagé par les trois conteneurs PHP, donc les journaux et les fichiers téléversés sur le disque local sont les mêmes partout.caddy-dataconserve les certificats, afin qu’ils survivent à chaque redéploiement.- MySQL ne publie aucun port. Seul le conteneur app peut l’atteindre, par le nom d’hôte
db.
Étape 5 : Cloner le code et créer .env
Sur le serveur :
git clone https://github.com/you/your-app.git /opt/your-app
cd /opt/your-app
Pour un dépôt privé, ajoutez-lui d’abord une clé de déploiement en lecture seule, ou clonez en HTTPS avec un jeton. Le .gitignore par défaut de Laravel exclut déjà .env, donc le fichier que vous créez maintenant reste sur le serveur entre les déploiements :
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 indique à FrankenPHP quel domaine servir et pour lequel demander un certificat. LOG_CHANNEL=stderr envoie les journaux de Laravel vers docker compose logs. Générez le mot de passe de la base de données avec openssl rand -hex 24, puis protégez le fichier avec chmod 600 .env. Compose lit ce même fichier pour remplir ${DB_DATABASE} et les autres valeurs pour MySQL.
Laravel a besoin d’une clé de chiffrement. Construisez l’image et laissez Laravel en générer une :
docker compose build
docker compose run --rm --no-deps app php artisan key:generate --show
Vous devriez voir une valeur commençant par base64:. Collez-la après APP_KEY= dans .env. Gardez-en une copie en lieu sûr : si vous la perdez, les données chiffrées et les sessions existantes ne pourront plus être lues.
Étape 6 : Tout démarrer et lancer les migrations
docker compose up -d
docker compose exec app php artisan migrate --force
docker compose exec app php artisan optimize
--force est requis parce que Laravel demande une confirmation avant de migrer en production. optimize met en cache la configuration, les événements, les routes et les vues, ce que la documentation de déploiement de Laravel recommande à chaque déploiement.
Vérifiez les conteneurs :
docker compose ps
Vous devriez voir app, queue, scheduler et db avec un statut Up, et db marqué healthy. Ouvrez https://app.example.com : l’appli se charge en HTTPS avec un certificat valide. La route de santé intégrée de Laravel devrait aussi répondre :
curl -I https://app.example.com/up
Vous devriez voir HTTP/2 200.
Les conteneurs queue et scheduler peuvent redémarrer plusieurs fois avant que les migrations ne soient terminées, parce que les tables de base de données qu’ils utilisent n’existent pas encore. C’est normal, et ils se stabilisent une fois que migrate a tourné.
Étape 7 : Déployer de nouvelles versions
À chaque version :
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 reconstruit l’image et recrée les trois conteneurs PHP, afin que le worker de file d’attente et le planificateur récupèrent le nouveau code. C’est ce que la documentation de Laravel veut dire quand elle affirme que les processus de longue durée doivent être redémarrés après un déploiement. MySQL continue de tourner, et les certificats restent dans leur volume. Le site est indisponible pendant les quelques secondes que prend le conteneur app pour redémarrer.
Pour revenir en arrière, faites un git checkout du commit précédent et relancez les mêmes commandes. Si la version incluait une migration, annulez-la d’abord avec docker compose exec app php artisan migrate:rollback --force, pendant que le nouveau code tourne encore.
Étape 8 : Sauvegarder la base de données
MySQL détient les seules données que vous ne pouvez pas reconstruire depuis Git. Enregistrez ceci sous /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 prend un instantané cohérent sans verrouiller les tables InnoDB, et --no-tablespaces permet à l’utilisateur de base de données propre à l’appli de lancer le dump sans privilèges supplémentaires. Rendez-le exécutable et planifiez-le avec crontab -e :
chmod +x /opt/your-app/backup.sh
30 3 * * * /opt/your-app/backup.sh >> /var/log/your-app-backup.log 2>&1
Une sauvegarde sur le même disque ne survit pas à la perte du serveur, donc copiez aussi le dossier backups/ ailleurs : Hetzner Object Storage, une autre machine, ou n’importe quel bucket compatible S3. Prouvez ensuite que le dump fonctionne en le restaurant dans la base de données :
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"'
Remplacez YYYY-MM-DD par la date du fichier. Ceci remplace les tables par le contenu de la sauvegarde, donc essayez-le juste après avoir pris une sauvegarde, ou contre une copie de la stack. Vous ne devriez voir aucune erreur, et l’appli devrait fonctionner comme avant.
Le raccourci : avec Railyard
Tout ce qui précède est une recette que Railyard exécute pour vous, sans le Dockerfile, le fichier Compose ni le script de sauvegarde. Voir Déployer Laravel sur Hetzner pour la marche à suivre côté fournisseur.
- Dans la Hetzner Cloud Console, ouvrez votre projet, puis Security, puis API tokens, et générez un jeton avec la permission Read & Write.
- Dans Railyard, allez dans Servers → Add a server → Create on your cloud, choisissez Hetzner, un emplacement et une taille. Le serveur est créé dans votre propre compte Hetzner, et Hetzner vous facture directement sans majoration. Vous pouvez aussi connecter un serveur que vous avez déjà : Ubuntu 22.04 ou 24.04,
sudosans mot de passe, ports 22, 80 et 443 ouverts. - Déployez le dépôt, ou ouvrez
https://app.railyard.run/deploy?repo=https://github.com/you/your-app.gitavec l’URL de votre propre dépôt.
Railyard lance php artisan migrate --force avant chaque version. Il relie DB_HOST, DB_DATABASE et les autres variables de base de données depuis votre .env.example à la base de données qu’il provisionne, génère APP_KEY une fois au format base64 de Laravel, et règle APP_URL sur l’adresse de l’appli. php artisan queue:work est proposé comme worker, ou horizon quand laravel/horizon est installé. Les bases de données reçoivent des sauvegardes planifiées qui sont vérifiées en les restaurant, avec restauration en un clic ; voir Bases de données et sauvegardes.
Le plan Free couvre un serveur plus un serveur de base de données et trois applis, sans carte requise. Pro est à 19 $ par mois par équipe et Team à 49 $ par mois par équipe. La documentation Laravel liste les règles de détection.
Dépannage
bind: address already in use sur le port 80 ou 443. Autre chose sur le serveur occupe le port, souvent un Nginx ou Apache installé plus tôt. sudo ss -tlnp | grep -E ':(80|443) ' montre quel processus. Arrêtez-le et désactivez-le, par exemple sudo systemctl disable --now nginx, puis relancez docker compose up -d.
Pas de certificat, et l’appli journalise des erreurs ACME. Le DNS ne pointe pas encore vers le serveur, SERVER_NAME ne correspond pas au domaine, ou un pare-feu bloque les ports 80 et 443. dig +short app.example.com doit retourner l’IP du serveur. Si vous avez ajouté un Hetzner Cloud Firewall, autorisez le TCP 80 et 443 en entrée (et l’UDP 443 pour HTTP/3). Le serveur réessaie par lui-même une fois le DNS correct.
SQLSTATE[HY000] [2002] ou Access denied de MySQL. Si c’est une erreur de connexion, vérifiez DB_HOST=db. Si l’accès est refusé, le mot de passe dans .env a changé après le premier démarrage de MySQL : MySQL ne lit MYSQL_USER et MYSQL_PASSWORD qu’au moment où il initialise un volume de données vide. Remettez le mot de passe original, ou changez-le à l’intérieur de MySQL.
Les liens et redirections utilisent http:// ou le mauvais hôte. APP_URL est incorrect, ou le cache de configuration contient une ancienne valeur. Corrigez .env, puis lancez docker compose up -d et docker compose exec app php artisan optimize pour que les conteneurs et le cache la récupèrent.
Vite manifest not found at: /app/public/build/manifest.json. Les assets compilés ne sont pas dans l’image. Vérifiez que npm run build a réussi dans l’étape assets (docker compose --progress plain build app affiche sa sortie) et que votre configuration Vite écrit toujours vers public/build, puis reconstruisez.
Permission denied en écrivant dans storage. L’image FrankenPHP tourne en root par défaut, donc ceci veut souvent dire des fichiers copiés à la main dans le volume, ou une image modifiée pour tourner sous un autre utilisateur. Rendez le volume inscriptible pour l’utilisateur sous lequel PHP tourne, par exemple docker compose exec app chown -R www-data:www-data storage si vous êtes passé à www-data.
Le build est tué pendant la compilation des assets ou l’installation des dépendances. Un CX23 a 4 Go de mémoire, ce qui suffit généralement, mais un gros build front-end peut l’épuiser aux côtés d’une stack en cours d’exécution. Ajoutez un fichier swap de 2 Go (fallocate -l 2G /swapfile && chmod 600 /swapfile && mkswap /swapfile && swapon /swapfile), ou passez à un CX33.
FAQ
Puis-je utiliser Nginx et PHP-FPM à la place ? Oui. La documentation de déploiement de Laravel inclut une configuration Nginx pour PHP-FPM. Vous feriez tourner une image PHP-FPM, un conteneur Nginx avec cette configuration, et quelque chose pour gérer les certificats. FrankenPHP fait les trois dans un seul conteneur, c’est pourquoi ce guide l’utilise.
Et Laravel Octane ? FrankenPHP est l’un des serveurs pris en charge par Octane. Installez laravel/octane, lancez php artisan octane:install --server=frankenphp, et démarrez le conteneur app avec php artisan octane:frankenphp. Lisez d’abord la documentation d’Octane : elle garde l’appli en mémoire entre les requêtes, ce qui change la façon dont vous devez gérer l’état.
Devrais-je utiliser Postgres plutôt que MySQL ? Les deux fonctionnent avec Laravel. Remplacez le service db par l’image Postgres officielle, réglez DB_CONNECTION=pgsql et DB_PORT=5432, et installez l’extension pdo_pgsql au lieu de pdo_mysql.
Où vont les fichiers téléversés ? Avec les disques local et public par défaut, dans storage/app, qui vit dans le volume storage. Sauvegardez aussi ce volume, ou déplacez les téléversements vers un stockage objet compatible S3, ce qui vous permet aussi d’ajouter un second serveur plus tard.
Ai-je besoin de Redis ? Pas pour démarrer. Cette configuration utilise les valeurs par défaut de Laravel 13, qui gardent le cache, les sessions et la file d’attente dans la base de données. Ajoutez un service Redis quand vous passez à Horizon ou avez besoin d’un cache plus rapide.
Voilà toute la stack : une image pour le serveur web, le worker et le planificateur, MySQL à leurs côtés, et les certificats gérés pour vous. Après la première configuration, chaque version se résume à un git pull, une commande Compose et une migration.
Pourquoi les gens choisissent Railyard pour ça
- Votre propre compte infonuagique. Déployez sur DigitalOcean, Hetzner, Vultr, AWS, OVHcloud, ou n'importe quel serveur Linux par SSH, facturé directement par votre fournisseur, sans majoration.
- Un prix fixe par équipe. L'offre gratuite est gratuite pour toujours : un serveur plus un serveur de base de données, trois applications, sans carte. Pro coûte 19 $ par mois pour jusqu'à cinq serveurs et un nombre illimité d'applications. Team coûte 49 $ par mois avec des serveurs illimités. Aucuns frais par application, par déploiement, par siège ou par gigaoctet.
- Des gabarits en un clic. Déployez des applications open source, comme des bases de données, des outils d'analyse et des outils de développement, sur votre propre serveur en un clic.
- Des bases de données gérées. Postgres, MySQL et Redis, avec des sauvegardes planifiées, vérifiées par restauration, et la récupération à un point dans le temps.
- Déploiement par push. Déployez depuis GitHub, GitLab ou la CLI, avec HTTPS automatique et des mises en production sans interruption.