Deploy Phoenix to Hetzner
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 --dockeror similar, which creates therel/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=trueis missing from the environment; a compiled release defaults to not starting the endpoint unless told to, which is correct behavior for running a one-offevaltask but wrong for the main process. (RuntimeError) environment variable SECRET_KEY_BASE is missing:config/runtime.exsreads it at boot and the release crashes immediately without it; confirm it’s actually set in.envand that.envwas present whendocker compose upran.- Migrations never run, or the app starts against an empty schema: the
Releasemodule and itsmigrate/0function need to exist in your app’s source (mix phx.gen.releasecreates this); without it, Step 6’sevalcommand has nothing to call. - HTTPS never issues, Caddy keeps retrying: DNS isn’t pointing at the server yet.
dig +short app.example.comshould match the server’s IP. - Build fails on
mix assets.deployspecifically: this step needs Node-based asset tooling (esbuild, tailwind) that Phoenix installs as standalone binaries rather than throughnpm; 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.