Phoenix auf Hetzner deployen
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 --dockeroder Ähnlichem, was dasrel/-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=truefehlt 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 einmaligeneval-Aufgabe korrekt, für den Hauptprozess aber falsch ist. (RuntimeError) environment variable SECRET_KEY_BASE is missing:config/runtime.exsliest sie beim Boot und das Release stürzt sofort ohne sie ab; bestätige, dass sie tatsächlich in.envgesetzt ist und dass.envvorhanden war, alsdocker compose uplief.- Migrationen laufen nie, oder die App startet gegen ein leeres Schema: Das
Release-Modul und seinemigrate/0-Funktion müssen im Source deiner App existieren (mix phx.gen.releaseerzeugt das); ohne es hat Schritt 6seval-Befehl nichts aufzurufen. - HTTPS wird nie ausgestellt, Caddy versucht es immer wieder: DNS zeigt noch nicht auf den Server.
dig +short app.example.comsollte mit der IP des Servers übereinstimmen. - Der Build schlägt speziell bei
mix assets.deployfehl: Dieser Schritt braucht node-basiertes Asset-Tooling (esbuild, tailwind), das Phoenix als eigenständige Binaries installiert statt übernpm; 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.