Here's a transcript of how you can deploy an Elixir Phoenix web application using mix releases and a GitHub action.

The release will be deployed by a systemd unit on a Linux server.

Create a blank app:

$ mix phx.new hello
* creating hello/lib/hello/application.ex
* creating hello/lib/hello.ex
* creating hello/lib/hello_web/controllers/error_json.ex
* creating hello/lib/hello_web/endpoint.ex
* creating hello/lib/hello_web/router.ex
* creating hello/lib/hello_web/telemetry.ex
* creating hello/lib/hello_web.ex
* creating hello/mix.exs
* creating hello/README.md
* creating hello/.formatter.exs
* creating hello/.gitignore
* creating hello/test/support/conn_case.ex
* creating hello/test/test_helper.exs
* creating hello/test/hello_web/controllers/error_json_test.exs
* creating hello/lib/hello/repo.ex
* creating hello/priv/repo/migrations/.formatter.exs
* creating hello/priv/repo/seeds.exs
* creating hello/test/support/data_case.ex
* creating hello/lib/hello_web/controllers/error_html.ex
* creating hello/test/hello_web/controllers/error_html_test.exs
* creating hello/lib/hello_web/components/core_components.ex
* creating hello/lib/hello_web/controllers/page_controller.ex
* creating hello/lib/hello_web/controllers/page_html.ex
* creating hello/lib/hello_web/controllers/page_html/home.html.heex
* creating hello/test/hello_web/controllers/page_controller_test.exs
* creating hello/lib/hello_web/components/layouts/root.html.heex
* creating hello/lib/hello_web/components/layouts/app.html.heex
* creating hello/lib/hello_web/components/layouts.ex
* creating hello/priv/static/images/logo.svg
* creating hello/lib/hello/mailer.ex
* creating hello/lib/hello_web/gettext.ex
* creating hello/priv/gettext/en/LC_MESSAGES/errors.po
* creating hello/priv/gettext/errors.pot
* creating hello/priv/static/robots.txt
* creating hello/priv/static/favicon.ico
* creating hello/assets/js/app.js
* creating hello/assets/vendor/topbar.js
* creating hello/assets/css/app.css
* creating hello/assets/tailwind.config.js

Fetch and install dependencies? [Yn] y
* running mix deps.get
* running mix assets.setup
* running mix deps.compile

We are almost there! The following steps are missing:

    $ cd hello

Then configure your database in config/dev.exs and run:

    $ mix ecto.create

Start your Phoenix app with:

    $ mix phx.server

You can also run your app inside IEx (Interactive Elixir) as:

    $ iex -S mix phx.server

Generate the release files:

$ mix phx.gen.release
* creating rel/overlays/bin/server
* creating rel/overlays/bin/server.bat
* creating rel/overlays/bin/migrate
* creating rel/overlays/bin/migrate.bat
* creating lib/hello/release.ex

Your application is ready to be deployed in a release!

See https://hexdocs.pm/mix/Mix.Tasks.Release.html for more information about Elixir releases.

Here are some useful release commands you can run in any release environment:

    # To build a release
    mix release

    # To start your system with the Phoenix server running
    _build/dev/rel/hello/bin/server

    # To run migrations
    _build/dev/rel/hello/bin/migrate

Once the release is running you can connect to it remotely:

    _build/dev/rel/hello/bin/hello remote

To list all commands:

    _build/dev/rel/hello/bin/hello

Create the folder structure on the target server:

mkdir -p /var/www/hello.yellowduck.be

Generate a new secret for the deployment:

mix phx.gen.secret

Create the .env file on the target server:

/var/www/hello.yellowduck.be/.env

PHX_HOST=hello.yellowduck.be
PORT=4001
PHX_SERVER=true
SECRET_KEY_BASE=my-secret-key
MIX_ENV=prod
DATABASE_URL=ecto://user:pass@localhost/hello

Create the systemctl daemon:

/etc/systemd/system/hello-yellowduck-be.service

[Unit]
Description=hello.yellowduck.be

[Service]
User=root
EnvironmentFile=/var/www/hello.yellowduck.be/.env
Environment=LANG=en_US.utf8
WorkingDirectory=/var/www/hello.yellowduck.be/
ExecStart=/var/www/hello.yellowduck.be/_build/prod/rel/hello/bin/hello start
ExecStop=/var/www/hello.yellowduck.be/_build/prod/rel/hello/bin/hello stop
KillMode=process
Restart=on-failure
LimitNOFILE=65535
SyslogIdentifier=hello-yellowduck-be

[Install]
WantedBy=multi-user.target

Load the daemon configuration:

systemctl daemon-reload

Define the following secrets in GitHub:

  • SSH_KEY: the SSH key used to connect to the server (see here on how to get this info)
  • SSH_HOST: the hostname of the server to where you want to deploy
  • SSH_USER: the username you you want to use to connect via SSH to the server

Create the github action (remember to update the env vars at the top of the action with the correct values for your environment):

.github/workflows/deploy.yaml

name: Deploy

on:
  push:
    branches: ["main"]
  pull_request:
    branches: ["main"]

env:
  MIX_ENV: prod
  UBUNTU_VERSION: ubuntu-20.04
  DEPLOY_PATH: /var/www/hello.yellowduck.be
  DEPLOY_APP_NAME: hello
  DEPLOY_DAEMON_NAME: hello-yellowduck-be

permissions:
  contents: write

jobs:
  deploy:
    runs-on: ubuntu-20.04
    steps:
    - name: Checkout code
      uses: actions/checkout@v4

    - name: Set up Elixir
      uses: erlef/setup-beam@v1
      with:
        version-file: .tool-versions
        version-type: strict

    - name: Cache deps
      id: cache-deps
      uses: actions/cache@v4
      env:
        cache-name: cache-elixir-deps
      with:
        path: deps
        key: ${{ env.UBUNTU_VERSION }}-mix-${{ env.cache-name }}-${{ hashFiles('**/mix.lock') }}-${{ hashFiles('**/.tool-versions') }}
        restore-keys: |
          ${{ env.UBUNTU_VERSION }}-mix-${{ env.cache-name }}-

    - name: Cache compiled build
      id: cache-build
      uses: actions/cache@v4
      env:
        cache-name: cache-compiled-build
      with:
        path: _build
        key: ${{ env.UBUNTU_VERSION }}-mix-${{ env.cache-name }}-${{ hashFiles('**/mix.lock') }}-${{ hashFiles('**/.tool-versions') }}
        restore-keys: |
          ${{ env.UBUNTU_VERSION }}-mix-${{ env.cache-name }}-
          ${{ env.UBUNTU_VERSION }}-mix-

    - name: Clean to rule out incremental build as a source of flakiness
      if: github.run_attempt != '1'
      run: |
        mix deps.clean --all
        mix clean
      shell: sh

    - name: Install dependencies
      run: mix deps.get --only-prod

    - name: Compile
      run: mix compile

    - name: Compile assets
      run: mix assets.deploy

    - name: Compile release
      run: mix release --overwrite

    - name: Install SSH key
      uses: shimataro/ssh-key-action@v2
      with:
        key: ${{ secrets.SSH_KEY }}
        known_hosts: 'to be defined on next step'

    - name: Add Known Hosts
      run: ssh-keyscan -H ${{ secrets.SSH_HOST }} >> ~/.ssh/known_hosts

    - name: Deploy Release with rsync
      run: rsync --delete -avz ./_build ${{ secrets.SSH_USER }}@${{ secrets.SSH_HOST }}:${{ env.DEPLOY_PATH }}

    - name: Restart Application
      uses: appleboy/ssh-action@v1.2.0
      with:
        host: ${{ secrets.SSH_HOST }}
        username: ${{ secrets.SSH_USER }}
        key: ${{ secrets.SSH_KEY }}
        script: |
          export $(cat ${{ env.DEPLOY_PATH }}/.env | xargs) && ${{ env.DEPLOY_PATH }}/_build/${{ env.MIX_ENV }}/rel/${{ env.DEPLOY_APP_NAME }}/bin/migrate
          systemctl daemon-reload
          systemctl restart ${{ env.DEPLOY_DAEMON_NAME }}

Configure Caddy to expose the application:

$ vim /etc/caddy/Caddyfile

Then add the following entry:

hello.yellowduck.be {
    root * /var/www/hello.yellowduck.be
    encode zstd gzip
    reverse_proxy localhost:4001
    header -Server
    log {
        output file /var/log/caddy/access_hello_yellowduck.log
    }
}

When you now push anything to the main branch, it will be built as a release, transferred to the target server and it will restart the daemon.