Deploying Phoenix Apps to Production
Phoenix is one of my favorite web application frameworks. There are some excellent platforms for deploying Phoenix apps to production, including Fly and Gigalixir. But there are times when you need to deploy your app to a VM. Here’s my recipe for deploying a Phoenix app to production and doing continuous deployment with Github Actions.
Packaging the Code
Elixir Releases make it trivial to package the code and ship to production. Phoenix has great documentation on deploying with releases.
All you have to do here is execute
mix phx.gen.release
which will generate some files to prepare your application for release. The actual release will be done with Github Actions, so this is all we need here.
Github Actions
We take an Ubuntu machine and build the Elixir release on it.
name: Elixir CI
on:
push:
branches: [ main ]
jobs:
build:
name: Build and Deploy
runs-on: ubuntu-latest
env:
MIX_ENV: prod
steps:
- uses: actions/checkout@v2
- name: Set up Elixir
uses: erlef/setup-elixir@885971a72ed1f9240973bd92ab57af8c1aa68f24
with:
elixir-version: '1.12.3' # Define the elixir version [required]
otp-version: '24.1' # 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: NPM
run: cd assets && npm install
- name: Deploy assets
run: mix assets.deploy
- name: Create Release
run: mix release
Now we’ll make some configurations on the server before transferring the release.
Server Setup
The first thing to do is to setup a unix user with sudo permission and disable root login. We then setup the web server and application server.
I like to use Nginx as the web server that forwards the requests to the Cowboy server running locally. Here’s the Nginx configuration,
upstream phoenix {
server 127.0.0.1:4000 max_fails=5 fail_timeout=60s;
}
server {
server_name <example.com>;
location / {
allow all;
# Proxy headers
proxy_http_version 1.1;
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
proxy_set_header X-Forwarded-Proto $scheme;
proxy_set_header Host $http_host;
proxy_set_header X-Cluster-Client-Ip $remote_addr;
# The Important Websocket Bits!
proxy_set_header Upgrade $http_upgrade;
proxy_set_header Connection "upgrade";
proxy_pass http://phoenix;
}
}
With this, Nginx will forward the requests to the Cowboy server listening on port 4000. Its also important to add SSL, which is super easy to do with Let’s Encrypt.
Phoenix App
I use systemd to run my phoenix app, which makes it run in a managed fashion. Here’s my
systemd config, /etc/systemd/system/<app_name>.service
[Unit]
Description=<App Description>
[Service]
Environment=PHX_SERVER=true
Environment=PHX_HOST=<host>
Environment=PORT=4000
Environment=SECRET_KEY_BASE=<secret_key>
Environment=DATABASE_URL=<db_url>
User=<unix_user>
ExecStart=/home/<unix_user>/_build/prod/rel/<app_name>/bin/<app_name> start
[Install]
WantedBy=default.target
It’s important to note that we have to provide all the environment variables needed for our Phoenix app in the service file itself.
Deployment
Now we can transfer the release built in our Github action to the server. We’ll create an
SSH key pair, add the public key to the authorized_keys
on the server, and store the private
key in Github secrets.
We’ll also need to allow our user to be able to start and stop the service from Github Actions.
For this, we’ll need to create a file /etc/sudoers.d/<app_name>
%<unix_user> ALL= NOPASSWD: /bin/systemctl start <app_name>.service
%<unix_user> ALL= NOPASSWD: /bin/systemctl stop <app_name>.service
%<unix_user> ALL= NOPASSWD: /bin/systemctl restart <app_name>.service
Also, we’ll need to add our Phoenix app environment variables to the file /etc/environment
PHX_SERVER=true
PHX_HOST=<host>
PORT=4000
SECRET_KEY_BASE=<secret_key>
DATABASE_URL=<db_url>
With this, we can now update our Github Action to deploy the release to our server. Add the following steps to the github action file,
- name: Copy files to server
uses: appleboy/scp-action@master
with:
host: ${{ secrets.PROD_HOST }}
username: ${{ secrets.PROD_USERNAME }}
key: ${{ secrets.PROD_SSH_KEY }}
source: "_build/prod/rel/<app_name>"
target: "~/"
- name: Run migrations
uses: appleboy/ssh-action@master
with:
host: ${{ secrets.PROD_HOST }}
username: ${{ secrets.PROD_USERNAME }}
key: ${{ secrets.PROD_SSH_KEY }}
script: ~/_build/prod/rel/<app_name>/bin/<app_name> eval "<AppName>.Release.migrate"
- name: Restart Server
uses: appleboy/ssh-action@master
with:
host: ${{ secrets.PROD_HOST }}
username: ${{ secrets.PROD_USERNAME }}
key: ${{ secrets.PROD_SSH_KEY }}
script: sudo systemctl restart <app_name>.service
And that’s it. With this, every time we push the code to the main branch, it is packaged into a release, transferred to our server and deployed.