← Blogue

Déployer une application Rails sur Hetzner

Railyard

4 avril 2026 · 13 min de lecture

Suivez ça sur votre propre serveur. Commencer gratuitement, sans carte

Les serveurs cloud de Hetzner comptent parmi les ressources de calcul réelles les moins chères qu’on puisse louer au mois, mais un serveur bon marché ne fait pas tourner votre application tout seul. Il faut quand même le provisionner, installer Docker, faire tourner Postgres à côté de votre application, mettre du HTTPS devant les deux, garder une sauvegarde quelque part en dehors du serveur lui-même, et avoir une façon de livrer votre prochaine version sans tout recommencer chaque fois. À la fin de ce guide, vous aurez une application Rails, construite à partir de son propre Dockerfile généré, qui tourne sur un serveur Hetzner derrière Caddy avec un vrai certificat, appuyée sur Postgres, avec des sauvegardes nocturnes et une façon courte et répétable de déployer une nouvelle version.

Ce qu’il vous faut

  • Un compte Hetzner Cloud et un serveur. Le CX23 à coût optimisé (2 vCPU, 4 Go de mémoire, 40 Go NVMe) est affiché à 5,99 € par mois hors TVA, laquelle s’applique selon l’endroit où vous êtes facturé. Vérifiez le prix et la disponibilité actuels dans la Hetzner Cloud Console avant de commander, puisque les gammes et les prix du cloud bougent.
  • Un nom de domaine auquel vous pouvez ajouter un enregistrement DNS.
  • Une paire de clés SSH sur votre machine (ssh-keygen -t ed25519 si vous n’en avez pas déjà une).
  • Une application Rails, ou une nouvelle : ce guide en construit une à partir de zéro avec rails new, donc vous pouvez suivre même sans application existante.
  • Environ quinze minutes pour que le serveur et le DNS se stabilisent, surtout passées à attendre plutôt qu’à taper.

Étape 1 : créer le serveur et noter son IP

Dans la Hetzner Cloud Console, ouvrez (ou créez) un projet, puis choisissez Add Server. Choisissez un emplacement : Nuremberg, Falkenstein ou Helsinki gardent vos données dans l’Union européenne, tandis qu’Ashburn, Hillsboro ou Singapour sont aussi proposés si vos utilisateurs sont ailleurs. Choisissez Ubuntu 24.04 comme image, puis sous le type de serveur choisissez le CX23 à vCPU partagé (2 vCPU, 4 Go de mémoire, 40 Go NVMe). Sous SSH keys, ajoutez la clé publique de votre machine plutôt que de vous fier à un mot de passe généré, puis créez le serveur.

Une fois démarré, la console affiche son adresse IPv4 publique. Connectez-vous pour confirmer l’accès :

ssh root@YOUR_SERVER_IP

Vous devriez voir la bannière de bienvenue d’Ubuntu et atterrir sur un shell root. Si la connexion est refusée, vérifiez que la clé SSH attachée au serveur correspond bien à la clé privée sur votre machine.

Étape 2 : pointer votre domaine vers le serveur

Ajoutez un enregistrement A chez votre fournisseur DNS pour le nom d’hôte que vous voulez utiliser, pointant vers l’IP du serveur :

Type: A
Name: myapp (ou @ pour le domaine nu)
Value: YOUR_SERVER_IP
TTL: 300

La propagation DNS peut prendre quelques minutes. Vérifiez-la depuis votre propre machine avant de continuer, puisque Caddy aura besoin que ça résolve déjà correctement à une étape plus loin :

dig +short myapp.example.com

Vous devriez voir l’IP de votre serveur s’afficher. Si vous n’obtenez rien, attendez quelques minutes et réessayez avant de continuer.

Étape 3 : installer Docker sur le serveur

Le Dockerfile généré par Rails a besoin de Docker Engine et du plugin Compose sur le serveur. Le script d’installation de Docker s’occupe des deux sur une boîte Ubuntu fraîche :

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

Hetzner ne met aucun pare-feu devant un nouveau serveur par défaut, alors verrouillez l’hôte sur seulement ce dont vous avez besoin avec ufw :

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

Vérifiez l’installation :

docker --version
docker compose version

Vous devriez voir une version de Docker et une version de Compose affichées, pas une erreur « command not found ».

Étape 4 : générer l’application Rails avec un Dockerfile et Postgres

Sur votre propre machine, générez une nouvelle application configurée pour Postgres plutôt que le SQLite par défaut :

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

rails new crée un Dockerfile par défaut, listé parmi les fichiers générés aux côtés de .dockerignore, et il est ajusté pour la production : il installe vos gems et précompile les assets en une seule étape de build, puis ne copie que ce qu’il faut dans une image d’exécution allégée qui tourne comme utilisateur non root. Rails 8 inclut aussi Thruster, un petit proxy HTTP devant Puma qui gère le HTTP/2, la compression et le service des assets, ce qui explique pourquoi l’image générée expose le port 80 en interne et démarre avec ./bin/thrust ./bin/rails server plutôt que de démarrer Puma directement. Vous n’atteindrez pas ce port directement depuis Internet, Caddy se place devant à l’étape 7.

Le script d’entrée du Dockerfile exécute aussi ./bin/rails db:prepare automatiquement, chaque fois que la commande du conteneur est un serveur Rails, avant même que le serveur démarre. Ça veut dire que vous n’avez pas besoin d’une étape de migration séparée plus loin dans ce guide : le premier démarrage crée la base de données, et chaque démarrage suivant applique les migrations livrées avec cette version.

Comme vous avez passé --database=postgresql, config/database.yml est configuré pour la gem pg plutôt que SQLite. Si vous êtes sur Rails 8, le même fichier branche aussi des entrées de base de données séparées cache, queue et cable pour Solid Cache, Solid Queue et Solid Cable, les systèmes de tâches en arrière-plan et de cache par défaut de Rails. Ces quatre bases, la principale plus ces trois-là, lisent la même DATABASE_URL et ne diffèrent que par le nom de base demandé (myapp_production, myapp_production_cache, et ainsi de suite), donc un seul conteneur Postgres et un seul mot de passe couvrent tout ça ; db:prepare crée les quatre la première fois qu’il tourne.

Vérifiez que les fichiers sont bien là :

ls Dockerfile .dockerignore config/database.yml

Vous devriez voir les trois chemins affichés sans erreur.

Étape 5 : faire passer vos secrets de l’application vers le serveur

Rails chiffre config/credentials.yml.enc avec config/master.key, un fichier exclu de git volontairement et jamais livré au serveur. Lisez sa valeur une fois, sur votre propre machine :

cat config/master.key

Sur le serveur, créez un répertoire pour l’application et un fichier .env avec cette clé et un mot de passe de base de données que vous générez vous-même :

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

Vérifiez-le :

cat /opt/myapp/.env

Vous devriez voir les deux variables avec de vraies valeurs, pas des placeholders, et le fichier ne devrait pas être lisible par tout le monde (-rw------- depuis ls -l).

Étape 6 : écrire le fichier Docker Compose

Copiez votre application sur le serveur (scp, rsync, ou un git clone de votre dépôt dans /opt/myapp), puis ajoutez compose.yml à côté :

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:

Le service app rejoint db par son nom de service, pas localhost, puisque Compose met les deux conteneurs sur le même réseau privé. depends_on: condition: service_healthy empêche l’application de démarrer avant que Postgres puisse vraiment accepter des connexions, pas juste avant que son conteneur existe. Les volumes nommés gardent votre base de données et les fichiers Active Storage sur disque à travers les redémarrages et les reconstructions ; seul caddy publie des ports vers l’hôte, donc Postgres n’est jamais accessible depuis l’extérieur de ce serveur.

Vérifiez que le fichier s’analyse correctement avant d’aller plus loin :

docker compose config

Vous devriez voir la configuration entièrement résolue affichée sans ligne d’erreur.

Étape 7 : ajouter Caddy comme proxy inverse avec HTTPS

Ajoutez un Caddyfile à côté de compose.yml :

myapp.example.com {
	reverse_proxy app:80
}

Remplacez myapp.example.com par le domaine que vous avez pointé vers le serveur à l’étape 2. Caddy fait deux choses tout seul à partir de ce seul bloc : il relaie chaque requête vers le port 80 du conteneur app, et il demande un certificat TLS gratuit à Let’s Encrypt pour ce nom d’hôte exact, en le renouvelant automatiquement avant son expiration. Ça ne fonctionne que si le domaine résout déjà vers ce serveur, d’où l’ordre : le DNS avant Caddy dans ce guide.

Étape 8 : démarrer la pile

Depuis /opt/myapp :

docker compose up -d --build

Grâce au comportement du script d’entrée vu à l’étape 4, cette seule commande construit l’image, démarre Postgres, attend qu’il se signale en santé, puis démarre l’application, qui crée la base de données et applique les migrations toute seule avant que Puma (derrière Thruster) démarre vraiment.

Vérifiez la pile :

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

Vous devriez voir les trois services à l’état Up (et db à l’état healthy), et le curl devrait retourner HTTP/2 200 sans avertissement de certificat.

Étape 9 : sauvegarder Postgres

Un dump pris en dehors du volume de données est ce qui vous protège réellement si le serveur disparaît. Ajoutez un script de sauvegarde :

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

Planifiez-le avec cron, chaque jour à 3 h :

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

Lancez-le une fois à la main et vérifiez que ça a fonctionné :

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

Vous devriez voir un fichier .sql.gz avec l’horodatage du jour et une taille non nulle. Pour tout ce qui dépasse un projet personnel, copiez aussi ces fichiers en dehors du serveur, vers une autre machine ou un espace de stockage objet, pour que perdre le serveur ne veuille pas dire perdre aussi la sauvegarde.

Étape 10 : livrer une mise à jour

Chaque changement suivant suit la même forme : amener le nouveau code sur le serveur, reconstruire l’image, remplacer le conteneur en cours d’exécution.

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

Le script d’entrée relance db:prepare sur ce nouveau conteneur avant que le serveur démarre, donc toute migration livrée avec cette version est appliquée automatiquement. docker compose up -d app ne recrée que le conteneur app, pas db ni caddy, donc Postgres continue de tourner et l’état du certificat de Caddy n’est pas touché. Il y a un court écart, une seconde ou deux, pendant que l’ancien conteneur s’arrête et que le nouveau démarre, puisque cette configuration manuelle fait tourner un seul conteneur d’application. Vérifiez que c’est passé :

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

Vous devriez voir votre application démarrer proprement dans les logs et le curl toujours retourner 200 sur la nouvelle version.

Le raccourci : faites-le avec Railyard

Tout ce qui précède, le serveur, Docker, Postgres, Caddy, les sauvegardes, une mise en production répétable, c’est aussi ce que Railyard automatise pour une application Rails sur Hetzner, sans que vous écriviez aucun des fichiers ci-dessus à la main.

Collez l’URL de votre dépôt dans https://app.railyard.run/deploy?repo= et choisissez Hetzner comme fournisseur. La première fois, vous générerez un jeton dans la Hetzner Cloud Console (ouvrez votre projet, puis Security, puis API tokens, et générez-en un avec la permission Read & Write) pour que Railyard puisse provisionner le serveur en votre nom. Pour une application Rails sur Hetzner, le choix par défaut de Railyard est un serveur à 2 vCPU et 4 Go, la même taille utilisée manuellement plus haut, et vous pouvez choisir n’importe quelle région Hetzner, y compris Nuremberg, Falkenstein ou Helsinki si la résidence des données dans l’Union européenne compte pour vous.

À partir de là, Railyard lit votre application de la même façon que ce guide l’a fait à la main : il voit pg dans votre Gemfile.lock et provisionne une base de données Postgres gérée plutôt que SQLite, et il exécute bin/rails db:prepare avant chaque mise en production, de la même façon que le script d’entrée du Dockerfile l’a fait manuellement à l’étape 8. Si vous êtes sur Rails 8, il donne aux bases cache, queue et cable leur propre chaîne de connexion plutôt que de vous laisser y réfléchir comme à l’étape 4. Active Storage sur disque obtient un volume persistant automatiquement, comme le volume storage de l’étape 6, et SECRET_KEY_BASE est généré une seule fois puis conservé, donc vous n’avez jamais à le gérer vous-même. Si votre Gemfile.lock inclut Sidekiq, Solid Queue, GoodJob, Resque ou Delayed Job, Railyard le propose comme processus worker à côté de votre processus web, sans service compose séparé à écrire. Le HTTPS et le proxy inverse de l’étape 7 sont gérés de la même façon, pointés vers votre domaine une fois que vous l’ajoutez.

Pour en savoir plus, voyez déployer depuis un dépôt, bases de données et sauvegardes, le bouton de déploiement en un clic, démarrage rapide, le guide de référence Rails sur Hetzner, et les comparaisons de coûts d’hébergement si vous voulez voir comment le coût du serveur seul ici se compare à une solution gérée. Le forfait gratuit de Railyard couvre un serveur plus un serveur de base de données et trois applications sans carte requise, Pro est à 19 $ par mois par équipe, et Team est à 49 $ par mois par équipe.

Dépannage

Port déjà utilisé. Si docker compose up échoue parce que quelque chose est déjà lié au port 80 ou 443, Apache ou Nginx par défaut d’Ubuntu est peut-être déjà installé et en cours d’exécution. Vérifiez avec ss -tlnp | grep -E ':80|:443' et arrêtez ou retirez le service en question avant que Caddy essaie de s’y attacher.

Mauvais RAILS_MASTER_KEY ou credentials. Une erreur comme ActiveSupport::MessageEncryptor::InvalidMessage au démarrage signifie presque toujours que le RAILS_MASTER_KEY dans /opt/myapp/.env ne correspond pas à la clé qui a chiffré config/credentials.yml.enc. Relancez cat config/master.key localement et comparez-le caractère par caractère avec ce qui se trouve dans .env sur le serveur.

Connexion à Postgres refusée. Si les logs de l’application montrent could not connect to server: Connection refused, vérifiez que DATABASE_URL utilise db comme nom d’hôte, pas localhost ni 127.0.0.1. À l’intérieur du réseau de Compose, db est la seule adresse qui résout vers le conteneur Postgres ; localhost désigne le conteneur app lui-même, qui n’a aucune base de données en cours d’exécution.

Le HTTPS ne s’émet pas. Si docker compose logs caddy montre des erreurs de certificat répétées ou NXDOMAIN, votre enregistrement DNS de l’étape 2 ne s’est pas encore propagé ou pointe vers la mauvaise IP. Relancez dig +short myapp.example.com, confirmez que ça correspond au serveur, puis redémarrez Caddy avec docker compose restart caddy une fois que c’est le cas.

Manque de mémoire pendant la précompilation des assets. Les 4 Go de mémoire d’un CX23 suffisent habituellement, mais docker compose build peut quand même se faire tuer en pleine compilation des assets sur une grosse application, surtout si elle construit et tourne en même temps. Ajoutez un fichier swap de 2 Go avant de construire : fallocate -l 2G /swapfile && chmod 600 /swapfile && mkswap /swapfile && swapon /swapfile, puis rendez-le permanent en l’ajoutant à /etc/fstab.

FAQ

Un CX23 suffit-il en production ? Pour une application Rails de petite à moyenne taille avec un trafic modeste, oui, puisque Puma, Postgres et Caddy tiennent confortablement ensemble dans 4 Go, et le correctif de swap ci-dessus couvre le seul cas, les gros builds d’assets, où la mémoire devient serrée.

Puis-je me passer de Postgres et garder SQLite ? Vous le pouvez ; SQLite fonctionne bien sur un seul serveur tant que vous montez son fichier de base de données sur un volume persistant pour qu’il survive aux redémarrages de conteneur, mais Postgres donne un chemin plus propre si vous ajoutez un jour un second serveur d’application.

Comment ajouter Redis ou Sidekiq ? Ajoutez un service redis à compose.yml avec l’image officielle redis et son propre volume nommé, ajoutez REDIS_URL dans l’environnement de app pointant vers redis://redis:6379/0, et ajoutez un service worker qui exécute bundle exec sidekiq contre la même image au lieu de la commande du serveur Rails.

Est-ce que ça survit à un redémarrage du serveur ? Oui, restart: unless-stopped sur chaque service fait en sorte que Docker remonte toute la pile automatiquement après le redémarrage du serveur, tant que le service systemd de Docker lui-même est activé, ce que le script d’installation de l’étape 3 fait par défaut.

Que sont Solid Cache, Solid Queue et Solid Cable ? Ce sont les systèmes par défaut de Rails 8 pour le cache, les tâches en arrière-plan et Action Cable, tous appuyés sur une base de données plutôt que sur un service séparé comme Redis. C’est pourquoi une application Rails 8 fraîche a besoin de quatre noms de bases de données plutôt qu’un seul, et pourquoi le conteneur Postgres unique et le mot de passe unique de ce guide couvrent tout ça.

Voilà une configuration de production complète : un serveur, Docker Compose qui fait tourner votre application et Postgres, Caddy qui gère le HTTPS, une sauvegarde nocturne, et une mise en production en deux commandes. Gardez ce compose.yml et ce Caddyfile dans votre dépôt aux côtés de l’application, et chaque futur déploiement, c’est le même git pull, build, redémarrage que vous venez de faire à l’étape 10.

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.

Sautez la configuration du serveur

Collez l'URL de votre dépôt dans Railyard et obtenez Postgres, HTTPS et les sauvegardes configurés automatiquement sur Hetzner.

Offre gratuite, sans carte, et 14 jours de Pro pour chaque nouvelle équipe. Prix fixe par équipe.