home overview tags

Herman verschooten

These things I recently learned, that may be useful to myself and others in the future.

How I deploy my Phoenix apps

Prerequisites

What do I need to do when I setup a new deployment?

  • Setup a new Ubuntu container on one of my ProxMox servers.
  • Install Postgres
  • Copy scripts/systemd unit-files
  • create /opt/app folder
  • Add github workflows to my app.
  • Add the necessary secrets to the Github repo.

I'll go over each in more detail below.

Please replace all instances of app with the real name of the application.

Setup the container

On the ProxMox server I select to create a new container, from the list of available images, I choose the most recent Ubuntu image, currently 22.04 LTS. For memory I usually set 1GB and HD space depends on what app this will be.

Once the image has been created and the instance started, I ssh into it and do the necessary apt-get update and apt-get dist-upgrade, before continuing with the installation of Postgresql.

Install Postgresql

Most of the time the version of Postgresql available on the Ubuntu default apt-repository suffices. The latest at the moment of writing is 14.

apt-get install postgresql
sudo -u postgres
createuser -P app
createdb -O app app_production
exit
psql -h localhost -U app -W app_production

First install Postgresql, then switch to the postgres user and create the role and database. Lastly check that the role really has access. I have noticed I need to add the -h localhost or the connection will fail.

Copy scripts/systemd unit-files

These are the files I usually copy over.

  • ~/deploy

    #!/usr/bin/env bash
    sudo systemctl stop app
    cd /opt/app
    tar -zxf app.tar.gz
    sudo systemctl start app
  • /usr/sbin/app

    #!/usr/bin/env sh
    export APP=$(basename $0)
    export RELEASE_NODE=$APP
    export RELEASE_COOKIE=$(grep -oe "RELEASE_COOKIE=.*" /lib/systemd/system/$APP.service | cut -d'=' -f2)
    export RELEASE_DISTRIBUTION=name
    /opt/$APP/bin/$APP $@
  • /lib/systemd/system/app.service

    [Unit]
    Description=app Website
    After=syslog.target
    After=network.target
    
    [Service]
    TimeoutSec=120
    User=root
    Group=root
    
    Environment=LANG=C.UTF-8
    Environment=LC_CTYPE=C.UTF-8
    Environment=HOME=/root
    Environment=PORT=
    Environment=SECRET_KEY_BASE=
    Environment=DATABASE_URL=ecto://app:password@localhost/app_production
    Environment=RELEASE_DISTRIBUTION=name
    Environment=RELEASE_COOKIE=
    Environment=PHX_SERVER=true
    
    ExecStartPre= /opt/app/bin/app eval "app.Release.migrate()"
    ExecStart= /opt/app/bin/app start
    ExecStop= /opt/app/bin/app stop
    Restart= on-failure
    
    [Install]
    WantedBy=multi-user.target

The first 2 scripts obviously need to be executable, so chmod +x if necessary.
The deploy-script is run from the Github action to copy the new release in place and restart the app.
The app-script allows me to easily use the remote iex-console of the running app, be ssure to rename it the name of your app, or it will not find the service-file.

The systemd unit-file needs some more adjustments, depending on if it will be running behind an Nginx reverse-proxy or if I will be using SiteEncrypt. Certainly we need to fill in the missing entries, and adjust the database connection info.
Once those are set, we instruct systemd of our changes, and enable the app.

systemctl daemon-reload
systemctl enable app

Create /opt/app folder

Next up we need to create the /opt/app-folder, this is where our app will live. We need to create it so the Github action can scp our tar.gzipped app here and the deploy script can unpack it.

Add Github workflows

Usually I have 2 workflows in each app, one for running the tests and the other for the actual deploy. I'll show the deploy workflow here. This workflow expects an app that will be deployed using a Wireguard VPN-tunnel. The workflow has 2 jobs, build and deploy. The build acts mostly as a cache, if the deploy fails - which happens sometimes, thank you Github - then we can just rerun this step and do not need to do a full recompilation.

name: Deploy to production

on:
  push:
    branches:
      - main

env:
  otp-version: 25.0.4
  elixir-version: 1.14.0
  node-version: 17.0.1


jobs:
  build:
    runs-on: ubuntu-22.04

    env:
      MIX_ENV: prod

    steps:
      - uses: actions/checkout@v3

      - name: Install OTP and Elixir
        uses: erlef/setup-beam@v1.14
        with:
          otp-version: ${{ env.otp-version }}
          elixir-version: ${{ env.elixir-version }}

      - name: Cache mix deps
        uses: actions/cache@v3
        env:
          cache-name: mix-deps-prod
        with:
          path: deps
          key: ${{ runner.os }}-${{ env.otp-version }}-${{ env.elixir-version }}-${{ env.cache-name }}-${{ hashFiles(format('{0}{1}', github.workspace, '/mix.lock'))}}

      - name: Cache build
        uses: actions/cache@v3
        env:
          cache-name: mix-build-prod-v1
        with:
          path: _build
          key: ${{ runner.os }}-${{ env.otp-version }}-${{ env.elixir-version }}-${{ env.cache-name }}-${{ hashFiles(format('{0}{1}', github.workspace, '/mix.lock'))}}

      - run: mix deps.get --only prod
      - run: mix deps.compile

  deploy:
    runs-on: ubuntu-22.04
    needs: build

    env:
      MIX_ENV: prod

    steps:
      - uses: actions/checkout@v3

      - name: Install OTP and Elixir
        uses: erlef/setup-beam@v1.14
        with:
          otp-version: ${{ env.otp-version }}
          elixir-version: ${{ env.elixir-version }}

      - name: Cache mix deps
        uses: actions/cache@v3
        env:
          cache-name: mix-deps-prod
        with:
          path: deps
          key: ${{ runner.os }}-${{ env.otp-version }}-${{ env.elixir-version }}-${{ env.cache-name }}-${{ hashFiles(format('{0}{1}', github.workspace, '/mix.lock'))}}

      - name: Cache build
        uses: actions/cache@v3
        env:
          cache-name: mix-build-prod-v1
        with:
          path: _build
          key: ${{ runner.os }}-${{ env.otp-version }}-${{ env.elixir-version }}-${{ env.cache-name }}-${{ hashFiles(format('{0}{1}', github.workspace, '/mix.lock'))}}

      - name: Install Node.js
        uses: actions/setup-node@v3
        with:
          node-version: ${{ env.node-version }}

      - name: Cache node modules
        uses: actions/cache@v3
        env:
          cache-name: npm-deps-v1
        with:
          path: ~/.npm
          key: ${{ runner.os }}-${{ env.node-version }}-${{ env.cache-name }}-${{ hashFiles(format('{0}{1}', github.workspace, '/assets/package-lock.json'))}}

      - run: |
          npm ci
        working-directory: ./assets

      - run: mix compile
      - run: mix assets.deploy
      - run: mix release --overwrite
      - run: tar -zcf app.tar.gz -C _build/prod/rel/app .

      - name: WireGuard
        uses: Hermanverschooten/wireguard@v0.0.01-alpha
        with:
          config: '${{ secrets.WIREGUARD }}'

      - name: Publish
        uses: nogsantos/scp-deploy@master
        with:
          src: ./app.tar.gz
          host: <host-vpn-ip>
          remote: /opt/app
          port:  22
          user:  root
          key: ${{ secrets.SSH_KEY }}

      - name: Remote SSH Commands
        uses: fifsky/ssh-action@v0.0.4
        with:
          host: <host-vpn-ip>
          port:  22
          user:  root
          key: ${{ secrets.SSH_KEY }}
          command: ./deploy

Add the necessary secrets to Github

If Wireguard is not needed, the step can be removed, the <host-vpn-ip> needs to be an IP on which the Github action can access your ssh-server.
If you do want to use Wireguard, supply a valid Wireguard configuration file in your Github secrets.

Your ssh private key must also be present as a Github secret called SSH_KEY.

You are free to replace any of these settings with more Github secrets

Wireguard?

Why do I use Wireguard in this deploy? Most of my ProxMox containers are only available on IPv6 addresses and Github actions currently does not support IPv6. This means I cannot access the server directly. Each of these instances has a secondary network interface that has a private-range-ip. I have setup a Wireguard server that allows access to that range.

Webserver

Recently I have begun to deploy apps that use SiteEncrypt for https, this removes the need to setup a reverse-proxy in front of the app. It brings in a bit more configuration, you can find most of the info on HexDocs. You'll probably need to add some more Environment keys to the unit-file, but that is a topic of another, maybe future, blog-post.

The attentive reader will have noticed that most of my VPS only have an IPv6 address... how are they reachable for IPv4? I use a set of haproxy instances for this. Depending on the webserver using either Proxy-Protocol or just plain https.


UPDATE 2022-12-13
Due to a Node.js deprecation in the Github Action workers, it was necessary to update the versions of most actions.