A step-by-step guide to deploying a Phoenix app on a Hetzner VPS using
plain mix release, Caddy, PostgreSQL, systemd, and GitHub Actions.
No Docker in production. No PaaS. Just a Linux box you control.
Throughout this guide, replace myapp with your actual app name (the one in
your mix.exs — e.g., :charges, :blog, :shop).
Internet → Caddy (HTTPS, port 443) → Phoenix/Bandit (port 4000) → PostgreSQL (local)
- Hetzner CX23: 2 vCPU, 4 GB RAM, 40 GB NVMe, ~€2.99/mo
- Caddy: reverse proxy with automatic Let's Encrypt TLS
- PostgreSQL: on the same box, local connections only
- systemd: manages the Phoenix release as a service
- GitHub Actions: builds the release and deploys via SSH
We use x86_64 (CX series) because GitHub Actions runners are x86_64 — the release you build in CI runs on the server without any cross-compilation tricks.
- A Phoenix app with Ecto/PostgreSQL
- A GitHub repository for the app
- A domain name with access to DNS records
- An SSH key pair (for accessing the server)
In the Hetzner Cloud console:
- Create a new server
- Location: pick the closest region to you
- Image: Debian 13 (or Ubuntu 24.04 — both work)
- Type: Shared Resources → Cost-Optimized → CX23 (x86)
- Networking: Public IPv4 + Public IPv6 (defaults)
- SSH key: add your public key
- Skip volumes, placement groups, and cloud-init
- Name: something meaningful (e.g., your org name or
phoenix-server) - Create the server
Enable Backups in the server's Backups tab (20% of plan, ~€0.60/mo). Hetzner takes automatic daily snapshots and keeps the last 7.
Note the server's IP address — you'll need it for DNS and SSH.
SSH in as root:
ssh root@YOUR_SERVER_IPRun the initial setup:
# Update packages
apt update && apt upgrade -y
# Create a deploy user
adduser --disabled-password deploy
mkdir -p /home/deploy/.ssh
cp ~/.ssh/authorized_keys /home/deploy/.ssh/
chown -R deploy:deploy /home/deploy/.ssh
# Allow deploy user to restart the app service without a password
cat > /etc/sudoers.d/deploy-myapp <<'SUDOERS'
deploy ALL=(root) NOPASSWD: /bin/systemctl restart myapp, /bin/systemctl stop myapp, /bin/systemctl start myapp, /bin/systemctl status myapp
SUDOERS
# Firewall (not included by default on Debian 13, skip if using Ubuntu)
apt install -y ufw
ufw default deny incoming
ufw default allow outgoing
ufw allow OpenSSH
ufw allow 80/tcp
ufw allow 443/tcp
ufw enableOnly ports 22, 80, and 443 are exposed. Phoenix (4000) and PostgreSQL (5432) stay on localhost.
Install fail2ban to auto-ban IPs after repeated SSH failures:
apt install -y fail2ban
cat > /etc/fail2ban/jail.local <<'EOF'
[DEFAULT]
bantime = 1h
findtime = 10m
maxretry = 5
[sshd]
enabled = true
EOF
systemctl restart fail2banThis bans IPs for 1 hour after 5 failed SSH attempts within 10 minutes. Without it, the SSH logs fill up with brute-force attempts from bots — key-only auth already blocks them, but they generate noise and waste resources.
apt install -y postgresql postgresql-contrib
sudo -u postgres createuser --pwprompt myapp
# Enter a strong password — save it, you'll need it for DATABASE_URL
sudo -u postgres createdb -O myapp myapp_prodCreate /opt/backups/pg_backup.sh:
#!/bin/bash
BACKUP_DIR="/opt/backups/postgres"
DB_NAME="myapp_prod"
KEEP_DAYS=14
mkdir -p "$BACKUP_DIR"
sudo -u postgres pg_dump "$DB_NAME" | gzip > "$BACKUP_DIR/${DB_NAME}_$(date +%Y%m%d_%H%M%S).sql.gz"
find "$BACKUP_DIR" -type f -mtime +${KEEP_DAYS} -deletemkdir -p /opt/backups
chmod +x /opt/backups/pg_backup.sh
# Run daily at 03:00
echo "0 3 * * * root /opt/backups/pg_backup.sh" > /etc/cron.d/pg-backupTest it:
/opt/backups/pg_backup.sh && ls -la /opt/backups/postgres/Important: local-only backups die with the disk. See the offsite backups section below for syncing to external storage.
apt install -y debian-keyring debian-archive-keyring apt-transport-https curl
curl -1sLf 'https://dl.cloudsmith.io/public/caddy/stable/gpg.key' | gpg --dearmor -o /usr/share/keyrings/caddy-stable-archive-keyring.gpg
curl -1sLf 'https://dl.cloudsmith.io/public/caddy/stable/debian.deb.txt' | tee /etc/apt/sources.list.d/caddy-stable.list
apt update
apt install -y caddyBefore configuring Caddy, point an A record for your domain (e.g.,
myapp.example.com) to the server's IP address. Caddy needs this to obtain
a TLS certificate from Let's Encrypt.
Create /etc/caddy/Caddyfile (replace the default contents):
myapp.example.com {
reverse_proxy localhost:4000
}
systemctl enable caddy
systemctl restart caddyThat's it. Caddy handles TLS certificates, automatic renewal, HTTP-to-HTTPS redirect, and WebSocket proxying (LiveView works out of the box).
Hosting multiple apps? Add more blocks to the same Caddyfile:
myapp.example.com {
reverse_proxy localhost:4000
}
otherapp.example.com {
reverse_proxy localhost:4001
}
mkdir -p /opt/myapp
chown deploy:deploy /opt/myappCreate /opt/myapp/.env with your production secrets:
DATABASE_URL=ecto://myapp:YOUR_DB_PASSWORD@localhost/myapp_prod
SECRET_KEY_BASE=YOUR_SECRET_KEY_BASE
PHX_HOST=myapp.example.com
PHX_SERVER=true
PORT=4000Generate the secret key base on your local machine:
mix phx.gen.secretLock down the env file:
chown root:deploy /opt/myapp/.env
chmod 640 /opt/myapp/.envThe deploy user can read the file (needed for migrations during deploy) but cannot modify it. Only root can change secrets.
Create /etc/systemd/system/myapp.service:
[Unit]
Description=MyApp Phoenix App
After=network.target postgresql.service
[Service]
Type=simple
User=deploy
Group=deploy
WorkingDirectory=/opt/myapp
EnvironmentFile=/opt/myapp/.env
Environment=LANG=en_US.UTF-8
Environment=HOME=/opt/myapp
ExecStart=/opt/myapp/bin/server
Restart=on-failure
RestartSec=5
SyslogIdentifier=myapp
[Install]
WantedBy=multi-user.targetsystemctl daemon-reload
systemctl enable myappDon't start it yet — there's no release to run until the first deploy.
On your local machine, in your Phoenix project directory:
mix phx.gen.releaseThis creates:
lib/myapp/release.ex— migration module used by thebin/migratescriptrel/overlays/bin/server— starts the app withPHX_SERVER=truerel/overlays/bin/migrate— runs Ecto migrations in a release context
Commit these files.
Note for Phoenix 1.8+: if the generator fails trying to fetch Docker Hub
tags, run mix phx.gen.release without the --docker flag. You don't need
a Dockerfile for this setup.
Create rel/env.sh.eex so bin/myapp remote can attach to the running node:
#!/bin/sh
# Single-node deployment with sname so bin/myapp remote works
export RELEASE_DISTRIBUTION="sname"
export RELEASE_NODE="myapp"Without this, the remote IEx console can't connect to the running release.
If you're migrating from Fly.io, replace the entire generated file — the
Fly-specific settings (DNS_CLUSTER_QUERY, FLY_APP_NAME, IPv6 proto)
will crash the app on a VPS.
GitHub Actions needs an SSH key to connect to your server. Generate a dedicated key pair:
ssh-keygen -t ed25519 -C "github-actions-deploy" -f ~/.ssh/id_deploy_myapp -N ""Add the public key to the deploy user on the server:
ssh root@YOUR_SERVER_IP "cat >> /home/deploy/.ssh/authorized_keys" < ~/.ssh/id_deploy_myapp.pubAdd the secrets to your GitHub repository:
gh secret set VPS_HOST -R youruser/myapp --body "YOUR_SERVER_IP"
gh secret set VPS_SSH_KEY -R youruser/myapp < ~/.ssh/id_deploy_myappUsing 1Password? You can create the key there instead. When setting the GitHub secret, make sure to export the private key in OpenSSH format (1Password's "Copy" gives PKCS#8, which doesn't work with OpenSSH).
Create .github/workflows/deploy.yml:
name: Deploy
on:
push:
branches: [main]
jobs:
test:
runs-on: ubuntu-latest
services:
postgres:
image: postgres:17
env:
POSTGRES_USER: postgres
POSTGRES_PASSWORD: postgres
POSTGRES_DB: myapp_test
ports:
- 5432:5432
options: >-
--health-cmd pg_isready
--health-interval 10s
--health-timeout 5s
--health-retries 5
env:
MIX_ENV: test
DATABASE_URL: ecto://postgres:postgres@localhost/myapp_test
steps:
- uses: actions/checkout@<sha>
with:
persist-credentials: false
- uses: erlef/setup-beam@<sha>
with:
otp-version: "28.4.1"
elixir-version: "1.19.5"
- run: mix deps.get
- run: mix test
build-and-deploy:
needs: test
runs-on: ubuntu-latest
concurrency:
group: deploy-myapp
cancel-in-progress: true
env:
MIX_ENV: prod
steps:
- uses: actions/checkout@<sha>
with:
persist-credentials: false
- uses: erlef/setup-beam@<sha>
with:
otp-version: "28.4.1"
elixir-version: "1.19.5"
- run: mix deps.get --only prod
- run: mix compile
- run: mix assets.deploy
- run: mix release
- name: Package release
run: tar czf myapp.tar.gz -C _build/prod/rel/myapp .
- name: Deploy to VPS
env:
VPS_HOST: ${{ secrets.VPS_HOST }}
VPS_SSH_KEY: ${{ secrets.VPS_SSH_KEY }}
run: |
mkdir -p ~/.ssh
echo "$VPS_SSH_KEY" > ~/.ssh/deploy_key
chmod 600 ~/.ssh/deploy_key
ssh-keyscan -H "$VPS_HOST" >> ~/.ssh/known_hosts
SSH_OPTS="-i ~/.ssh/deploy_key"
scp $SSH_OPTS myapp.tar.gz deploy@"$VPS_HOST":/tmp/myapp.tar.gz
ssh $SSH_OPTS deploy@"$VPS_HOST" bash <<'REMOTE'
set -euo pipefail
sudo systemctl stop myapp
rm -rf /opt/myapp/bin /opt/myapp/lib /opt/myapp/erts-* /opt/myapp/releases
tar xzf /tmp/myapp.tar.gz -C /opt/myapp
set -a; source /opt/myapp/.env; set +a
/opt/myapp/bin/migrate
sudo systemctl start myapp
rm /tmp/myapp.tar.gz
REMOTEReplace <sha> with the actual commit SHAs of the actions. You can find the
latest SHAs with:
gh api repos/actions/checkout/releases/latest --jq '.tag_name'
gh api repos/erlef/setup-beam/releases/latest --jq '.tag_name'
# Then get the SHA for a tag:
gh api repos/actions/checkout/git/ref/tags/vX.Y.Z --jq '.object.sha'Pin Elixir and OTP to exact versions matching your .tool-versions to avoid
builds drifting to a different patch release.
The concurrency group prevents parallel deploys from racing on the same systemd service.
Important: the mix compile step before mix assets.deploy is required
for Phoenix 1.8+ colocated components. Without it, esbuild can't resolve
phoenix-colocated/* imports.
config/test.exs needs to read DATABASE_URL. The CI test job above
sets DATABASE_URL as an env var, but Ecto will ignore it unless the test
Repo config opts in:
config :myapp, MyApp.Repo,
url: System.get_env("DATABASE_URL"),
# ... the rest of your test repo config (hostname, database, etc.)With url: present, CI uses the postgres service defined in the workflow;
locally, DATABASE_URL is unset and the hardcoded credentials below take
over.
mix release produces a binary tied to the host's OS, architecture, and
glibc version. The CI path above works without Docker because GitHub's
ubuntu-latest runner already matches the Debian server closely enough.
For a manual deploy from macOS (or any non-Linux dev machine), you need
Docker to produce a Linux x86_64 release that will run on the server:
docker run --rm -v $(pwd):/app -w /app \
hexpm/elixir:1.19.5-erlang-28.4.1-debian-trixie-20260406-slim \
bash -c "apt-get update && apt-get install -y build-essential git \
&& mix local.hex --force && mix local.rebar --force \
&& mix deps.get --only prod \
&& MIX_ENV=prod mix compile \
&& MIX_ENV=prod mix assets.deploy \
&& MIX_ENV=prod mix release"
tar czf myapp.tar.gz -C _build/prod/rel/myapp .
scp myapp.tar.gz deploy@YOUR_SERVER_IP:/tmp/
ssh deploy@YOUR_SERVER_IP bash <<'REMOTE'
set -euo pipefail
sudo systemctl stop myapp
rm -rf /opt/myapp/bin /opt/myapp/lib /opt/myapp/erts-* /opt/myapp/releases
tar xzf /tmp/myapp.tar.gz -C /opt/myapp
set -a; source /opt/myapp/.env; set +a
/opt/myapp/bin/migrate
sudo systemctl start myapp
rm /tmp/myapp.tar.gz
REMOTECheck Docker Hub for available image tags matching your Elixir and OTP versions.
If your app uses phx.gen.auth and has no public registration, create the
first user via the remote IEx console. SSH into the VPS as the deploy
user and run:
/opt/myapp/bin/myapp remoteThen in IEx:
alias MyApp.Accounts.User
alias MyApp.Repo
{:ok, user} = %User{} |> User.email_changeset(%{email: "you@example.com"}) |> Repo.insert()
user |> User.password_changeset(%{password: "your-strong-password"}) |> Repo.update!()The password must be between 12 and 72 characters (bcrypt limit). Generate
one in a password manager. If update! raises, check the validation error.
- Provision server on Hetzner, add SSH key
- Enable Hetzner VPS backups
- Run server setup (deploy user, firewall, packages)
- Install and configure PostgreSQL
- Set up DNS A record for your domain
- Install and configure Caddy
- Create app directory and
.envfile - Create the systemd service
- Run
mix phx.gen.releaselocally and commit - Set up deploy SSH key and GitHub secrets
- Push to
mainand watch the deploy - Verify the app is live at your domain
- Set up backup cron job and verify it runs
# View logs
journalctl -u myapp -f
# Restart the app
sudo systemctl restart myapp
# Remote IEx console (SSH in as deploy user)
/opt/myapp/bin/myapp remote
# Run migrations (in a release, without Mix)
/opt/myapp/bin/myapp eval "MyApp.Release.migrate()"
# Run seeds (if you added a seed/0 function to your Release module)
/opt/myapp/bin/myapp eval "MyApp.Release.seed()"
# Check Postgres
sudo -u postgres psql -d myapp_prod
# Restore a backup
gunzip < /opt/backups/postgres/myapp_prod_YYYYMMDD_HHMMSS.sql.gz | psql -U myapp myapp_prodLocal backups on the same disk aren't real backups — if the disk dies, you lose everything. Hetzner offers Storage Boxes starting at €3.20/mo (1 TB) for offsite storage.
- Create a BX11 Storage Box in the Hetzner console (any location)
- Enable SSH Support in the storage box settings
- Note the hostname and username (e.g.,
uNNNNNN@uNNNNNN.your-storagebox.de)
Storage Boxes require keys in RFC 4716 format. From the VPS as root:
ssh-keygen -t ed25519 -f /root/.ssh/id_storagebox -N ""
# Convert to RFC 4716 format (required by Hetzner Storage Boxes)
ssh-keygen -e -f /root/.ssh/id_storagebox.pub > /tmp/storagebox_key_rfc.pub
# Upload via SFTP (will prompt for storage box password)
sftp uNNNNNN@uNNNNNN.your-storagebox.de
mkdir .ssh
put /tmp/storagebox_key_rfc.pub .ssh/authorized_keys
chmod 600 .ssh/authorized_keys
mkdir backups
quitVerify key auth works (should connect without a password):
sftp -i /root/.ssh/id_storagebox uNNNNNN@uNNNNNN.your-storagebox.deNote: Storage Boxes only support SFTP/SCP, not regular SSH sessions.
If you see exec request failed on channel 0, that's normal — use sftp
to test, not ssh.
Add the sync step to /opt/backups/pg_backup.sh:
#!/bin/bash
BACKUP_DIR="/opt/backups/postgres"
DB_NAME="myapp_prod"
KEEP_DAYS=14
STORAGEBOX="uNNNNNN@uNNNNNN.your-storagebox.de"
STORAGEBOX_KEY="/root/.ssh/id_storagebox"
mkdir -p "$BACKUP_DIR"
sudo -u postgres pg_dump "$DB_NAME" | gzip > "$BACKUP_DIR/${DB_NAME}_$(date +%Y%m%d_%H%M%S).sql.gz"
find "$BACKUP_DIR" -type f -mtime +${KEEP_DAYS} -delete
scp -i "$STORAGEBOX_KEY" "$BACKUP_DIR"/*.sql.gz "$STORAGEBOX":backups/| Item | Monthly cost |
|---|---|
| Hetzner CX23 (2 vCPU, 4 GB RAM) | ~€2.99 |
| Hetzner VPS Backups (20% of plan) | ~€0.60 |
| Hetzner Storage Box BX11 (1 TB, optional) | ~€3.20 |
| Total | ~€6.79 |
Domain and DNS costs vary by provider and are not included.