This post will guide you through automating a "bare-metal" machine configuration, and getting a server ready for building and deploying Elixir / Phoenix applications (with LiveView support!)
There comes a time in every application’s life when, as a developer, you want to share it with the world. There are some easy solutions out there for deploying Elixir, most notably Gigalixir or Heroku. However, when something goes wrong you may want to solve the issue yourself, not get on long support calls or email chains. Knowledge is power, and understanding all the moving pieces of deploying to “bare metal” gives you a powerful skill-set that will allow you to deliver more customized solutions, without the black-box limitations of Platform-as-a-Service software.
In a previous post, I demonstrated how we can automate the creation of infrastructure. This guide will build on that idea.
You can follow along using the companion repository on Github.
Target Audience - Who Is This Post For
This post is targeted towards Elixir Developers interested in deploying their application onto a Linux box on a cloud provider of their choice. Automating this process shortens iteration cycles so that the process is easily repeatable. This makes scaling, replicating, and fixing issues a breeze. In the process I hope to demystify "DevOps" and empower developers to be more comfortable with Linux, Nginx and automation tools like Ansible.
We are going to set up a machine with automatically renewable SSL certificates from Let's Encrypt and unattended security upgrades so that maintenance is kept to a bare minimum. In most cases, there shouldn't be a need to SSH into it once everything is up and running.
Prerequisites - Tools We Will Use
In this post we will set up a Debian based machine, in this case Ubuntu, to host our Elixir application, so a prerequisite will be creating one on a cloud platform such as Digital Ocean. I've used a $5/month machine with 1 CPU and 1GB RAM, and it is very capable and sufficient for running a Phoenix application. See my previous post if you want to quickly spin one up.
I recommend setting up your SSH Config file to point to the IP address or domain of the machine we will be deploying to:
Host example
User root
HostName example.com # or IP address
IdentityFile ~/.ssh/your_ssh_key
Throughout this post I've used
example
andexample.com
as the project name and domain respectively. You will need to make sure to replace those for filenames and other places where the wordexample
is being used.
You will need root access to set up many of the pieces in this tutorial. For simplicity purposes I am assuming you already have access to the root
user.
We will use Ansible, a Configuration Management tool, to set up the machine, install dependencies etc.
Then to deliver a release of our application to the server, we will use eDeliver with Distillery. While it is possible to use Ansible to deliver the code, I believe in using the best tool for the job. Ansible is great for configuring a machine, but eDeliver and Distillery are more well suited for Elixir / Erlang's specific build and delivery requirements.
With all of that out of the way, let's get started!
Setting Up Our Ansible Project
First we will need to create a directory for our Ansible project. In my project I placed it under ansible/
.
You will need a configuration file for Ansible. For now we will not get too deep into how the Ansible configuration works, you can read more about that here, instead we will use some sensible defaults.
Feel free to copy mine and save it into ansible/ansible.cfg
:
[defaults]
nocows=1
inventory = inventories/production
log_path = /tmp/ansible.log
retry_files_enabled = True
retry_files_save_path = tmp
roles_path = galaxy_roles:roles
callback_whitelist = timer, profile_tasks
stdout_callback = skippy
gathering = smart
[ssh_connection]
ssh_args = -o ForwardAgent=yes -o ControlMaster=auto -o ControlPersist=30m
pipelining = True
control_path = /tmp/ansible-ssh-%%h-%%p-%%r
You will need to create a few directories inside the ansible
directory to keep things organized:
$ mkdir -p inventories/group_vars/{all,application/secret} playbooks/templates tmp
This will create a basic directory structure:
❯ tree
.
├── inventories
│ └── group_vars
│ ├── all
│ └── application
│ └── secret
├── playbooks
│ └── templates
└── tmp
NOTE: If you are using git to store your project, I suggest adding ansible/tmp
to your .gitignore
since Ansible will store some 'retry' files there:
$ echo 'tmp/' >> .gitignore
Next we need to create a main.yml
file, this will be our entry point for the script that will import all of the smaller playbooks and execute them in order.
ansible/main.yml
:
---
- hosts: application
remote_user: root
We have set the hosts
to application
- that is our target group - a group of hosts that we will run the tasks on. We will discuss how to set up target groups in the next section.
Inventory
Ansible needs to know about all the machines it will be targeting. It keeps track of these machines in an inventory. For this project, we have one environment, production, and one machine in that environment example
.
Let's create our production inventory in ansible/inventories/production
:
[application]
example
We are using the host example
which should match the SSH Config entry we added in a previous step, and putting it under the application
group.
Configuring Machine Login
In this step we will setup a deploy
user that will build, deploy and run our application. In addition, we will implement a few hardening steps to ensure your machine's SSH Server is more secure from brute-force attacks.
Many worms, scanners, and botnets scan the entire Internet looking for SSH logins, so it's always a good idea to reduce the risk by disabling password authentication over SSH and using a proactive log analyzer such as Fail2Ban.
First we need to create some variables to make things easy to refactor and
maintain. In your ansible/inventories/group_vars/all
directory, create a new file
ansible/inventories/group_vars/all/all.yml
:
---
username: deploy
app_name: example
domain: example.com
These variables will be shared across all your Playbooks (and feel free to change them according to your needs. e.g. There is no requirement for the user to be called deploy
).
Next, create a new Playbook and add some tasks in ansible/playbooks/configure-login.yml
:
---
- hosts: application
remote_user: root
tasks:
- name: Create Deploy User
user:
name: '{{ username }}'
createhome: yes
state: present
shell: /bin/bash
register: deployuser
- name: Disable password for deploy on creation
# this will set the password to something untypable and random essentialy
# preventing password login for this user
shell: /usr/bin/passwd -l '{{ username }}'
# this line tells Ansible to only run this task if the deployuser we
# defined above has changed
when: deployuser.changed
- name: Deploy SSH Key
authorized_key:
user: '{{ username }}'
# you would need to change this line to point to your public key
key: "{{ lookup('file', '~/.ssh/your_ssh_key.pub') }}"
state: present
- name: Disable Password Authentication
lineinfile:
# completely disables password authentication for ssh, so make sure your
# root user is set up to connect with a key, not a password!
dest: /etc/ssh/sshd_config
regexp: '^PasswordAuthentication'
line: "PasswordAuthentication no"
state: present
backup: yes
notify: restart ssh
handlers:
- name: restart ssh
service:
name: sshd
state: restarted
A really cool feature of Ansible is its ability to replace a line in a file (see lineinfile
above under "Disable Password Authentication"
). It allows you to run a sed
-like command to search with regex, and replace a line, but will only run if the line is not already present. You can also pass it backup: yes
to create a backup file, just in case the replacement did not go as planned.
Also notice the handlers section. You can think of those as functions that are reusable throughout your Playbook. In this case we created one for restarting the sshd
service after making changes to its configuration file, and we call it in the notify
action of the "Disable Password Authentication"
task.
Notify actions will only be triggered once even if notified by multiple different tasks.
Finally, we'll import that playbook in our ansible/main.yml
file:
---
- hosts: application
remote_user: root
- name: Configure Machine Login
import_playbook: playbooks/configure-login.yml
To test everything, run:
$ ansible-playbook main.yml
If everything worked you should be able to log into the machine using our newly created user like so:
$ ssh deploy@example -i ~/.ssh/your_ssh_key
Install Packages
Our next step is to install some packages from the operating system package manager, in this case apt
on Ubuntu. Some of those are optional, so feel free to drop them, and depending on your use case you may want to add more. Ansible comes with built-in support for apt
so installing packages is a breeze.
We will also utilize Ansible Roles, more specifically Galaxy Roles. You can think of Roles as packages / dependencies; they are groupings of Ansible vars
, tasks
and handlers
. Ansible Galaxy is Ansible's package repository where you can find many different roles for automating common complex tasks.
First, let's create our new Playbook in ansible/playbooks/install-packages.yml
:
---
- hosts: application
vars:
- packages:
# Scans system access logs and bans IPs that show malicious signs
- fail2ban
# For building with eDeliver
- git
# For compiling assets using webpack
- nodejs
- npm
# For reverse proxy into our application
- nginx
remote_user: root
tasks:
- name: Update APT package cache
apt:
update_cache: yes
cache_valid_time: 3600
- name: Install required packages
apt:
state: present
pkg: "{{ packages }}"
- name: Check if Erlang is Installed
command: dpkg-query -W esl-erlang
register: erlang_check_deb
failed_when: erlang_check_deb.rc > 1
changed_when: erlang_check_deb.rc == 1
- name: Download erlang.deb
get_url:
url: "https://packages.erlang-solutions.com/erlang-solutions_1.0_all.deb"
dest: "/home/{{ username }}/erlang-solutions_1.0_all.deb"
when: erlang_check_deb.rc == 1
- name: Install erlang dpk src
apt:
deb: "/home/{{ username }}/erlang-solutions_1.0_all.deb"
when: erlang_check_deb.rc == 1
- name: Install erlang and elixir
apt:
update_cache: yes
state: present
pkg:
- esl-erlang
- elixir
when: erlang_check_deb.rc == 1
- name: Install Hex
command: mix local.hex --force
changed_when: >
"Will always run, don't show that it changed" == 1
roles:
- role: jnv.unattended-upgrades
unattended_origins_patterns:
- 'origin=Ubuntu,archive=${distro_codename}-security'
unattended_automatic_reboot: true
unattended_automatic_reboot_time: '09:00'
unattended_mail: "{{ admin_email }}"
Most of the steps above are documented in their name, but generally what this Playbook will do for us is update the apt
database, and install some packages, including Erlang and Elixir. Some of those packages are optional, so you should examine the list and modify it according to your needs.
To make it work we need to add a variable to our ansible/inventories/group_vars/all/all.yml
file that we defined earlier, specifically the admin_email
variable:
---
username: deploy
app_name: example
domain: example.com
admin_email: admin@example.com
If you are going to commit this to a public repository, you may not want to expose your email in clear text. In a future step we will look at how we can utilize Ansible Vault to store variables such as this one in an encrypted file.
We will also need to install the Galaxy Role we referenced at the bottom of the file jnv.unattended-upgrades. In your Ansible folder run:
$ ansible-galaxy install jnv.unattended-upgrades
As always when using a dependency of this nature, skim through the code to give yourself confidence that the code isn't doing something unsafe.
This role will install the UnattendedUpgrades package which will keep your server up to date with security updates.
Notice that we have enabled unattended_automatic_reboot
which will reboot the machine at 9am UTC if any of the security updates installed requires a restart. If automated restarts are not acceptable in your case, you may want to remove the two related configurations, and restart manually.
Let's add the Playbook to our ansible/main.yml
file:
---
- hosts: application
remote_user: root
- name: Configure Machine Login
import_playbook: playbooks/configure-login.yml
- name: Install Packages
import_playbook: playbooks/install-packages.yml
Now re-run Ansible:
$ ansible-playbook main.yml
You'll notice that the steps we did in the configure-login.yml
Playbook have not been re-run again since Ansible is idempotent and is able to determine those changes have already been done.
Setting Up Application Deployment Considerations
In this section we will prepare the server for deployment using eDeliver and Distillery (which we will cover in the next post).
We'll start by creating a new Playbook in ansible/playbooks/application-deployment-setup.yml
:
---
- hosts: application
remote_user: root
tasks:
- name: Create .env file
template:
src: "{{ app_name }}.env"
dest: "/home/{{ username }}/{{ app_name }}.env"
owner: "{{ username }}"
group: "{{ username }}"
- name: Source .env file in user profile
lineinfile:
dest: '/home/{{ username }}/.profile'
regexp: '^\. "$HOME/{{ app_name }}.env"'
line: '. "$HOME/{{ app_name }}.env"'
state: present
backup: yes
- name: Ensures shared/config dir exists
file:
path: "/home/{{ username }}/app_config"
state: directory
owner: "{{ username }}"
group: "{{ username }}"
- name: Copy prod.secret.exs with owner and permissions
copy:
src: ../../config/prod.secret.exs
dest: "/home/{{ username }}/app_config/prod.secret.exs"
owner: "{{ username }}"
group: "{{ username }}"
- name: Create Systemd Init Script
template:
src: "{{ app_name }}.service"
dest: "/etc/systemd/system/{{ app_name }}.service"
- name: Enable Systemd service for application
systemd:
name: "{{ app_name }}"
enabled: yes
We need to create a few templates for this Playbook, first the .env
file. This is where you'll store environment variables needed by your application during the build process and during runtime ansible/playbooks/templates/example.env
:
export SECRET_KEY_BASE='{{ secret_key_base }}'
export ERLANG_COOKIE='{{ erlang_cookie }}'
This file will be copied into your deploy
user's home directory, sourced in .profile
and in the Systemd service. Next, we'll define the variables used in the template in Ansible Vault:
$ ansible-vault create inventories/group_vars/application/secret/phoenix.yml
This will ask for a password to use when encrypting your secret variables. Once the password was entered, it will open your text editor defined in $EDITOR
and allow you to edit this file. The convention is to use the secret_
prefix before encrypted variables:
---
secret_example_secret_key_base: super secret stuff here
secret_example_erlang_cookie: it's best to generate these values using mix phx.gen.secret
When you save and exit your editor, Ansible Vault will encrypt your secret variables, now for discoverability we will refer to them in a regular unencrypted variable file ansible/inventories/group_vars/application/phoenix.yml
:
---
secret_key_base: "{{ secret_secret_key_base }}"
erlang_cookie: "{{ secret_erlang_cookie }}"
Lastly, we are going to define a template for the Systemd service that will ensure our application re-spawns if the server is restarted ansible/playbooks/templates/example.service
:
[Unit]
Description={{ app_name }}
After=network.target
[Service]
User={{ username }}
Restart=on-failure
Type=forking
Environment=MIX_ENV=prod
EnvironmentFile= "/home/{{ username }}/{{ app_name }}.env"
ExecStart= /home/{{ username }}/app_release/{{ app_name }}/bin/{{ app_name }} start
ExecStop= /home/{{ username }}/app_release/{{ app_name }}/bin/{{ app_name }} stop
[Install]
WantedBy=multi-user.target
Add our new Playbook to the ansible/main.yml
file:
---
- hosts: application
remote_user: root
- name: Configure Machine Login
import_playbook: playbooks/configure-login.yml
- name: Install Packages
import_playbook: playbooks/install-packages.yml
- name: Application Deployment Setup
import_playbook: playbooks/application-deployment-setup.yml
Now since we have encrypted vault secrets we will need to tell Ansible how to decrypt them. There are a few ways of doing that, you can either tell Ansible to ask you to type the password before running:
$ ansible-playbook main.yml --ask-vault-pass
Or, my preferred method, store the password in a plain-text file (that should NEVER be committed to git) and tell Ansible where that file is:
$ ansible-playbook main.yml --vault-password-file .vault-password
Ignore that file in git:
echo '.vault-password' >> .gitignore
To make your life a bit easier, you may want to create a mix alias in the root of your project under mix.exs
:
def project do
[
aliases: aliases(),
# ...
]
end
defp aliases do
[
ansible: &run_ansible/1,
# ...
]
end
defp run_ansible(_) do
Mix.shell().cmd(
"cd ansible/ && ANSIBLE_FORCE_COLOR=True ansible-playbook main.yml --vault-password-file .vault-password"
)
end
Now to re-run the Ansible script you can simply run this command from the root of your project:
$ mix ansible
Setting Up Auto Renewing SSL Certification With Let's Encrypt
To set up an SSL certificate we will once again use a Galaxy Role. This will automate the certificate renewal and take care of verification for us.
First let's install the role from Ansible Galaxy. From inside our ansible/
directory run:
$ ansible-galaxy install geerlingguy.certbot
As before, make sure you skim through the code to ensure it is safe to run.
Next we will create our Playbook in ansible/playbooks/lets-encrypt.yml
:
---
- hosts: application
vars:
- certbot_auto_renew: true
- certbot_auto_renew_user: "root"
- certbot_auto_renew_hour: "3"
- certbot_auto_renew_minute: "30"
- certbot_auto_renew_options: "--quiet --no-self-upgrade"
- certbot_create_if_missing: true
- certbot_admin_email: "{{ admin_email }}"
- certbot_create_method: standalone
- certbot_create_standalone_stop_services:
- nginx
- certbot_certs:
- domains:
- "{{ domain }}"
- "www.{{ domain }}"
remote_user: root
roles:
- geerlingguy.certbot
We need to give Certbot an administrator email address, if you remember in a previous step we typed the email in clear text under inventories/group_vars/all.yml
. This time we are going to move it into a vault so that it is not exposed. Run this command to create a new vault for all
:
$ ansible-vault create inventories/group_vars/all/secret/all.yml
Enter a password for the vault. In the editor that opens write your email address:
---
secret_admin_email: "admin@example.com"
And replace the previous value in the unencrypted inventories/group_vars/all.yml
:
admin_email: "{{ secret_admin_email }}"
Make sure all the settings seem reasonable, and run Ansible:
$ mix ansible
Now if your domain is configured correctly and pointing at your server's IP address, you should be able to see two files on the remote machine in /etc/letsencrypt/live/example.com/fullchain.pem
and /etc/letsencrypt/live/example.com/privkey.pem
. In the next section we will configure Nginx to use those files.
Setting Up Nginx
To serve our application we will use Nginx - this is not a necessary step, but it makes setting up SSL certificates with Let's Encrypt a little easier.
Let's start with the Playbook. Create a file at ansible/playbooks/nginx.yml
:
---
- hosts: application
remote_user: root
tasks:
- name: Remove the default nginx app's config
file:
path: /etc/nginx/sites-available/default
state: absent
- name: Remove the default nginx app's symlink if it exists
file:
path: /etc/nginx/sites-enabled/default
state: absent
- name: Copy nginx.conf
template:
src: nginx.conf
dest: /etc/nginx/nginx.conf
- name: Ensure Nginx Modules dir exists
file:
path: /etc/nginx/modules
state: directory
- name: Nginx SSL Shared Settings Module
template:
src: "{{ app_name }}_shared_ssl_settings"
dest: /etc/nginx/modules/{{ app_name }}_shared_ssl_settings
- name: Configure nginx for the app
template:
src: "{{ app_name }}.nginx"
dest: "/etc/nginx/sites-available/{{ app_name }}"
group: "{{ username }}"
owner: "{{ username }}"
force: yes
- name: Enable the app
file:
src: "/etc/nginx/sites-available/{{ app_name }}"
dest: "/etc/nginx/sites-enabled/{{ app_name }}"
state: link
owner: "{{ username }}"
group: "{{ username }}"
- name: Restart nginx
service:
name: nginx
state: restarted
changed_when: >
"Will always run, don't show that it changed" == 1
In this Playbook we are deleting some of the defaults that Nginx came with, and replacing them with another set of defaults. You will need to create the following template ansible/playbooks/templates/nginx.conf
:
env PATH;
user www-data;
worker_processes 1;
pid /var/run/nginx.pid;
events {
worker_connections 1024;
multi_accept on;
}
http {
# Basic Settings
sendfile on;
tcp_nopush on;
tcp_nodelay on;
keepalive_timeout 65;
types_hash_max_size 2048;
server_tokens off;
include /etc/nginx/mime.types;
default_type application/octet-stream;
# Cache
open_file_cache max=1000 inactive=20s;
open_file_cache_valid 30s;
open_file_cache_min_uses 5; open_file_cache_errors off;
# Logging Settings
access_log /var/log/nginx/access.log;
error_log /var/log/nginx/error.log;
# Gzip Settings
gzip on;
gzip_types application/json;
include /etc/nginx/conf.d/*.conf;
include /etc/nginx/sites-enabled/*;
}
This is not too interesting so we won't go through it. The only relevant line here is the one that includes all files in /etc/nginx/sites-enabled/
. Our Playbook creates a file in /etc/nginx/sites-available/
and symlinks it to sites-enabled
. Let's create the template for this file in ansible/playbooks/templates/example.nginx
:
upstream {{app_name}} {
server 127.0.0.1:4000;
}
map $http_upgrade $connection_upgrade {
default upgrade;
'' close;
}
server {
listen 80 default_server;
listen [::]:80 default_server ipv6only=on;
server_name {{domain}} www.{{domain}};
return 301 https://{{domain}}$request_uri;
}
server {
server_name www.{{domain}};
include modules/{{ app_name }}_shared_ssl_settings;
return 301 https://{{domain}}$request_uri;
}
server {
server_name {{domain}} www.{{domain}};
root /home/{{username}}/app_release/static;
include modules/{{ app_name }}_shared_ssl_settings;
location / {
proxy_pass http://{{app_name}};
proxy_set_header Host $host;
proxy_set_header X-Real-IP $remote_addr;
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
proxy_buffering off;
}
location /live {
proxy_pass http://{{app_name}}$request_uri;
proxy_http_version 1.1;
proxy_set_header Upgrade $http_upgrade;
proxy_set_header Connection "Upgrade";
proxy_set_header Host $host;
}
}
What this configuration does is listen for connections to your bare domain (in our case example.com
) on ports 80 and 443. It will permanently redirect http connections to their more secure counterpart (https). As well as redirect www.example.com
to the bare domain.
The /live
location is set up to support websockets by upgrading the connection, specifically the /live
path is used by Phoenix LiveView, which is my favorite new feature in Phoenix 1.5.
You might be wondering where the 443 port and SSL cert path are defined. To allow re-use and to DRY up the configuration, we extracted some SSL settings into a shared Nginx module. This allows us to reuse them in both our www.
redirect and main server block. So you'll need to create this template next.
In ansible/playbooks/templates/example_shared_ssl_settings
place the following:
listen 443 ssl http2;
listen [::]:443;
ssl_certificate /etc/letsencrypt/live/{{domain}}/fullchain.pem;
ssl_certificate_key /etc/letsencrypt/live/{{domain}}/privkey.pem;
# TLS
ssl on;
ssl_session_cache shared:SSL:20m;
ssl_session_timeout 10m;
ssl_protocols TLSv1.2 TLSv1.3;
ssl_prefer_server_ciphers on;
ssl_ciphers ECDH+AESGCM:ECDH+AES256:ECDH+AES128:!DH+3DES:!ADH:!AECDH:!MD5;
# HSTS
add_header Strict-Transport-Security "max-age=31536000; includeSubDomains" always;
# Secure Headers
add_header X-Frame-Options DENY;
add_header X-Content-Type-Options nosniff;
add_header X-Permitted-Cross-Domain-Policies none;
Finally, we'll import the Playbook into our ansible/main.yml
:
---
- hosts: application
remote_user: root
- name: Configure Machine Login
import_playbook: playbooks/configure-login.yml
- name: Install Packages
import_playbook: playbooks/install-packages.yml
- name: Application Deployment Setup
import_playbook: playbooks/application-deployment-setup.yml
- name: Let's Encrypt SSL Setup
import_playbook: playbooks/lets-encrypt.yml
- name: Setup Nginx
import_playbook: playbooks/nginx.yml
Now run the script:
$ mix ansible
And we're done with Ansible! Your server is now ready to receive build commands and run releases.
If we re-run mix ansible
, you'll notice there were no changes and the process should take less than a minute to complete.
Conclusion
In this post we took an initial step towards automating deployment of Elixir applications. Automating this process may take longer than doing it manually, at least initially. However, in the long run it allows for faster iteration on configuration changes, and it allows us to blow away the machine and spin up another one in seconds. In addition, your Ansible files serve as documentation of what it takes to run your application in production.
In the next post we will setup releases with Distillery and deploy them using eDeliver.
Thanks for reading!
At Hashrocket, we love Elixir, Phoenix and LiveView and have years of experience delivering robust, well-tested Elixir applications to production. Reach out if you need help with your Elixir project!
Photo by Markus Spiske on Unsplash