Deploying Elixir (3 of 3): Provisioning EC2 With Ansible
Tags: elixir, webdev, aws, tutorial
Published: 2020-06-18 18:00:23

---
title: Deploying Elixir (3 of 3): Provisioning EC2 With Ansible
published: true
description: Provision an EC2 instance with Ansible for elixir deployment. third in a series of three posts on deploying Elixir.
tags: elixir,webdev,aws,tutorial
---

This post is the third in a series of three on deploying elixir:

1. [Building Releases with Docker & Mix](https://dev.to/jonlunsford/deploying-elixir-1-of-3-building-releases-with-mix-1o4a)
2. [Terraforming an AWS EC2 Instance](https://dev.to/jonlunsford/deploying-elixir-2-of-3-terraforming-an-aws-ec2-instance-1nh5)
3. Deploying Releases with Ansible

---

In pt 2. we installed [Terraform](https://learn.hashicorp.com/terraform/getting-started/install) and [built some infrastructure](https://learn.hashicorp.com/terraform/getting-started/build) on AWS, specifically a `Debian Buster` EC2 instance to run our app. Now that we have our release built, and a place to run our app, let's walk through the basics of [Ansible](https://www.ansible.com/resources/get-started) and provision our new EC2 instance.

If you did not follow along with that last post, you can grab the complete code here: [https://github.com/jonlunsford/webhook_processor](https://github.com/jonlunsford/webhook_processor)

---

#### Ansible is:

> A radically simple IT automation engine that automates [cloud provisioning](https://www.ansible.com/provisioning?hsLang=en-us), [configuration management](https://www.ansible.com/configuration-management?hsLang=en-us), [application deployment](https://www.ansible.com/application-deployment?hsLang=en-us), [intra-service orchestration](https://www.ansible.com/orchestration?hsLang=en-us), and many other IT needs.

### Installing Ansible

If you have `pip` installed, you can run:

```bash
$ pip install --user ansible
```

Otherwise take a look at their [Installation Guide](https://docs.ansible.com/ansible/latest/installation_guide/intro_installation.html?extIdCarryOver=true&sc_cid=701f2000001OH7YAAW#intro-installation-guide) for in depth instructions on how to install on your system. Verify the install worked properly by opening a new shell and typing:

```bash
$ ansible --version
```

You should see some output about the install, mine shows:

```bash
ansible 2.8.0
...
```

Here's the outline of the tasks we will automate with Ansible:

1. Setup: Bootstrapping the new server with all the prerequisites
    * Ensure python is installed (Ansible is a python lib)
    * Create our deploy user/group
    * Create our directory structure
    * Upload various system files (sudoers, systemd)
    * Forward traffic from port 80 to our running app  
2. Deploy: Upload our build artifacts
    * Unzip our app tarball in the correct directory
    * Start/restart the app
3. Profit!

Finally, we'll wrap this all up in mix task so we can run:

```bash
mix ansible.playbook setup
mix ansible.playbook deploy
```

---

### Configuring Ansible

Create an inventory file noting the public dns of your ec2 instance, this tells Ansible the hosts we would like to run commands on:

```yml
# ./rel/ansible/inventory/main.yml

---
all:
  children:
    webservers:
      hosts:
        ec2-x-xxx-x-xx.us-west-1.compute.amazonaws.com
```

Next, we'll use an `ansible.cfg` file to store our configuration:

```cfg
# ./rel/ansible/ansible.cfg

[defaults]
inventory=./inventory/main.yml
remote_user=admin 
private_key_file=../webhook_processor_key 
```
With these few files in place, we can now test to see if Ansible can connect to our ec2 instance, cd into `./rel/ansible` and run:

```bash
ansible webservers -m ping
```

Notice we explicitly called the command with `webservers`, this matches our inventory file above and scopes our command to only run on those hosts. You could have further inventory like `dbservers` (or whatever is relevant) and you would _scope_ any commands or playbook runs accordingly.

The last configuration step we will do is create a shared vars file under `./rel/ansible/vars/main.yml`. This way we can populate and use common vars across our tasks:

```yml
# ./rel/ansible/vars/main.yml

---
mix_env: "{{ lookup('env', 'MIX_ENV')}}"
app_port: "{{ lookup('env', 'APP_PORT') }}"
app_name: "{{ lookup('env', 'APP_NAME') }}"
app_vsn: "{{ lookup('env', 'APP_VSN') }}"
app_local_release_path: "{{ lookup('env', 'APP_LOCAL_RELEASE_PATH') }}"

app_local_path: "{{ app_local_release_path }}/{{ mix_env }}-{{ app_vsn }}.tar.gz"

# Main app server config
# OS user that deploys / owns the release files
deploy_user: deploy
# OS group that deploys / owns the release files
deploy_group: "{{ deploy_user }}"

# Base directory for deploy files
deploy_dir: "/opt/{{ app_name }}"

# Dirs owned by the deploy user
deploy_dirs:
  - "{{ deploy_dir }}"
```

Notice we'll use the `lookup('env', 'MY_ENV_VAR')` function to grab a few details about our app, more on that when we write the mix task later.

### Setup Tasks

Now that we've verified we can connect, we can begin provisioning our ec2 instance. First, we'll write a [playbook](https://docs.ansible.com/ansible/latest/user_guide/playbooks.html) that runs various setup tasks:

```yml
# ./rel/ansible/tasks/setup.yml

---
- hosts: webservers
  gather_facts: False
  become: yes
  vars_files:
    - "../vars/main.yml"
  tasks:
    # Ensure python is installed, the debian ec2 instance does by default
    # Ansible will show us a warning about that
    - name: Install python 2
      raw: test -e /usr/bin/python || (apt -y update && apt install -y python-minimal)

    # Add the deploy group set in ../vars/main.yml if it does not exists
    - name: "Add {{ deploy_group }} group"
      group: name={{ deploy_group }} state=present

    # Add the deploy user set in ../vars/main.yml if it does not exists
    - name: "Add {{ deploy_user }} user"
      user: name={{ deploy_user }} groups={{ deploy_group }} append=yes state=present

    # Create the directories for the app
    - name: Create deploy dirs
      file: path={{ item }} state=directory owner={{ deploy_user }} group={{ deploy_user }} mode=0700
      with_items: "{{ deploy_dirs }}"

    # Create a sudoers file that gives the app user ONLY the ability to
    # start/stop the app. This helps ensure the security of the server, in case
    # the app user were compromised
    - name: Create sudoers config for deploy user
      template:
        src: "../templates/sudoers.j2"
        dest: /etc/sudoers.d/{{ deploy_user }}-{{ app_name }}
        owner: root
        group: root
        mode: 0600

    # Upload our systemd template
    - name: Copy systemd config file
      template:
        src: "../templates/systemd.j2"
        dest: "/etc/systemd/system/{{ app_name }}.service"
        owner: root
        group: root
        mode: 0644

    # Enable the systemd service
    - name: Enable service
      service: name={{ app_name }} enabled=yes

    # Forward port 80 to our app_port, set in ../vars/main.yml
    - name: "Forward port 80 to {{ app_port }}"
      iptables:
        table: nat
        chain: PREROUTING
        in_interface: eth0
        protocol: tcp
        match: tcp
        destination_port: "80"
        jump: REDIRECT
        to_ports: "{{ app_port }}"
        comment: "Redirect web traffic to port {{ app_port }}"
```
We're using a couple template files here as well, let's create those next, first the systemd service file that will allow the app to be managed by systemctl:

```j2
## ./rel/ansible/templates/systemd.j2

[Unit]
Description={{ app_name }}
After=local-fs.target network.target

[Service]
Type=simple
User={{ deploy_user }}
Group={{ deploy_group }}
WorkingDirectory={{ deploy_dir }}
ExecStart={{ deploy_dir }}/bin/{{ mix_env }} start
ExecStop={{ deploy_dir }}/bin/{{ mix_env }} stop
EnvironmentFile={{ deploy_dir }}/{{ app_name }}.env
LimitNOFILE=65536
UMask=0027
SyslogIdentifier={{ deploy_user }}
Restart=always
RestartSec=5
RemainAfterExit=no

[Install]
WantedBy=multi-user.target
```

Next, the sudoers file that will only permit the app user (`deploy`) to stop/start/restart the app:

```j2
## ./rel/ansible/templates/sudoers.j2

# {{ ansible_managed }}
{{ deploy_user }} ALL=(ALL) NOPASSWD: /bin/systemctl start {{ app_name }}, /bin/systemctl stop {{ app_name }}, /bin/systemctl restart {{ app_name }}, /bin/systemctl status {{ app_name }}, /bin/systemctl status {{ app_name }}
Defaults:{{ deploy_user }} !requiretty
```

The last template we'll need is our env file, allowing us to set any vars the app may need. In this case, just the `app_port`:

```j2
## ./rel/ansible/templates/env.j2

APP_PORT={{ app_port }}
```

---

### Ansible Mix Task

Since running these commands will become redundant, let's write yet another Mix task to facilitate everything, including setting all the env vars `./rel/ansible/vars/main.yml` is picking up. Create the initial namespace:

```elixir
# ./lib/mix/tasks/ansible.playbook.ex

defmodule Mix.Tasks.Ansible.Playbook do
  use Mix.Task

  @shortdoc "Run ansible playbooks"
  def run(args) do
    Mix.Task.run("ansible", args)
  end
end
```

Our task will need to setup a few env vars, then kick off the ansible playbook:

```elixir
# ./rel/ansible/tasks/ansible.ex

defmodule Mix.Tasks.Ansible do
  use Mix.Task
  use Mix.Tasks.Utils

  @shortdoc "Run ansible playbooks"
  def run([playbook]) do
    cmd_args = ["./rel/ansible/tasks/#{playbook}.yml"]
    {dir, _resp} = System.cmd("pwd", [])
    dir = String.trim(dir)
    mix_env = System.get_env("MIX_ENV")

    System.cmd(
      "ansible-playbook",
      cmd_args,
      env: [
        {"ANSIBLE_CONFIG", "#{dir}/rel/ansible/ansible.cfg"},
        {"APP_NAME", app_name()},
        {"APP_VSN", app_vsn()},
        {"APP_PORT", app_port()},
        {"MIX_ENV", mix_env},
        {"APP_LOCAL_RELEASE_PATH", "#{dir}/_build/#{mix_env}"}
      ],
      into: IO.stream(:stdio, :line)
    )
  end
end
```

Notice we're setting all the env vars that are relevant to the app, that will become available to Ansible. Run the setup command now, from the project root:

```bash
export MIX_ENV=prod
mix ansible.playbook setup
```

You should see all of the output indicating the result of each task.

---

### Deploying the App Tarball

Once setup is complete, the next step is a matter of uploading our build artifacts, here's the entire playbook needed to actually deploy:

```yaml
# ./rel/ansible/tasks/deploy.yml

---
- hosts: webservers
  become: yes
  vars_files:
    - "../vars/main.yml"
  tasks:
    - name: Copy env to webserver
      template:
        src: "../templates/env.j2"
        dest: "{{ deploy_dir }}/{{ app_name }}.env"

    - name: "Check if {{ deploy_dir }} exists"
      stat:
        path: "{{ deploy_dir }}/{{ app_name }}.tar.gz"
      register: new_deploy_dir

    - name: Unarchive tarbal on webserver
      unarchive: src={{ app_local_path }} dest={{ deploy_dir }}
      when: not new_deploy_dir.stat.exists

    - name: Set file permissions
      file: path={{ deploy_dir }} owner={{ deploy_user }} group={{ deploy_group }} recurse=yes mode=0700

    - name: Start app
      raw: sudo /bin/systemctl restart {{ app_name}}
```

From the project root, run:

```bash
export MIX_ENV=prod
mix ansible.playbook deploy
```

If all went according to plan, you should be able to navigate to your ec2 instance and see the app running. Navigate to `http://ec2-xx-xx-xx-xx.us-west-1.compute.amazonaws.com/version` and you'll see the current version of the app.

---

### Deploying Updates

Let's bump the version of our app and see how to deploy changes:

```elixir
# ./mix.exs
defmodule WebhookProcessor.MixProject do
  use Mix.Project

  def project do
    [
      app: :webhook_processor,
      version: "0.2.0",
      ...
    ]
  end
  ...
end
```
The we re-run our build/deploy workflow:

```bash
export MIX_ENV=prod
mix do docker.build prod
mix ansible.playbook deploy
```
Now navigate to `http://ec2-xx-xx-xx-xx.us-west-1.compute.amazonaws.com/version` and you will see your latest changes deployed.

---

To recap we've:

1. Installed and configured Ansible.
2. Created Ansible playbooks to setup a new server and deploy.
3. Created a mix task to facilitate everything.

With this approach you will now be able to standup the entire infrastructure needed to run your app. As usual, the full code can be found on github: https://github.com/jonlunsford/webhook_processor

Up next, let's look at another method for deploying elixir apps, we'll walk through getting setup with [dokku](http://dokku.viewdocs.io/dokku/) on DigitalOcean and deploy a Phoenix app this time.