Set Up Continuous Deployment for Elixir with GitHub Actions

Once you Deploy Elixir to Digital Ocean with Docker, the next step is to eliminate the manual Elixir deployment steps and establish a continuous integration / continuous deployment (CI/CD) pipeline. There are many CI/CD services available, but we are going to set up continuous deployment for Elixir with GitHub Actions.

With a continuous deployment pipeline, every change that passes all stages of your production pipeline is released to your users. There’s no human intervention, and only a failed test will prevent a new change to be deployed to production.

The goal is to use GitHub Actions to run the Elixir test suite using mix test, build & publish the Docker image to Docker Hub, and deploy the image by updating our service on the Digital Ocean Droplet.

We’re going to start with a Phoenix app deployed to Digital Ocean using the steps in our previous post, Deploy Elixir to Digital Ocean with Docker. We are using Elixir version 1.11.4-otp-24. Once you finish the steps in the post, your app should be up and running on your Droplet.

Overview

We’ll go over the following steps to set up continuous deployment in a GitHub workflow for Elixir with GitHub Actions:

  1. Mix Test
  2. Build and Publish to Docker Hub
  3. Continuously Deploy Updated Release

Mix Test with GitHub Actions

The first step in the workflow will be to run our automated test suite when we push code to GitHub to hopefully reduce the bugs in production. With continuous deployment, if the tests pass after pushing changes to your GitHub default branch, the changes will automatically get deployed to production.

GitHub Actions allow you to start with a preconfigured workflow template. We’ll use the template for Elixir.

Click the "Actions" tab then "Set up this workflow".

Elixir Template for GitHub Actions

We need to make some updates to the GitHub Actions Elixir template, the main change is to add the database.

We have a Phoenix app and we used the CI GitHub Action ci.yml file from the Elixir Companies website project on GitHub as a reference.

Here is the updated elixir.yml file for our Github Actions. We’ve name the workflow Elixir CI. We’ll discuss the details of each section below.

name: Elixir CI

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

jobs:
  test:
    name: Build and test
    runs-on: ubuntu-latest
    services:
      db:
        env:
          POSTGRES_USER: postgres
          POSTGRES_PASSWORD: postgres
          POSTGRES_DB: postgres
        image: postgres:11
        ports: ['5432:5432']
        options: >-
          --health-cmd pg_isready
          --health-interval 10s
          --health-timeout 5s
          --health-retries 5

    steps:
    - uses: actions/checkout@v2
    - name: Set up Elixir
      uses: erlef/setup-elixir@885971a72ed1f9240973bd92ab57af8c1aa68f24
      with:
        elixir-version: '1.11.4' # Define the elixir version [required]
        otp-version: '24' # Define the OTP version [required]
    - name: Restore dependencies cache
      uses: actions/cache@v2
      with:
        path: deps
        key: ${{ runner.os }}-mix-${{ hashFiles('**/mix.lock') }}
        restore-keys: ${{ runner.os }}-mix-
    - name: Install dependencies
      run: mix deps.get
    - name: Run tests
      run: mix test
    - name: Check Formatting
      run: mix format --check-formatted

For now, we’ll use the default settings to run our workflow for both pull requests and pushes to master.

on:
  push:
    branches: [ master ]
  pull_request:
    branches: [ master ]

Then we’ll run our first job we’ve labelled Build and test on the latest ubuntu. Before we get to the Elixir setup, we also need to set up our Postgres service. Postgres options include our environment variables.

  test:
    name: Build and test
    runs-on: ubuntu-latest
    services:
      db:
        env:
          POSTGRES_USER: postgres
          POSTGRES_PASSWORD: postgres
          POSTGRES_DB: postgres
        image: postgres:11
        ports: ['5432:5432']
        options: >-
          --health-cmd pg_isready
          --health-interval 10s
          --health-timeout 5s
          --health-retries 5

With the database created, we can use Checkout and Setup Elixir to prepare the environment.

Setup Elixir includes defining your elixir version and otp version. Then to install dependencies run mix deps and call mix test to run tests in the test suite.

You can also do things like check formatting with mix format and static code analysis. You can accomplish static code analysis with a tool like Credo.

    steps:
    - uses: actions/checkout@v2
    - name: Set up Elixir
      uses: erlef/setup-elixir@885971a72ed1f9240973bd92ab57af8c1aa68f24
      with:
        elixir-version: '1.11.4' # Define the elixir version [required]
        otp-version: '24' # Define the OTP version [required]
    - name: Restore dependencies cache
      uses: actions/cache@v2
      with:
        path: deps
        key: ${{ runner.os }}-mix-${{ hashFiles('**/mix.lock') }}
        restore-keys: ${{ runner.os }}-mix-
    - name: Install dependencies
      run: mix deps.get
    - name: Run tests
      run: mix test
    - name: Check Formatting
      run: mix format --check-formatted

To test our action, commit the updated elixir.yml file to master on GitHub. Then click the "Actions" tab to watch the progress and results of your Elixir CI workflow.

If your mix format fails, you may need to git pull changes to your local machine, run mix format on the file and then push changes back to GitHub.

GitHub Actions workflow

You may see some deprecation warnings, but the GitHub Actions workflow exits with success after the tests all pass.

Build and Publish to DockerHub

To begin this section, git pull the changes to your local machine from your default branch and checkout a new branch action:

docker_phx $ git checkout -b action
Switched to a new branch 'action'

The goal of this section is to publish your Docker image from GitHub to Docker Hub. If you are not familiar with Docker Hub, check out Publishing to Docker Hub in my previous post.

Open elixir.yml and add another job below test. We’ll label it publish.

  publish:
    runs-on: ubuntu-latest
    needs: test
    steps:
    - uses: actions/checkout@v2
    - name: Publish to DockerHub
      uses: elgohr/Publish-Docker-Github-Action@master
      with:
        name: staknine/docker_phx:${{ github.sha }}
        username: ${{ secrets.DOCKER_USERNAME }}
        password: ${{ secrets.DOCKER_PASSWORD }}

The publish job will use the Publish Docker GitHub Action.

A few things to note:

The following line configures the workflow to run publish only after a successful test job, see the jobs.<job_id>.needs documentation. Without this update, GitHub runs the jobs in parallel by default.

    needs: test

We’re going to update the Docker image tag to use the SHA of the git commit, ${{ github.sha }}. See all data available on the github context. With the commit SHA, we don’t have to update the project version with each change and we’ll use the commit SHA in the next step to deploy the update.

      with:
        name: staknine/docker_phx:${{ github.sha }}

Finally, we use GitHub secrets to create and store encrypted secrets to use in the workflow for our Docker Hub username and password.

        username: ${{ secrets.DOCKER_USERNAME }}
        password: ${{ secrets.DOCKER_PASSWORD }}

Follow the instructions for creating and storing encrypted secrets for DOCKER_USERNAME and DOCKER_PASSWORD.

Once you’ve stored your username and password:

  1. commit changes
  2. push your branch to origin
  3. open a pull request on GitHub

Now go to the "Actions" tab again to see the progress and results of your workflow. You should see your image has been tagged and pushed to your repository on Docker Hub.

GitHub Actions workflow

You can verify your image is published to your Docker Hub repository.

Docker Hub

Your image is published and ready for the next step!

Continuously Deploy Updated Release

The last step is to use the image you published on Docker Hub to deploy an update to your system.

We started with a Q&A we found on the Digital Ocean Community forum asking, How to deploy using GitHub Actions? The example provided in the answer uses the SSH Remote Commands and the SCP Command to Transfer Files GitHub Actions.

We initially thought we would need to copy the docker-compose.yml file from GitHub to our Droplet, but since the docker service update uses the entrypoint.sh file in the new image, we don’t think it is necessary for a regular update.

The next steps assume you have your code deployed and running on a Digital Ocean Droplet. If you need to deploy it again, visit the Setting Up the Droplet section of our previous post.

Add gh_actions SSH to GitHub and droplet

We need to add GitHub secrets to enable SSH for your GitHub repo. It is a good idea to create a new set of SSH keys for your GitHub Actions.

For security reasons, you can’t add or modify the SSH keys on your Droplet using the control panel after you create it, but Digital Ocean has steps to add SSH keys from your local computer using ssh-copy-id.

From your terminal:

$ ssh-keygen -t rsa -b 4096 -C "[email protected]"
> Generating public/private rsa key pair.

You want to save these in a new file location (replace "you" with your computer username):

> Enter a file in which to save the key (/Users/you/.ssh/id_rsa): /Users/you/.ssh/gh_actions_id_rsa
> Enter passphrase (empty for no passphrase): [Type a passphrase]
> Enter same passphrase again: [Type passphrase again]
Your identification has been saved in /Users/you/.ssh/gh_actions_id_rsa.
Your public key has been saved in /Users/you/.ssh/gh_actions_id_rsa.pub.

Now we need to add our gh_actions SSH public key into our Droplet’s list of authorized keys using ssh-copy-id:

$ ssh-copy-id -i ~/.ssh/gh_actions_id_rsa.pub root@<droplet public ip>
/usr/bin/ssh-copy-id: INFO: Source of key(s) to be installed: "/Users/you/.ssh/gh_actions_id_rsa.pub"
/usr/bin/ssh-copy-id: INFO: attempting to log in with the new key(s), to filter out any that are already installed
/usr/bin/ssh-copy-id: INFO: 1 key(s) remain to be installed -- if you are prompted now it is to install the new keys

Number of key(s) added:        1

Now try logging into the machine, with:   "ssh 'root@<droplet public ip'"
and check to make sure that only the key(s) you wanted were added.

Once again, follow the instructions for creating and storing encrypted secrets to add HOST, USERNAME, and SSHKEY to GitHub secrets. HOST should be the Droplet public IP. USERNAME should be root. SSHKEY should be the result of:

$ cat ~/.ssh/gh_actions_id_rsa
-----BEGIN OPENSSH PRIVATE KEY-----
REALLY_LONG_TEXT_STRING
-----END OPENSSH PRIVATE KEY-----

Add deploy step to elixir.yml

With SSH configured, we’re ready to add the deploy step to our elixir.yml file:

  deploy:
    runs-on: ubuntu-latest
    needs: [test, publish]
    steps:
    - uses: actions/checkout@v2
    - name: Deploy Update
      uses: appleboy/ssh-action@master
      with:
        host: ${{ secrets.HOST }}
        USERNAME: ${{ secrets.USERNAME }}
        KEY: ${{ secrets.SSHKEY }}
        script: docker service update --image username/docker_phx:${{ github.sha }} docker_phx_app

deploy will only run after both test and publish are successful:

    needs: [test, publish]

The final script will ssh into the Droplet and run docker service update using the image we published to Docker Hub. See additional info in the Deploying New Versions of the Application section of the Distillery guide.

script: docker service update --image username/docker_phx:${{ github.sha }} docker_phx_app

Note: we’re only updating the service running our app, so the final argument is docker_phx_app. You can see your docker services listed with:

root@droplet $ docker service ls
ID                  NAME                MODE                REPLICAS            IMAGE                        PORTS
khyilosyohjo        docker_phx_app      replicated          1/1                 username/docker_phx:0.1.0   *:80->4000/tcp
rppyatgvd30p        docker_phx_db       replicated          1/1                 postgres:10-alpine           *:5432->5432/tcp

With these updates:

  • commit your changes
  • push your actions branch changes to origin

The Elixir CI workflow should start automatically. You can monitor progress from the "Actions" tab. Your mix test, publish and deploy steps should all complete successfully.

Testing workflow with additional changes

We’re going to make two final updates before we merge our action branch to master.

First, we only want to deploy our updates when we merge and push to master so remove the following lines from elixir.yml:

  pull_request:
    branches: [ master ]

Then we want to see docker service update deploys the changes correctly, so make a small change to lib/docker_phx_web/templates/page/index.html.eex:

  <h1><%= gettext "Welcome to %{name}!", name: "Docker Phx" %></h1>

After you make those changes:

  • commit changes
  • push updated action branch to origin
  • checkout master
  • merge action branch to master
  • push master to origin

This time when you push your new commits to update the pull request, you will not start the GitHub Actions workflow. However, it will start after you merge action to master and push to origin.

Oops! The workfow starts and our mix test fails because the DockerPhxWeb.PageControllerTest asserted the homepage response would include "Welcome to Phoenix!" Our workflow stopped before publishing and deploying so our potentially broken code did not get deployed.

Failed GitHub Actions workflow

Update page_controllers_test.exs to:

assert html_response(conn, 200) =~ "Welcome to Docker Phx!"

Make sure your tests pass locally, then commit and push your change to master.

All steps in the workflow should pass!

Successful GitHub Actions workflow

When you visit the public IP address of your droplet, you should see the homepage in your Phoenix app updated with "Welcome to Docker Phx!"

Updated Elixir Phoenix Homepage

Conclusion

Congratulations, we’ve set up continuous deployment for Elixir with GitHub Actions! One additional update might be to set up another workflow to run the tests on each pull request (without the deploy step) so you don’t push bad code into master in the first place.

If you liked learning about continuous deployment, we have an entire book about managing your app in production. See below for how to get the Quick Reference Guide from the Phoenix Deployment Handbook for free.

Want To Know How To Deploy Phoenix Apps Using A single Command?

This brand-new FREE training reveals the most powerful new way to reduce your deployment time and skyrocket your productivity… and truly see your programming career explode off the charts!