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:
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".
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.
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:
- commit changes
- push your branch to origin
- 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.
You can verify your image is published to your Docker Hub repository.
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 tomaster
- 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.
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!
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!"
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.