Recently I started hosting wishlist palace on a Hetzner CX23 VPS (that has 4 GB of ram and 2 vcpu). At work usually I package software into a container before deploying it. One of the reasons I chose Elixir and Phoenix as the tech stack for wishlist palace is that I wanted to try out mix releases which prepare binaries.
OS and chip architecture
Originally I was just going to compile on my local machine (A thinkpad T480) and upload to the server. But this didn't work because the VPS I had provisioned was running ubuntu 24.04 LTS and my local machine is on EndevousOS (a flavor of Arch by-the-way). So rather than local build and push, I opted to get claude to build a series of github actions that would build on a worker that was running ubuntu. This had the added benefit of basically completely setting up a basic CI loop so that when a release is cut we build a new binary and deploy it on the server.
Elixir's mix release creates a self-contained binary that bundles the Erlang VM along with the compiled application. The server needs no Elixir or Erlang installed (just a matching Linux architecture which as I said before tripped me up a bit).
Release Strategy
The release is pushed to the server with rsync, which only transfers changed files:
rsync -avz --delete _build/prod/rel/wish_list/ deploy@$VPS_IP:/opt/wish_list/
Process management with systemd
The phoenix app runs as a systemd service so one uses systemctl to interact with processes under its control. In the future I might setup something more like a blue-green deploy but for now I just restart the service after rsync updates the binary. The github action below restarts the service to pick up the changes.
Migrations
Of course I also have a postgreSQL database running on the VPS so I need to do migrations from time to time. Migrations are run via the release's eval command, calling a specific release command within my application code and run directly against the production database. For me that looks like
/opt/wish_list/bin/wish_list eval "WishListDomain.Release.migrate()"
CI/CD: GitHub Actions on Release
Deployments are triggered by publishing a GitHub Release. I like to have some testing before deploy just to catch catastrophic failures on my part so the first step runs tests. Then if tests are green we can build, compile, deploy, migrate, and restart the service.
Job 1 — Test
Spins up a Postgres 16 service container, installs Elixir 1.19 / OTP 27, runs migrations, and executes the test suite.
name: Deploy
on:
release:
types: [published]
jobs:
test:
name: Test
runs-on: ubuntu-24.04
services:
postgres:
image: postgres:16
env:
POSTGRES_USER: postgres
POSTGRES_PASSWORD: postgres
POSTGRES_DB: wish_list_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/wish_list_test
TOKEN_SIGNING_SECRET: test_token_signing_secret
steps:
- uses: actions/checkout@v4
- name: Set up Elixir
uses: erlef/setup-beam@v1
with:
elixir-version: "1.19"
otp-version: "27"
- name: Install deps
run: mix deps.get
- name: Compile
run: mix compile --warnings-as-errors
- name: Run migrations
run: mix ecto.migrate --repo WishListDomain.Repo
- name: Run tests
run: mix test
Job 2 — Build & Deploy (runs only after test passes)
Builds the production release, opens an SSH connection to the Hetzner VPS, rsyncs the release binary, runs migrations, restarts the systemd service, and verifies it came up:
build-and-deploy:
name: Build & Deploy
runs-on: ubuntu-24.04
needs: test
env:
MIX_ENV: prod
steps:
- uses: actions/checkout@v4
- name: Set up Elixir
uses: erlef/setup-beam@v1
with:
elixir-version: "1.19"
otp-version: "27"
- name: Install deps
run: mix deps.get --only prod
- name: Build assets
run: cd apps/wish_list_web && mix assets.deploy
- name: Build release
run: mix release wish_list
- name: Set up SSH
run: |
mkdir -p ~/.ssh
echo "${{ secrets.SSH_PRIVATE_KEY }}" > ~/.ssh/id_ed25519
chmod 600 ~/.ssh/id_ed25519
ssh-keyscan -H ${{ secrets.VPS_IP }} >> ~/.ssh/known_hosts
- name: Sync release to VPS
run: |
rsync -az --delete \
_build/prod/rel/wish_list/ \
deploy@${{ secrets.VPS_IP }}:/opt/wish_list/
- name: Run migrations
run: |
ssh deploy@${{ secrets.VPS_IP }} \
'set -a && source /etc/wish_list.env && set +a && /opt/wish_list/bin/wish_list eval
"WishListDomain.Release.migrate()"'
- name: Restart service
run: |
ssh deploy@${{ secrets.VPS_IP }} \
'sudo systemctl restart wish_list'
- name: Verify service is running
run: |
ssh deploy@${{ secrets.VPS_IP }} \
'sudo systemctl is-active wish_list'
Two GitHub Actions secrets are required: SSH_PRIVATE_KEY (the private key for the deploy user) and VPS_IP (the server's IP address).
Top comments (0)