I’ll detail how we’ve made a straightforward build and deploy process for our Phoenix setup at Nomad Rental. I found some blog posts that led us most of the way but thought it would be great to have an A-Z guide, that can be easily modified for your particular setup.
This blog post by Piotr Włodarek helped us a long way.
What do we want?
- Build and deploy locally and on CI
- Automatic deploys with master branch builds from CI
- Build inside a Docker container
- Clean builds from master branch on GitHub (no dev pollution)
- Encrypted production secrets
- Date versioned releases
- Manually rollback if necessary
Quick note
We’re using my_app
and MyApp
to reference our app. Replace with your actual app name. There’s a sample repo on GitHub.
Ansible
We’ll use ansible to build and deploy our release. It can also be used to provision your production server. The sample repo contains all the roles and tasks we’ll go through here.
Docker
First, we’ll set up a docker image for our builds. Make sure that you’ve docker installed already. The docker image should match your own production server setup.
If the docker image below already matches your production setup, then you can skip the following steps and just use our docker image (docker pull dreamconception/phoenix-build-elixir-ubuntu18
).
We’re using Ubuntu 18 on our production server, so we’ll add the following to .ansible/apps/build/Dockerfile
:
FROM ubuntu:18.04
WORKDIR /app
RUN apt-get update && apt-get install -y curl locales
# Set locale
RUN locale-gen en_US.UTF-8
ENV LANG=en_US.UTF-8
ENV LANGUAGE=en_US:en
ENV LC_ALL=en_US.UTF-8
ENV HOME=/root
ENV PATH="$HOME/.asdf/bin:$HOME/.asdf/shims:$PATH"
# Install dependencies
RUN apt-get update && \
apt-get install -y aptitude ca-certificates python python-dev python-pip \
python-virtualenv \
git \
nodejs \
automake autoconf libreadline-dev libncurses-dev libssl-dev libyaml-dev libxslt-dev libffi-dev libtool unixodbc-dev unzip && \
rm -rf /var/lib/apt/lists/*
# Install asdf
RUN git clone https://github.com/asdf-vm/asdf.git $HOME/.asdf --branch v0.7.2
# Install node js
RUN asdf plugin-add nodejs https://github.com/asdf-vm/asdf-nodejs.git && \
$HOME/.asdf/plugins/nodejs/bin/import-release-team-keyring && \
asdf install nodejs 11.8.0 && \
asdf global nodejs 11.8.0 && \
rm -rf /tmp/*
# Install erlang
RUN asdf plugin-add erlang https://github.com/asdf-vm/asdf-erlang.git && \
asdf install erlang 22.0.2 && \
asdf global erlang 22.0.2 && \
rm -rf /tmp/*
# Install elixir
RUN asdf plugin-add elixir https://github.com/asdf-vm/asdf-elixir.git && \
asdf install elixir 1.9.0 && \
asdf global elixir 1.9.0 && \
rm -rf /tmp/*
# Install hex and rebar
RUN mix local.hex --force
RUN mix local.rebar --force
# Set up ansible
RUN apt-get update && \
apt-get install -y software-properties-common && \
apt-add-repository ppa:ansible/ansible && \
apt-get update && \
apt-get install -y ansible
We’ll build and upload the docker image to Docker Hub. You should sign in and set up your organization if you don’t have one already, and create a repository.
Create .ansible/apps/build/update-docker-image.yml
with:
---
- hosts: 127.0.0.1
connection: local
gather_facts: no
tasks:
- name: Build and upload docker image
docker_image:
path: ./
name: YOUR_ORG/phoenix-build-elixir-ubuntu18
repository: YOUR_ORG/phoenix-build-elixir-ubuntu18
tag: 1.9.0
pull: yes
push: yes
force: yes
Remember to replace YOUR_ORG
with your organization name. Now you can run the following commando in .ansible/
to build and push your docker image:
ansible-playbook apps/build/update-docker-image.yml -vvv
We’re ready to build the app!
Mix release
We’ll use the build date and time for the build version in the form of 0.1.0-2019.6.12.4.55
. Leading zeros are removed from the timestamp since mix release
requires valid SemVer. Update mix.exs
so the build :version
can be set as an environment variable. We’ll also set the :releases
option here:
defmodule MyApp.MixProject do
use Mix.Project
def project do
[
app: :my_app,
version: System.get_env("BUILD_VERSION") || "0.1.0",
elixir: "~> 1.9",
elixirc_paths: elixirc_paths(Mix.env()),
compilers: [:phoenix, :gettext] ++ Mix.compilers(),
start_permanent: Mix.env() == :prod,
aliases: aliases(),
deps: deps(),
releases: [
my_app: [
include_executables_for: [:unix],
applications: [runtime_tools: :permanent]
],
]
]
end
# ...
end
Local build
Create .ansible/apps/build/inventory
and use your docker container as the ansible_host
:
---
all:
hosts:
build_server
vars:
ansible_connection: docker
ansible_host: my_app_build_server
build_dir: "/app/build"
We’ll need a vault secret for encrypting our secrets. Run the following command:
openssl rand -base64 280 > .ansible/.vault_pass.txt
Remember to add .vault_pass.txt
to .ansible/.gitignore
!
Add the following to .ansible/ansible.cfg
:
[defaults]
vault_password_file = ./.vault_pass.txt
roles_path = ./.downloaded_roles:./roles
pipelining = False
callback_whitelist = profile_tasks
Create the build playbook .ansible/apps/build/build.yml
with the following:
---
- hosts: all
gather_facts: no
vars:
local_build_dir: "/tmp/my_app_build"
git_repo: "GIT_REPO"
production_vars_file: "../production/host_vars/MY_HOST"
pre_tasks:
- command: date +"0.1.0-%Y.%-m.%-d.%-H.%-M"
delegate_to: localhost
register: build_version_cmd
- set_fact:
build_version: "{{ build_version_cmd.stdout }}"
- name: Checkout the master branch from git repo
delegate_to: localhost
git:
repo: "{{ git_repo }}"
dest: "{{ local_build_dir }}"
version: master
force: yes
when: ansible_connection == "docker"
roles:
- role: docker_setup/0.0.1
vars:
container_name: "{{ ansible_host }}"
image_name: YOUR_ORG/phoenix-build-elixir-ubuntu18:1.9.0
when: ansible_connection == "docker"
- role: build_app/0.0.1
vars:
mix_env: prod
app_name: my_app
Replace GIT_REPO
with your git repo url, MY_HOST
to the domain name of your production website, and YOUR_ORG
with your docker hub organization name (or dreamconception
if you skipped creating the docker image).
Add the following scripts:
.ansible/roles/docker_setup/0.0.1/tasks/main.yml
:
---
- name: Start docker container
delegate_to: localhost
docker_container:
name: "{{ container_name }}"
image: "{{ image_name }}"
volumes:
- "{{ local_build_dir }}:{{ build_dir }}"
env:
SSH_AUTH_SOCK: /ssh-agent
auto_remove: yes
detach: yes
interactive: yes
tty: yes
.ansible/roles/build_app/0.0.1/tasks/main.yml
:
---
- name: Load production vars
include_vars: "{{ production_vars_file }}"
- name: Create secrets
template:
src: prod.secret.exs.j2
dest: "{{ build_dir }}/config/prod.secret.exs"
mode: 0644
- name: Fetch mix dependencies
command: bash -lc "mix deps.get" chdir="{{ build_dir }}"
environment:
MIX_ENV: "{{ mix_env }}"
- name: Fetch npm dependencies
command: bash -lc "cd {{ build_dir }}/assets && npm install"
environment:
MIX_ENV: "{{ mix_env }}"
- name: Build assets
command: bash -lc "cd {{ build_dir }}/assets && npm run deploy"
environment:
MIX_ENV: "{{ mix_env }}"
- name: Digest assets
command: bash -lc "mix phx.digest" chdir="{{ build_dir }}"
environment:
MIX_ENV: "{{ mix_env }}"
- name: Remove previous build
file:
name: "{{ build_dir }}/_build/{{ mix_env }}/rel/{{ app_name }}"
state: absent
- name: "Releasing {{ build_version }}"
command: bash -lc "mix release" chdir="{{ build_dir }}"
environment:
MIX_ENV: "{{ mix_env }}"
BUILD_VERSION: "{{ build_version }}"
- name: Adding BUILD_VERSION file with "{{ build_version }}"
copy:
content: "{{ build_version }}"
dest: "{{ build_dir }}/_build/{{ mix_env }}/rel/{{ app_name }}/BUILD_VERSION"
- name: Get GIT version
command: git rev-parse HEAD
args:
chdir: "{{ build_dir }}"
register: git_result
- name: Adding COMMIT_HASH file with "{{ git_result.stdout }}"
copy:
content: "{{ git_result.stdout }}"
dest: "{{ build_dir }}/_build/{{ mix_env }}/rel/{{ app_name }}/COMMIT_HASH"
Add the following file (you can also copy your current prod.secret.exs
and replace the secrets with literals yourself):
.ansible/roles/build_app/0.0.1/templates/prod.secret.exs.j2
:
use Mix.Config
# In this file, we keep production configuration that
# you'll likely want to automate and keep away from
# your version control system.
#
# You should document the content of this
# file or create a script for recreating it, since it's
# kept out of version control and might be hard to recover
# or recreate for your teammates (or yourself later on).
config :my_app, MyAppWeb.Endpoint,
secret_key_base: "{{ secret_key_base }}"
# Configure your database
config :my_app, MyApp.Repo,
adapter: Ecto.Adapters.Postgres,
username: "{{ database_username }}",
password: "{{ database_password }}",
database: "my_app",
pool_size: 15
Now we’ll add the secrets.
Create .ansible/apps/production/host_vars/MY_HOST
, using the domain of your production website instead of MY_HOST
as filename. Add the encrypted secrets one by one by running:
ansible-vault encrypt_string --vault-password-file ".ansible/.vault_pass.txt" --stdin-name "NAME"
Replace NAME
in the above command with the variable name, e.g. secret_key_base
. You should hit ctrl-d twice (not enter) to encrypt the secret. Paste them into your MY_HOST
file so it looks similar to this:
---
database_username: "my_app"
database_name: "my_app"
secret_key_base: !vault |
$ANSIBLE_VAULT;1.1;AES256
33306165616163393530303239656263393439663134613565663930613339346664626538373062
6438653532646332366339353839616162353765353266380a623432306235346364393433613038
64333536643738366361666535666136346334303335636166326666643939616335653131396134
3565306266613561320a306234343836313339663331626264333336633737633837663735663732
34373564616163616238663336313234393638633230383831363564633162353861616261366632
38376331663565633562366566303264616333383138623665383362326363373534383732396538
65313865653936653764663330383334323633313035363966636431316263343733306237663564
36656135333438656438
database_password: !vault |
$ANSIBLE_VAULT;1.1;AES256
66333166303832313166666632373539316139383232396266613130366265383165653431623661
3136646563646131323237353037636666303433336538350a643334623532623539323461326134
65623231633335356565393165353761366633646566633435613961346635316131356233373530
3063333730636337350a653937376363343561356166613538626439616137653830313065386330
3534
That’s it!
You’ll be able to do a local build by running the following in the .ansible
subdirectory:
ansible-playbook -i apps/build/inventory apps/build/build.yml -vvv
To make sure that Phoenix can start in your build, uncomment this line in config/prod.exs
:
config :phoenix, :serve_endpoints, true
CI builds
We’ll use CircleCI in this tutorial, but you can replace it with whatever CI you prefer. Just keep in mind that I’ll let CircleCI test and build inside the docker container we created earlier.
Copy the content from .ansible/.vault_pass.txt
and use it for the VAULT_PASS
environment value in your CI. With CircleCI I had to base64 encode the string first, as they don’t accept multiline strings. Hence why we decode the environment variable in the following CircleCI config.
Add the following to .circleci/config.yml
:
# Elixir CircleCI 2.0 configuration file
#
# Check https://circleci.com/docs/2.0/language-elixir/ for more details
version: 2
jobs:
build:
docker:
- image: YOUR_ORG/phoenix-build-elixir-ubuntu18:1.9.0
- image: circleci/postgres:11
environment:
MIX_ENV: test
working_directory: ~/repo
steps:
- checkout
- restore_cache:
keys:
- deps-build
# Test
- run: mix deps.get
- run: mix ecto.create
- run: mix ecto.migrate
- run: mix test
# Build
- run: cd ~/repo && git reset --hard && git clean -dfx
- run: echo "$VAULT_PASS" | base64 -d > ~/repo/.ansible/.vault_pass.txt
- run: cd .ansible && ansible-playbook -i apps/build/inventory-ci apps/build/build.yml -vvv
- save_cache:
key: deps-build
paths:
- ~/repo/_build
- ~/repo/deps
Replace YOUR_ORG
with your organization Docker Hub, or you can also just use dreamconception/phoenix-build-elixir-ubuntu18:1.9.0
as the image
. If you use MySQL instead of Postgres, you should replace circleci/postgres:11
with circleci/mysql:8
.
Create .ansible/apps/build/inventory-ci
:
---
all:
hosts:
build_server
vars:
ansible_connection: local
ansible_host: 127.0.0.1
build_dir: ~/repo
Now every push will be automatically build on our CI server!
Deployment
Now that we have a full build setup ready, it’s time for deployment!
First, we’ll need to add a migration task. Add lib/release.ex
with:
defmodule MyApp.Release do
@app :my_app
def migrate do
for repo <- repos() do
{:ok, _, _} = Ecto.Migrator.with_repo(repo, &Ecto.Migrator.run(&1, :up, all: true))
end
end
def rollback(repo, version) do
{:ok, _, _} = Ecto.Migrator.with_repo(repo, &Ecto.Migrator.run(&1, :down, to: version))
end
defp repos do
Application.load(@app)
Application.fetch_env!(@app, :ecto_repos)
end
end
I’ll not dwell on the server setup, but I expect you have the following set up on your production server:
- A
deploy
user - SSH pub key on
deploy
accessible by you - SSH pub key on
deploy
accessible by CI /u/apps/my_app/releases
directory- The
deploy
user has aPORT
environment variable set, or you’ve set the port specifically inconfig/prod.exs
We’ll create a systemd service file on our server so we can easily restart the app after deploy. Add the following to /etc/systemd/system/my_app.service
on your server (replace postgres.service
with mysql.service
if necessary):
[Unit]
Description=Server for my_app
Wants=postgres.service
After=postgres.service
After=syslog.target
After=network.target
[Service]
Type=simple
User=deploy
Group=deploy
WorkingDirectory=/u/apps/my_app/current
ExecStart=/u/apps/my_app/current/bin/my_app start
KillMode=process
Restart=on-failure
SuccessExitStatus=143
TimeoutSec=10
RestartSec=5
SyslogIdentifier=my_app
[Install]
WantedBy=multi-user.target
Update .ansible/apps/production/host_vars/MY_HOST
, and append the following:
releases_dir: "/u/apps/my_app/releases"
current_dir: "/u/apps/my_app/current"
releases_to_keep: 10
Create .ansible/apps/production/inventory
, and replace MY_HOST
with the domain name for your production website:
---
all:
hosts:
MY_HOST
vars:
local_build_dir: /tmp/my_app
ansible_user: deploy
Add .ansible/apps/production/deploy.yml
:
---
- hosts: all
gather_facts: no
vars:
mix_env: prod
local_release_dir: "{{ local_build_dir }}/_build/{{ mix_env }}/rel/my_app"
build_version: "{{ lookup('file', local_build_dir + '/_build/{{ mix_env }}/rel/my_app/BUILD_VERSION') }}"
commit_hash: "{{ lookup('file', local_build_dir + '/_build/{{ mix_env }}/rel/my_app/COMMIT_HASH') }}"
pre_tasks:
- name: Get git version
delegate_to: localhost
become: false
shell: "git rev-parse master {{ local_build_dir }}"
register: git_version_result
- name: Check for newest build
delegate_to: localhost
fail:
msg: "Latest GIT commit of {{ git_version_result.stdout_lines[0] }} does not match build version of {{ commit_hash }}. Please build a new release."
when: commit_hash != git_version_result.stdout_lines[0]
roles:
- role: deploy_app/0.0.1
vars:
app_name: my_app
username: "{{ ansible_user }}"
As you can see, we use the COMMIT_HASH
file to ensure that the build version contains the latest git commit.
Add .ansible/roles/deploy_app/0.0.1/tasks/main.yml
:
---
- name: Check for existing current directory
stat:
path: "{{ current_dir }}"
register: current_dir_stat
- name: Check for existing release directory
stat:
path: "{{ releases_dir }}/{{ build_version }}"
register: release_dir_stat
- name: Copy previous release (make faster release deploys)
command: "cp -r -L {{ current_dir }}/ {{ releases_dir }}/{{ build_version }}"
when: current_dir_stat.stat.exists and not release_dir_stat.stat.exists
- name: "Upload new {{ build_version }} release"
synchronize:
src: "{{ local_release_dir }}/"
dest: "{{ releases_dir }}/{{ build_version }}"
recursive: yes
delete: yes
# Here you should symlink shared directory in case you have uploaded files, etc.
- name: Run migrations
command: bash -lc "bin/{{ app_name }} eval MyApp.Release.migrate" chdir="{{ releases_dir }}/{{ build_version }}"
- name: Update current symlink
file:
dest: "{{ current_dir }}"
src: "{{ releases_dir }}/{{ build_version }}"
state: link
force: yes
notify:
- "restart app"
- name: List all releases
shell: "ls -t {{ releases_dir }} | tail -n +{{ releases_to_keep + 1 }}"
register: ls_output
- name: Remove old releases
file:
name: "{{ releases_dir }}/{{ item }}"
state: absent
with_items: "{{ ls_output.stdout_lines }}"
Create .ansible/roles/deploy_app/0.0.1/handlers/main.yml
:
---
- name: "restart app"
raw: "sudo /bin/systemctl restart {{ app_name }}"
Note: The deploy
user should be allowed to run the /bin/systmectl restart my_app
without password.
Deploy from your machine
You’ll be able to do a deploy from your local environment now, by running the following in .ansible/
:
ansible-playbook -i apps/production/inventory apps/production/deploy.yml -vvv
Deploy from CI
Now let’s automate the deploys.
Create .ansible/apps/production/inventory-ci
, and replace MY_HOST
with the domain name for your production website:
---
all:
hosts:
MY_HOST
vars:
local_build_dir: ~/repo
ansible_ssh_extra_args: "-o StrictHostKeyChecking=no"
ansible_user: deploy
Add the following deploy
step to your .circleci/config.yml
:
jobs:
build:
# ...
steps:
# ...
# Install rsync for deploy
- run: apt-get install rsync -y
- deploy:
name: "Deploy master to production"
command: |
if [ "${CIRCLE_BRANCH}" == "master" ]; then
cd .ansible && ansible-playbook -i apps/production/inventory-ci production/deploy.yml -vvv;
fi
CircleCI will deploy after successful test and build of any push on the master
branch.
Congratulations, you got yourself an automated build and deploy setup for your Phoenix app. Happy coding!