DEV Community

jsph
jsph

Posted on • Originally published at jsphwrkshp.com

Github Actions for Phoenix App Deployment to Hetzner

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/
Enter fullscreen mode Exit fullscreen mode

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()"
Enter fullscreen mode Exit fullscreen mode

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
Enter fullscreen mode Exit fullscreen mode

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'

Enter fullscreen mode Exit fullscreen mode

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)