Continuously Deploy Phoenix to Digital Ocean with GitHub Actions
Once you Deploy Phoenix and Postgres to Digital Ocean with Docker, the next step is to eliminate the manual deployment steps and establish a continuous integration / continuous deployment (CI/CD) pipeline. 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.
There are many CI/CD services available, but we are going to use GitHub Actions. The goal is to run the 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 project deployed to Digital Ocean using the steps in my previous post, Deploy Phoenix and Postgres to Digtial Ocean with Docker. Once you finish the steps in the post, your app should be up and running on your Droplet.
Overview
Our GitHub Action Workflow will include the following steps:
If you didn't make it all the way through the last post, you can start by cloning from the Update docker-compose.yml for Digital Ocean deploy commit in my axelclark/docker_phx project. If you just want to follow along, I've made a commit for each section below.
Mix Test
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 master 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 Elixir template, the main change is to add the database.
I used the Phoenix example from the Setup Elixir template and the Continuous Integration GitHub Action elixir.yml file from the Elixir Companies website project on GitHub.
Here is the updated elixir.yml
file, I'll discuss the details of each section below.
name: Elixir CI
on:
push:
branches: [ master ]
pull_request:
branches: [ master ]
jobs:
test:
runs-on: ubuntu-latest
name: mix test
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
- uses: actions/setup-elixir@v1.2.0
with:
elixir-version: 1.9.4 # Define the elixir version [required]
otp-version: 22.2 # Define the OTP version [required]
- name: Install Dependencies
run: mix deps.get
- name: Run Tests
run: mix test
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 test
on the latest ubuntu. Before we get to the Elixir setup, we also need to set up our Postgres service.
test:
runs-on: ubuntu-latest
name: mix test
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 Elixir Setup to prepare the environment then call mix deps.get
and mix test
to run the test suite.
steps:
- uses: actions/checkout@v2
- uses: actions/setup-elixir@v1.2.0
with:
elixir-version: 1.9.4 # Define the elixir version [required]
otp-version: 22.2 # Define the OTP version [required]
- name: Install Dependencies
run: mix deps.get
- name: Run Tests
run: mix test
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.
You may see some deprecation warnings, but the workflow exits with success after the tests all pass.
Build and Publish to Docker Hub
To begin this section, git pull
the changes to your local machine and checkout a new branch action
:
docker_phx $ git pull
remote: Enumerating objects: 6, done.
remote: Counting objects: 100% (6/6), done.
remote: Compressing objects: 100% (3/3), done.
remote: Total 5 (delta 1), reused 0 (delta 0), pack-reused 0
Unpacking objects: 100% (5/5), done.
From github.com:axelclark/docker_phx
a04ce03..edd85a4 master -> origin/master
Updating a04ce03..edd85a4
Fast-forward
.github/workflows/elixir.yml | 35 +++++++++++++++++++++++++++++++++++
1 file changed, 35 insertions(+)
create mode 100644 .github/workflows/elixir.yml
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: axelclark/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: axelclark/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!
Deploy Updated Release
The last step is to use the image you published on Docker Hub to deploy an update to your system.
I started with a Q&A I 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.
I initially thought I would need to copy the docker-compose.yml
file from GitHub to my Droplet, but since the docker service update uses the entrypoint.sh
file in the new image, I 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 my previous post.
We're going to add the deploy
step to our elixir.yml
file:
deploy:
runs-on: ubuntu-latest
needs: [test, publish]
steps:
- uses: actions/checkout@v2
- name: Executing remote command
uses: appleboy/ssh-action@master
with:
host: ${{ secrets.HOST }}
USERNAME: ${{ secrets.USERNAME }}
KEY: ${{ secrets.SSHKEY }}
script: docker service update --image axelclark/docker_phx:${{ github.sha }} docker_phx_app
deploy
will only run after both test
and publish
are successful:
needs: [test, publish]
We need to add GitHub secrets to enable SSH. 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 "your_email@example.com"
> Generating public/private rsa key pair.
You want to save these in a new file location (replace "you" with your 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-----
Now that SSH is configured, we'll ssh into the Droplet and run the docker service update
script 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 axelclark/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 axelclark/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.
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 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 and when you visit the public IP address of your droplet, you should see the homepage updated with "Welcome to Docker Phx!"
Congratulations, we've set up continuous deployment on Digital Ocean with GitHub Actions and Docker!
One additional update might be to set up another workflow to run the tests on each pull request so you don't push bad code into master
in the first place.
This was my first time working with GitHub Actions, Docker and Digital Ocean, so I welcome any of your suggested improvements to this guide. Thanks!