← Blog

Phoenix auf Hetzner deployen

Railyard

2. August 2026 · 6 Min. Lesezeit

Folge dem auf deinem eigenen Server. Kostenlos starten, keine Karte nötig

mix phx.server braucht eine vollständige Elixir-Toolchain und deinen Source-Tree auf der Maschine, die es ausführt, was in der Entwicklung in Ordnung und für einen Produktionsserver die falsche Form ist. Phoenix’ eigene Antwort ist mix release: ein kompilierter, eigenständiger Build mit der Erlang-VM und deinem kompilierten Code zusammen gebündelt, kein mix oder Source-Checkout zur Laufzeit nötig. Diese Anleitung baut dieses Release in einem Multi-Stage-Dockerfile, betreibt es auf einem Hetzner-Cloud-Server mit Postgres und Caddy, und endet mit einer Phoenix-App auf deiner eigenen Domain, die du zu redeployen weißt.

Was du brauchst

  • Einen Hetzner-Cloud-Server. Ein CX23 (2 vCPU, 4 GB Speicher) für 5,99 €/Monat exkl. MwSt. betreibt eine einzelne Phoenix-App mit ihrer Postgres-Datenbank problemlos; steige auf einen CX33 (4 vCPU, 8 GB, 8,99 €/Monat) um, falls die App zusätzlich echte Hintergrund-Job-Verarbeitung über Oban oder Ähnliches betreibt.
  • Eine Domain, mit DNS, das schon vor dem Beantragen von HTTPS auf den Server zeigt.
  • Eine Phoenix-App in einem Git-Repository, erzeugt mit mix phx.gen.release --docker oder Ähnlichem, was das rel/-Verzeichnis erzeugt, das das Dockerfile dieser Anleitung erwartet. Diese Anleitung nutzt Phoenix 1.8.16, Elixir 1.18.5 und Erlang/OTP 27.3.4.19.
  • Docker und das Compose-Plugin, entweder lokal, um den Build zuerst zu testen, oder direkt auf dem Server.

Schritt 1: Den Server erstellen

Erstelle in der Hetzner-Cloud-Konsole bei Bedarf ein Projekt, dann Add Server: wähle Ubuntu 24.04, den CX23-Shared-vCPU-Typ, einen Standort (Nürnberg, Falkenstein und Helsinki bleiben alle in der EU), füge deinen SSH-Key hinzu und erstelle ihn.

ssh root@your-server-ip

Schritt 2: Docker installieren

curl -fsSL https://get.docker.com | sh
docker --version
docker compose version

Schritt 3: Das Dockerfile schreiben

Das spiegelt das Multi-Stage-Dockerfile, das Phoenix’ eigenes mix phx.gen.release --docker generiert, festgelegt auf spezifische Elixir-, OTP- und Debian-Versionen, damit der Build reproduzierbar ist:

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"]

Ersetze myapp im letzten COPY durch deinen tatsächlichen OTP-Anwendungsnamen (das Atom in mix.exs’ def application, nicht den menschenlesbaren Projektnamen). Baue es einmal, um zu bestätigen, dass es compiliert:

docker build -t my-phoenix-app .

Schritt 4: Die Compose-Datei schreiben

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:

Deine .env-Datei:

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

Generiere lokal einen echten SECRET_KEY_BASE, bevor du deployst, da das Release zur Laufzeit kein mix zur Verfügung hat, um einen für dich zu erzeugen:

mix phx.gen.secret

PHX_SERVER=true ist speziell für ein Release wichtig: Ohne es startet das kompilierte Release, ohne dass sein Web-Endpoint lauscht, was normalerweise für eine einmalige mix-Aufgabe in Ordnung, aber für einen laufenden Webserver falsch ist. Validiere die Compose-Datei:

docker compose config

Schritt 5: Das Caddyfile schreiben

app.example.com {
    reverse_proxy app:4000
}

Schritt 6: Erster Boot und Migrationen

Releases haben mix ecto.migrate nicht verfügbar, da zur Laufzeit kein mix existiert; das Release selbst stellt statt dessen eine Migrations-Aufgabe bereit:

docker compose up -d
docker compose exec app /app/bin/myapp eval "MyApp.Release.migrate"
docker compose ps

Das setzt voraus, dass dein rel/-Verzeichnis das Release-Modul enthält, das Phoenix’ Generator genau zu diesem Zweck erzeugt (lib/my_app/release.ex, mit einer migrate/0-Funktion, die Ecto.Migrator aufruft). Du solltest app, db und caddy alle als running sehen, und https://app.example.com sollte mit einem gültigen Zertifikat antworten.

Schritt 7: Neue Versionen deployen

git pull
docker compose build app
docker compose up -d --no-deps app
docker compose exec app /app/bin/myapp eval "MyApp.Release.migrate"

Nur app neu zu bauen lässt db und caddy unberührt. Variablen, die über config/runtime.exs gelesen werden (die Datei, die dieses Dockerfile absichtlich erst kurz vor mix release kopiert, damit sie beim Boot gelesen werden statt ins kompilierte Release gebacken zu sein), übernehmen bei jedem Neustart, was in .env steht, ohne Rebuild.

Backups

docker compose exec -T db pg_dump -U app app_prod | gzip > backup-$(date +%F).sql.gz

Kopiere die Datei per Cron-Job vom Server weg, und teste regelmäßig eine Wiederherstellung.

Die Abkürzung: mit Railyard

Phoenix auf Hetzner deployen führt denselben Build- und Release-Prozess von einer Repository-URL aus, ohne dass du das Multi-Stage-Dockerfile selbst schreibst. Verbinde einen Server mit einem Token aus der Hetzner-Cloud-Konsole, oder bringe einen bereits provisionierten mit. Railyard erkennt ecto_sql oder postgrex in mix.exs als Postgres, redix als Redis, und ecto_ch als ClickHouse, mit automatisch injiziertem DATABASE_URL und den anderen. Migrationen laufen über das kompilierte Release genauso wie in Schritt 6 oben, ohne dass zur Laufzeit mix nötig ist, da Railyard dieselbe Art Release baut, die diese Anleitung von Hand baut. SECRET_KEY_BASE wird einmalig generiert, statt dass du ihn selbst erfinden musst, MIX_ENV und PHX_SERVER werden gesetzt, und mix phx.server wird zum Start-Befehl des Releases. Siehe die Phoenix-Docs für die exakten Erkennungsregeln.

Troubleshooting

  • Der Container beendet sich sofort, ohne dass ein Webserver lauscht: PHX_SERVER=true fehlt in der Umgebung; ein kompiliertes Release startet den Endpoint standardmäßig nicht, wenn es nicht dazu angewiesen wird, was für das Ausführen einer einmaligen eval-Aufgabe korrekt, für den Hauptprozess aber falsch ist.
  • (RuntimeError) environment variable SECRET_KEY_BASE is missing: config/runtime.exs liest sie beim Boot und das Release stürzt sofort ohne sie ab; bestätige, dass sie tatsächlich in .env gesetzt ist und dass .env vorhanden war, als docker compose up lief.
  • Migrationen laufen nie, oder die App startet gegen ein leeres Schema: Das Release-Modul und seine migrate/0-Funktion müssen im Source deiner App existieren (mix phx.gen.release erzeugt das); ohne es hat Schritt 6s eval-Befehl nichts aufzurufen.
  • HTTPS wird nie ausgestellt, Caddy versucht es immer wieder: DNS zeigt noch nicht auf den Server. dig +short app.example.com sollte mit der IP des Servers übereinstimmen.
  • Der Build schlägt speziell bei mix assets.deploy fehl: Dieser Schritt braucht node-basiertes Asset-Tooling (esbuild, tailwind), das Phoenix als eigenständige Binaries installiert statt über npm; bestätige, dass die Builder-Stage während des Builds Netzwerkzugriff hat, da diese Binaries bei der ersten Nutzung herunterladen, sofern nicht schon im Image zwischengespeichert.

FAQ

Warum hexpm/elixir statt des einfachen elixir-Images festlegen? Die hexpm/elixir-Images bündeln eine spezifische Kombination aus Elixir, Erlang/OTP und Debian als einen Tag, genau das, was Phoenix’ eigener Release-Generator nutzt, da ein Release empfindlich auf genau die OTP-Version reagiert, mit der es gebaut wurde.

Kann ich Migrationen automatisch bei jedem Boot laufen lassen statt mit einem separaten Befehl? Manche Teams rufen die Release.migrate/0-Funktion aus dem eigenen Start-Skript des Releases auf statt sie manuell auszuführen; das ist eine vernünftige Wahl, sobald man darauf vertraut, dass Migrationen immer sicher gleichzeitig mit einem rollenden Deploy laufen können, was nicht universell für jede Art von Migration gilt.

Postgres, MySQL oder etwas anderes? ecto_sql unterstützt mehrere Adapter; diese Anleitung nutzt Postgres, da es die häufigste Paarung mit Phoenix ist, aber der Ecto-Adapter bestimmt, was sich tatsächlich ändert, nicht Phoenix selbst.

Was, wenn mein App-Name nicht myapp ist? Ersetze jedes Vorkommen von myapp im Dockerfile und Schritt 6s Befehl durch deinen tatsächlichen OTP-App-Namen, zu finden in mix.exs als das Atom, das an def application übergeben wird, nicht der großgeschriebene Modulname.

Reicht ein CX23 wirklich für eine Phoenix-App? Die BEAM (Erlangs VM) ist auch auf bescheidener Hardware effizient mit parallelen Verbindungen; 4 GB sind komfortabel für eine einzelne App und ihre Postgres-Datenbank, wobei Oban-artige Hintergrund-Job-Verarbeitung der wahrscheinlichere Grund zum Hochskalieren ist.

Eine Phoenix-App auf einem selbst provisionierten Hetzner-Server kostet 5,99 € im Monat, gebaut als dieselbe Art Release, die Phoenix’ eigenes Tooling empfiehlt, in einem Dockerfile, das du von oben bis unten lesen kannst. Einen neuen Release auszuliefern, nachdem er existiert, ist ein Neubau eines Containers und ein Migrationsbefehl, der gegen das kompilierte Release selbst läuft.

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.

Spar dir das Server-Setup.

Push ein Phoenix-Repository zu Railyard, und es baut das Release, provisioniert Postgres und deployt bei jedem Push.

Kostenloser Plan, keine Kreditkarte, und 14 Tage Pro für jedes neue Team. Ein fester Preis pro Team.