Skip to content

Instantly share code, notes, and snippets.

@petros
Last active April 21, 2026 07:58
Show Gist options
  • Select an option

  • Save petros/ac8cf273553456e1e9a6addaebdce33c to your computer and use it in GitHub Desktop.

Select an option

Save petros/ac8cf273553456e1e9a6addaebdce33c to your computer and use it in GitHub Desktop.
Deploying a Phoenix App to Hetzner

Deploying a Phoenix App to Hetzner

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).

Architecture

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.

Prerequisites

  • 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)

1. Provision the VPS

In the Hetzner Cloud console:

  1. Create a new server
  2. Location: pick the closest region to you
  3. Image: Debian 13 (or Ubuntu 24.04 — both work)
  4. Type: Shared Resources → Cost-Optimized → CX23 (x86)
  5. Networking: Public IPv4 + Public IPv6 (defaults)
  6. SSH key: add your public key
  7. Skip volumes, placement groups, and cloud-init
  8. Name: something meaningful (e.g., your org name or phoenix-server)
  9. 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_IP

Run 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 enable

Only 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 fail2ban

This 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.

2. Install PostgreSQL

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_prod

Automated backups

Create /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} -delete
mkdir -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-backup

Test 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.

3. Install Caddy

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 caddy

Before 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 caddy

That'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
}

4. Set up the app directory

mkdir -p /opt/myapp
chown deploy:deploy /opt/myapp

Create /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=4000

Generate the secret key base on your local machine:

mix phx.gen.secret

Lock down the env file:

chown root:deploy /opt/myapp/.env
chmod 640 /opt/myapp/.env

The deploy user can read the file (needed for migrations during deploy) but cannot modify it. Only root can change secrets.

5. Create the systemd service

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.target
systemctl daemon-reload
systemctl enable myapp

Don't start it yet — there's no release to run until the first deploy.

6. Generate the release config locally

On your local machine, in your Phoenix project directory:

mix phx.gen.release

This creates:

  • lib/myapp/release.ex — migration module used by the bin/migrate script
  • rel/overlays/bin/server — starts the app with PHX_SERVER=true
  • rel/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.

Release environment (rel/env.sh.eex)

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.

7. Set up the deploy SSH key

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.pub

Add 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_myapp

Using 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).

8. Build and deploy with GitHub Actions

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
          REMOTE

Replace <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.

Manual deploy (no CI)

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
REMOTE

Check Docker Hub for available image tags matching your Elixir and OTP versions.

9. Create the admin user

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 remote

Then 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.

10. Deploy checklist

  1. Provision server on Hetzner, add SSH key
  2. Enable Hetzner VPS backups
  3. Run server setup (deploy user, firewall, packages)
  4. Install and configure PostgreSQL
  5. Set up DNS A record for your domain
  6. Install and configure Caddy
  7. Create app directory and .env file
  8. Create the systemd service
  9. Run mix phx.gen.release locally and commit
  10. Set up deploy SSH key and GitHub secrets
  11. Push to main and watch the deploy
  12. Verify the app is live at your domain
  13. Set up backup cron job and verify it runs

11. Day-to-day operations

# 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_prod

Offsite backups (optional)

Local 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.

Setting up a Hetzner Storage Box

  1. Create a BX11 Storage Box in the Hetzner console (any location)
  2. Enable SSH Support in the storage box settings
  3. Note the hostname and username (e.g., uNNNNNN@uNNNNNN.your-storagebox.de)

SSH key authentication

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
  quit

Verify key auth works (should connect without a password):

sftp -i /root/.ssh/id_storagebox uNNNNNN@uNNNNNN.your-storagebox.de

Note: 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.

Update the backup script

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/

Cost

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.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment