Déployer Phoenix sur Hetzner
Suivez ça sur votre propre serveur. Commencer gratuitement, sans carte
mix phx.server a besoin d’une chaîne d’outils Elixir complète et de votre arborescence source sur la machine qui l’exécute, ce qui convient en développement mais pas du tout pour un serveur de production. La réponse propre de Phoenix est mix release : un build compilé et autonome avec la VM Erlang et votre code compilé regroupés ensemble, sans mix ni checkout source nécessaire à l’exécution. Ce guide construit cette release dans un Dockerfile multi-étapes, la fait tourner sur un serveur Hetzner Cloud avec Postgres et Caddy, et termine avec une application Phoenix sur votre propre domaine que vous saurez redéployer.
Ce qu’il vous faut
- Un serveur Hetzner Cloud. Un CX23 (2 vCPU, 4 Go de mémoire) à 5,99 € par mois avant taxes fait tourner confortablement une seule application Phoenix avec sa base de données Postgres ; passez à un CX33 (4 vCPU, 8 Go, 8,99 € par mois) si l’application fait aussi du vrai traitement de tâches de fond via Oban ou similaire.
- Un domaine, avec le DNS déjà pointé vers le serveur avant de demander le HTTPS.
- Une application Phoenix dans un dépôt Git, générée avec
mix phx.gen.release --dockerou équivalent, qui crée le répertoirerel/que le Dockerfile de ce guide attend. Ce guide utilise Phoenix 1.8.16, Elixir 1.18.5 et Erlang/OTP 27.3.4.19. - Docker et le plugin Compose, soit localement pour tester le build d’abord, soit directement sur le serveur.
Étape 1 : créer le serveur
Dans la Hetzner Cloud Console, créez un projet si nécessaire, puis Add Server : choisissez Ubuntu 24.04, le type de vCPU partagée CX23, un emplacement (Nuremberg, Falkenstein et Helsinki restent tous dans l’UE), ajoutez votre clé SSH, et créez-le.
ssh root@your-server-ip
Étape 2 : installer Docker
curl -fsSL https://get.docker.com | sh
docker --version
docker compose version
Étape 3 : écrire le Dockerfile
Ceci reproduit le Dockerfile multi-étapes que mix phx.gen.release --docker de Phoenix génère lui-même, fixé à des versions spécifiques d’Elixir, d’OTP et de Debian pour que le build soit reproductible :
ARG ELIXIR_VERSION=1.18.5
ARG OTP_VERSION=27.3.4.19
ARG DEBIAN_VERSION=bookworm-20261020
ARG BUILDER_IMAGE="hexpm/elixir:${ELIXIR_VERSION}-erlang-${OTP_VERSION}-debian-${DEBIAN_VERSION}-slim"
ARG RUNNER_IMAGE="debian:${DEBIAN_VERSION}-slim"
FROM ${BUILDER_IMAGE} AS builder
RUN apt-get update -y && apt-get install -y build-essential git \
&& apt-get clean && rm -f /var/lib/apt/lists/*_*
ENV MIX_ENV="prod"
WORKDIR /app
RUN mix local.hex --force && mix local.rebar --force
COPY mix.exs mix.lock ./
RUN mix deps.get --only $MIX_ENV
RUN mkdir config
COPY config/config.exs config/${MIX_ENV}.exs config/
RUN mix deps.compile
COPY priv priv
COPY lib lib
COPY assets assets
RUN mix assets.deploy
RUN mix compile
COPY config/runtime.exs config/
COPY rel rel
RUN mix release
FROM ${RUNNER_IMAGE}
RUN apt-get update -y \
&& apt-get install -y libstdc++6 openssl libncurses6 locales ca-certificates \
&& apt-get clean && rm -f /var/lib/apt/lists/*_*
ENV LANG=C.UTF-8
WORKDIR /app
RUN chown nobody /app
ENV MIX_ENV="prod"
COPY --from=builder --chown=nobody:root /app/_build/${MIX_ENV}/rel/myapp ./
USER nobody
CMD ["/app/bin/server"]
Remplacez myapp dans le COPY final par le vrai nom d’application OTP (l’atome dans def application de mix.exs, pas le nom de projet lisible par un humain). Construisez-le une fois pour confirmer qu’il compile :
docker build -t my-phoenix-app .
Étape 4 : écrire le fichier Compose
services:
app:
build: .
restart: unless-stopped
env_file:
- .env
depends_on:
db:
condition: service_healthy
expose:
- "4000"
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
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:
Votre fichier .env :
POSTGRES_DB=app_prod
POSTGRES_USER=app
POSTGRES_PASSWORD=change-this
DATABASE_URL=ecto://app:change-this@db/app_prod
SECRET_KEY_BASE=generate-this-with-mix-phx-gen-secret
PHX_HOST=app.example.com
PORT=4000
MIX_ENV=prod
PHX_SERVER=true
Générez un vrai SECRET_KEY_BASE localement avant de déployer, puisque la release n’a pas de mix disponible à l’exécution pour en générer un pour vous :
mix phx.gen.secret
PHX_SERVER=true compte particulièrement pour une release : sans lui, la release compilée démarre sans que son endpoint web écoute, ce qui est normalement correct pour une tâche mix ponctuelle mais faux pour un serveur web en marche. Validez le fichier compose :
docker compose config
Étape 5 : écrire le Caddyfile
app.example.com {
reverse_proxy app:4000
}
Étape 6 : premier démarrage et migrations
Les releases n’ont pas mix ecto.migrate de disponible, puisqu’il n’y a pas de mix à l’exécution ; la release elle-même expose plutôt une tâche de migration :
docker compose up -d
docker compose exec app /app/bin/myapp eval "MyApp.Release.migrate"
docker compose ps
Ceci suppose que votre répertoire rel/ inclut le module Release que le générateur de Phoenix crée exactement pour ça (lib/my_app/release.ex, avec une fonction migrate/0 appelant Ecto.Migrator). Vous devriez voir app, db et caddy tous running, et https://app.example.com devrait répondre avec un certificat valide.
Étape 7 : déployer de nouvelles versions
git pull
docker compose build app
docker compose up -d --no-deps app
docker compose exec app /app/bin/myapp eval "MyApp.Release.migrate"
Reconstruire seulement app laisse db et caddy intacts. Les variables lues via config/runtime.exs (le fichier que ce Dockerfile copie juste avant mix release, par conception, pour qu’elles soient lues au démarrage plutôt qu’intégrées dans la release compilée) prennent ce qui se trouve dans .env à chaque redémarrage, sans reconstruction.
Sauvegardes
docker compose exec -T db pg_dump -U app app_prod | gzip > backup-$(date +%F).sql.gz
Copiez le fichier hors du serveur via une tâche cron, et testez une restauration périodiquement.
Le raccourci : faites-le avec Railyard
Déployer Phoenix sur Hetzner exécute le même processus de build et de release à partir d’une URL de dépôt, sans écrire le Dockerfile multi-étapes vous-même. Connectez un serveur avec un jeton de la Hetzner Cloud Console, ou amenez-en un déjà provisionné. Railyard détecte ecto_sql ou postgrex dans mix.exs comme Postgres, redix comme Redis, et ecto_ch comme ClickHouse, avec DATABASE_URL et les autres injectées automatiquement. Les migrations passent par la release compilée de la même façon qu’à l’étape 6 ci-dessus, sans besoin de mix à l’exécution puisque Railyard construit le même genre de release que ce guide fait à la main. SECRET_KEY_BASE est généré une fois plutôt que laissé à inventer, MIX_ENV et PHX_SERVER sont définis, et mix phx.server devient la commande de démarrage de la release. Voyez la documentation Phoenix pour les règles exactes de détection.
Dépannage
- Le conteneur s’arrête immédiatement sans serveur web qui écoute :
PHX_SERVER=truemanque dans l’environnement ; une release compilée par défaut ne démarre pas l’endpoint sans qu’on le lui dise, ce qui est le comportement correct pour exécuter une tâcheevalponctuelle mais faux pour le processus principal. (RuntimeError) environment variable SECRET_KEY_BASE is missing:config/runtime.exsle lit au démarrage et la release plante immédiatement sans lui ; confirmez qu’il est réellement défini dans.envet que.envétait présent quanddocker compose upa tourné.- Les migrations ne s’exécutent jamais, ou l’application démarre contre un schéma vide : le module
Releaseet sa fonctionmigrate/0doivent exister dans le code source de votre application (mix phx.gen.releasecrée ça) ; sans lui, la commandeevalde l’étape 6 n’a rien à appeler. - Le HTTPS ne s’émet jamais, Caddy continue de réessayer : le DNS ne pointe pas encore vers le serveur.
dig +short app.example.comdevrait correspondre à l’IP du serveur. - Le build échoue spécifiquement sur
mix assets.deploy: cette étape a besoin d’outils de ressources basés sur Node (esbuild, tailwind) que Phoenix installe comme binaires autonomes plutôt que vianpm; confirmez que l’étape builder a un accès réseau pendant le build, puisque ces binaires se téléchargent à la première utilisation sauf s’ils sont déjà en cache dans l’image.
FAQ
Pourquoi fixer hexpm/elixir plutôt que l’image elixir simple ? Les images hexpm/elixir regroupent une combinaison spécifique d’Elixir, d’Erlang/OTP et de Debian en une seule étiquette, ce que le générateur de release de Phoenix utilise lui-même, puisqu’une release est sensible à la version exacte d’OTP qui l’a construite.
Puis-je exécuter les migrations automatiquement à chaque démarrage plutôt qu’une commande séparée ? Certaines équipes appellent la fonction Release.migrate/0 depuis le propre script de démarrage de la release plutôt que de l’exécuter manuellement ; c’est un choix raisonnable une fois que vous faites confiance aux migrations pour toujours être sûres à exécuter en même temps qu’un déploiement progressif, ce qui n’est pas universellement vrai pour tous les types de migration.
Postgres, MySQL ou autre chose ? ecto_sql supporte plusieurs adaptateurs ; ce guide utilise Postgres puisque c’est le pairage le plus commun avec Phoenix, mais c’est l’adaptateur Ecto qui détermine ce qui change réellement, pas Phoenix lui-même.
Et si le nom de mon application n’est pas myapp ? Remplacez chaque occurrence de myapp dans le Dockerfile et la commande de l’étape 6 par votre vrai nom d’application OTP, trouvé dans mix.exs comme l’atome passé à def application, pas le nom de module en majuscule.
Un CX23 suffit-il vraiment pour une application Phoenix ? La BEAM (la VM d’Erlang) est efficace avec des connexions concurrentes même sur du matériel modeste ; 4 Go est confortable pour une seule application et sa base de données Postgres, un traitement de tâches de fond de style Oban étant la raison la plus probable de monter en taille.
Une application Phoenix sur un serveur Hetzner que vous avez provisionné vous-même coûte 5,99 € par mois, construite comme le même genre de release que l’outillage propre de Phoenix recommande, dans un Dockerfile que vous pouvez lire de haut en bas. Expédier une nouvelle version une fois qu’elle existe, c’est reconstruire un conteneur et exécuter une commande de migration contre la release compilée elle-même.
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.