← Blogue

Déployer Phoenix sur Hetzner

Railyard

2 août 2026 · 7 min de lecture

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 --docker ou équivalent, qui crée le répertoire rel/ 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=true manque 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âche eval ponctuelle mais faux pour le processus principal.
  • (RuntimeError) environment variable SECRET_KEY_BASE is missing : config/runtime.exs le lit au démarrage et la release plante immédiatement sans lui ; confirmez qu’il est réellement défini dans .env et que .env était présent quand docker compose up a tourné.
  • Les migrations ne s’exécutent jamais, ou l’application démarre contre un schéma vide : le module Release et sa fonction migrate/0 doivent exister dans le code source de votre application (mix phx.gen.release crée ça) ; sans lui, la commande eval de 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.com devrait 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 via npm ; 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.

Sautez la configuration du serveur.

Poussez un dépôt Phoenix sur Railyard et il construit la release, provisionne Postgres, et déploie à chaque push.

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