← Blog

Deploy Phoenix to Hetzner

Railyard

August 2, 2026 · 6 min read

Follow this on your own server. Start free, no card needed

mix phx.server needs a full Elixir toolchain and your source tree on the machine running it, which is fine in development and the wrong shape for a production server. Phoenix’s own answer is mix release: a compiled, self-contained build with the Erlang VM and your compiled code bundled together, no mix or source checkout needed at runtime. This guide builds that release in a multi-stage Dockerfile, runs it on a Hetzner Cloud server with Postgres and Caddy, and ends with a Phoenix app at your own domain you know how to redeploy.

What you’ll need

  • A Hetzner Cloud server. A CX23 (2 vCPU, 4 GB memory) at €5.99/month excluding VAT runs a single Phoenix app with its Postgres database comfortably; move up to a CX33 (4 vCPU, 8 GB, €8.99/month) if the app also runs real background job processing through Oban or similar.
  • A domain, with DNS already pointed at the server before requesting HTTPS.
  • A Phoenix app in a Git repository, generated with mix phx.gen.release --docker or similar, which creates the rel/ directory this guide’s Dockerfile expects. This guide uses Phoenix 1.8.16, Elixir 1.18.5 and Erlang/OTP 27.3.4.19.
  • Docker and the Compose plugin, either locally to test the build first or directly on the server.

Step 1: Create the server

In the Hetzner Cloud Console, create a project if needed, then Add Server: pick Ubuntu 24.04, the CX23 shared vCPU type, a location (Nuremberg, Falkenstein and Helsinki all stay in the EU), add your SSH key, and create it.

ssh root@your-server-ip

Step 2: Install Docker

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

Step 3: Write the Dockerfile

This mirrors the multi-stage Dockerfile Phoenix’s own mix phx.gen.release --docker generates, pinned to specific Elixir, OTP and Debian versions so the build is reproducible:

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

Replace myapp in the final COPY with your actual OTP application name (the atom in mix.exs’s def application, not the human-readable project name). Build it once to confirm it compiles:

docker build -t my-phoenix-app .

Step 4: Write the Compose file

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:

Your .env file:

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

Generate a real SECRET_KEY_BASE locally before deploying, since the release has no mix available at runtime to generate one for you:

mix phx.gen.secret

PHX_SERVER=true matters specifically for a release: without it, the compiled release starts without its web endpoint listening, which is normally fine for a one-off mix task but wrong for a running web server. Validate the compose file:

docker compose config

Step 5: Write the Caddyfile

app.example.com {
    reverse_proxy app:4000
}

Step 6: First boot and migrations

Releases don’t have mix ecto.migrate available, since there’s no mix at runtime; the release itself exposes a migration task instead:

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

This assumes your rel/ directory includes the Release module Phoenix’s generator creates for exactly this purpose (lib/my_app/release.ex, with a migrate/0 function calling Ecto.Migrator). You should see app, db and caddy all running, and https://app.example.com should respond with a valid certificate.

Step 7: Deploy new 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"

Rebuilding only app leaves db and caddy untouched. Variables read through config/runtime.exs (the file this Dockerfile copies in just before mix release, by design, so they’re read at boot rather than baked into the compiled release) pick up whatever’s in .env on each restart, without a rebuild.

Backups

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

Copy the file off the server on a cron job, and test a restore periodically.

The shortcut: do it with Railyard

Deploy Phoenix to Hetzner runs the same build and release process from a repository URL, without writing the multi-stage Dockerfile yourself. Connect a server with a token from the Hetzner Cloud Console, or bring one already provisioned. Railyard detects ecto_sql or postgrex in mix.exs as Postgres, redix as Redis, and ecto_ch as ClickHouse, with DATABASE_URL and the others injected automatically. Migrations run through the compiled release the same way as Step 6 above, with no mix needed at runtime since Railyard builds the same kind of release this guide does by hand. SECRET_KEY_BASE is generated once rather than left for you to invent, MIX_ENV and PHX_SERVER are set, and mix phx.server becomes the release’s start command. See the Phoenix docs for the exact detection rules.

Troubleshooting

  • The container exits immediately with no web server listening: PHX_SERVER=true is missing from the environment; a compiled release defaults to not starting the endpoint unless told to, which is correct behavior for running a one-off eval task but wrong for the main process.
  • (RuntimeError) environment variable SECRET_KEY_BASE is missing: config/runtime.exs reads it at boot and the release crashes immediately without it; confirm it’s actually set in .env and that .env was present when docker compose up ran.
  • Migrations never run, or the app starts against an empty schema: the Release module and its migrate/0 function need to exist in your app’s source (mix phx.gen.release creates this); without it, Step 6’s eval command has nothing to call.
  • HTTPS never issues, Caddy keeps retrying: DNS isn’t pointing at the server yet. dig +short app.example.com should match the server’s IP.
  • Build fails on mix assets.deploy specifically: this step needs Node-based asset tooling (esbuild, tailwind) that Phoenix installs as standalone binaries rather than through npm; confirm the builder stage has network access during the build, since those binaries download on first use unless already cached in the image.

FAQ

Why pin hexpm/elixir instead of the plain elixir image? The hexpm/elixir images bundle a specific Elixir, Erlang/OTP and Debian combination as one tag, which is what Phoenix’s own release generator uses, since a release is sensitive to exactly which OTP version built it.

Can I run migrations automatically on every boot instead of a separate command? Some teams call the Release.migrate/0 function from the release’s own start script instead of running it manually; that’s a reasonable choice once you trust migrations to always be safe to run concurrently with a rolling deploy, which isn’t universally true for every kind of migration.

Postgres, MySQL or something else? ecto_sql supports several adapters; this guide uses Postgres since it’s the most common pairing with Phoenix, but the Ecto adapter determines what actually changes, not Phoenix itself.

What if my app name isn’t myapp? Replace every occurrence of myapp in the Dockerfile and Step 6’s command with your actual OTP app name, found in mix.exs as the atom passed to def application, not the capitalized module name.

Is a CX23 really enough for a Phoenix app? The BEAM (Erlang’s VM) is efficient with concurrent connections even on modest hardware; 4 GB is comfortable for a single app and its Postgres database, with Oban-style background job processing being the more likely reason to size up.

A Phoenix app on a Hetzner server you provisioned yourself costs €5.99 a month, built as the same kind of release Phoenix’s own tooling recommends, in a Dockerfile you can read top to bottom. Shipping a new release after it exists is a rebuild of one container and a migration command run against the compiled release itself.

Why people pick Railyard for this

  • Your own cloud account. Deploy to DigitalOcean, Hetzner, Vultr, AWS, OVHcloud, or any Linux server over SSH, billed directly by your provider with no markup.
  • One flat price per team. Free is free forever: one server plus a database server, three apps, no card. Pro is $19 a month for up to five servers and unlimited apps. Team is $49 a month with unlimited servers. No per-app, per-deploy, per-seat or per-gigabyte charges.
  • One-click templates. Deploy open-source apps, like databases, analytics and dev tools, to your own server in one click.
  • Managed databases. Postgres, MySQL and Redis with scheduled, restore-verified backups and point-in-time recovery.
  • Push-to-deploy. Deploy from GitHub, GitLab or the CLI, with automatic HTTPS and zero-downtime releases.

Skip the server setup.

Push a Phoenix repository to Railyard and it builds the release, provisions Postgres, and deploys on every push.

Free plan, no card, and 14 days of Pro for every new team. One flat price per team.