Setting up a server for deploying a Rails application using Ansible

Not long ago, I needed to write several Ansible playbooks to prepare a server for deploying a Rails application. Surprisingly, I couldn't find a simple step-by-step manual. I didn't want to copy someone else's playbook without understanding what was happening, so I had to read the documentation and piece everything together myself. Hopefully, I can help someone speed up this process with this article.

First of all, it's important to understand that Ansible provides you with a convenient interface for executing a predefined list of actions on a remote server (or servers) via SSH. There's no magic here; you can't just install a plugin and out-of-the-box get zero downtime deployment for your application with Docker, monitoring, and other features. To write a playbook, you need to know exactly what you want to do and how to do it. That's why I'm not satisfied with ready-made playbooks from GitHub or articles like: 'Copy and run — it will work.'

What do we need?

As I mentioned, to write a playbook, you need to know what you want to do and how to do it. Let's determine what we need. For a Rails application, we will require several system packages: nginx, postgresql (redis, etc.). Additionally, we need a specific version of Ruby. It's best to install it using rbenv (rvm, asdf…). Running all of this as the root user is always a bad idea, so we should create a separate user and set up the appropriate permissions. After that, we need to upload our code to the server, copy the configs for nginx, postgres, etc., and start all these services.

Ultimately, the sequence of actions is as follows:

  1. Log in as root
  2. Install system packages
  3. Create a new user, set permissions, and SSH key
  4. Configure system packages (nginx, etc.) and start them
  5. Create a database user (you can also create the database at the same time)
  6. Log in as the new user
  7. Install rbenv and Ruby
  8. Install bundler
  9. Upload the application code
  10. Start the Puma server

Moreover, the last stages can be performed using Capistrano, as it natively supports copying code to release directories, switching the release symlink upon successful deployment, copying configurations from the shared directory, restarting Puma, etc. All of this can also be done using Ansible, but why bother?

File Structure

Ansible has a strict file structure for all its files, so it's best to keep everything in a separate directory. It doesn't matter if it's within the Rails application or separately. Files can be stored in a separate Git repository. Personally, I find it most convenient to create an Ansible directory in the /config directory of the Rails application and keep everything in one repository.

Simple Playbook

A Playbook is a YAML file that describes what and how Ansible should execute its tasks using a specific syntax. Let's create our first playbook that does nothing:

---
- name: Simple playbook
  hosts: all

Here we simply state that our playbook is named Simple Playbook and that its contents should be executed on all hosts. We can save it in the /ansible directory with the name playbook.yml and try to run:

ansible-playbook ./playbook.yml

PLAY [Simple Playbook] ************************************************************************************************************************************
skipping: no hosts matched

Ansible states that it doesn't know of any hosts matching the list 'all'. They need to be listed in a special inventory file..

Let's create it in the same Ansible directory:

123.123.123.123

This is how we simply specify a host (ideally, your VPS host for testing, or you can specify localhost) and save it under the name inventory.
You can try running Ansible with the inventory file:

ansible-playbook ./playbook.yml -i inventory
PLAY [Simple Playbook] ************************************************************************************************************************************

TASK [Gathering Facts] ************************************************************************************************************************************

PLAY RECAP ************************************************************************************************************************************

If you have SSH access to the specified host, Ansible will connect and gather information about the remote system. (default TASK [Gathering Facts]) after which it will provide a brief report on the execution (PLAY RECAP).

By default, the connection uses the username with which you are logged into the system. It is likely that it will not exist on the host. In the playbook file, you can specify which user to use for connection with the remote_user directive. Also, information about the remote system may often be unnecessary, and it is not worth spending time collecting it. This task can also be disabled:

---
- name: Simple playbook
  hosts: all
  remote_user: root
  become: true
  gather_facts: no

Try running the playbook again and ensure that the connection works. (If you specified the root user, you also need to include the become: true directive to gain elevated privileges, as stated in the documentation: become set to ‘true’/’yes’ to activate privilege escalation. though it's not entirely clear why).

You may encounter an error caused by ansible being unable to determine the Python interpreter; if so, you can specify it manually:

ansible_python_interpreter: /usr/bin/python3 

You can find out where Python is located with the command whereis python.

Installing System Packages

The standard Ansible distribution comes with many modules for working with various system packages, which means we don't have to write bash scripts for every single case. We will need one of these modules for system updates and installing system packages. I am using Ubuntu Linux on my VPS, so I will use apt-get and the module for it.If you are using another operating system, you may need a different module (remember I mentioned at the beginning that it's important to know in advance what and how we will be doing). However, the syntax will probably be similar.

Let's add the first tasks to our playbook:

---
- name: Simple playbook
  hosts: all
  remote_user: root
  become: true
  gather_facts: no

  tasks:
    - name: Update system
      apt: update_cache=yes
    - name: Install system dependencies
      apt:
        name: git,nginx,redis,postgresql,postgresql-contrib
        state: present

Task — this is the task that ansible will execute on the remote servers. We give the task a name to track its execution in the log. And we describe what it needs to do using the specific module syntax. In this case, apt: update_cache=yes — tells it to update the system packages using the apt module. The second command is somewhat more complex. We pass a list of packages to the apt module and instruct it that their state should become present, meaning we are saying to install these packages. Similarly, we can instruct to remove or update them, simply by changing state. Note that for Rails to work with PostgreSQL, we need the postgresql-contrib package, which we are currently installing. This is something you need to know and do, as Ansible will not do this by itself.

Try running the playbook again and check that the packages will be installed.

Creating new users.

For user management, Ansible also has a module — user. Let's add another task (I've hidden the well-known parts of the playbook with comments to avoid copying it completely every time):

---
- name: Simple playbook
  # ...
  tasks:
    # ...
    - name: Add a new user
      user:
        name: my_user
        shell: /bin/bash
        password: "{{ 123qweasd | password_hash('sha512') }}"

We create a new user, set a shell and a password for them. We immediately face several issues. What if the usernames need to be different for different hosts? Also, storing the password in plain text in the playbook is a very bad idea. For starters, let's move the username and password to variables, and later in the article, I will show how to encrypt the password.

---
- name: Simple playbook
  # ...
  tasks:
    # ...
    - name: Add a new user
      user:
        name: "{{ user }}"
        shell: /bin/bash
        password: "{{ user_password | password_hash('sha512') }}"

using double curly braces in playbooks to set variables.

We will specify the variable values in the inventory file:

123.123.123.123

[all:vars]
user=my_user
user_password=123qweasd

Pay attention to the directive [all:vars] — it indicates that the following text block consists of variables (vars) applicable to all hosts (all).

The construction "{{ user_password | password_hash('sha512') }}". The fact is that Ansible does not add users using user_add like you would do it manually. Instead, it saves all data directly, which is why we also need to preprocess the password into a hash, which this command does.

Let's add our user to the sudo group. However, before that, we need to make sure that such a group exists, because no one will do this for us:

---
- name: Simple playbook
  # ...
  tasks:
    # ...
    - name: Ensure a 'sudo' group
      group:
        name: sudo
        state: present
    - name: Add a new user
      user:
        name: "{{ user }}"
        shell: /bin/bash
        password: "{{ user_password | password_hash('sha512') }}"
        groups: "sudo"

It's quite simple; we also have a group module for creating groups, with syntax very similar to apt. After that, it's enough to assign this group to the user (groups: "sudo").
It's also useful to add an SSH key for this user so that we can log in as them without a password:

---
- name: Simple playbook
  # ...
  tasks:
    # ...
    - name: Ensure a 'sudo' group
      group:
      name: sudo
        state: present
    - name: Add a new user
      user:
        name: "{{ user }}"
        shell: /bin/bash
        password: "{{ user_password | password_hash('sha512') }}"
        groups: "sudo"
    - name: Deploy SSH Key
      authorized_key:
        user: "{{ user }}"
        key: "{{ lookup('file', '~/.ssh/id_rsa.pub') }}"
        state: present

In this case, the interesting construction is "{{ lookup('file', '~/.ssh/id_rsa.pub') }}" — it copies the content of the id_rsa.pub file (its name may differ), i.e., the public part of the SSH key, and uploads it to the list of authorized keys for the user on the server.

Roles

All three tasks for user creation can easily be related to a single group of tasks, and it would be beneficial to keep this group separate from the main playbook to prevent it from growing too large. For this, Ansible has roles.
According to the file structure mentioned at the beginning, roles must be placed in a separate directory named roles, with each role having its own directory with a corresponding name, containing directories for tasks, files, templates, etc.
Let's create the file structure: ./ansible/roles/user/tasks/main.yml (main is the main file that will be loaded and executed when connecting the role to the playbook; it can link to other role files). Now we can transfer all tasks related to the user into this file:

# Create user and add him to groups
- name: Ensure a 'sudo' group
  group:
    name: sudo
    state: present

- name: Add a new user
  user:
    name: "{{ user }}"
    shell: /bin/bash
    password: "{{ user_password | password_hash('sha512') }}"
    groups: "sudo"

- name: Deploy SSH Key
  authorized_key:
    user: "{{ user }}"
    key: "{{ lookup('file', '~/.ssh/id_rsa.pub') }}"
    state: present

In the main playbook, you need to indicate the use of the user role:

---
- name: Simple playbook
  hosts: all
  remote_user: root
  gather_facts: no

  tasks:
    - name: Update system
      apt: update_cache=yes
    - name: Install system dependencies
      apt:
        name: git,nginx,redis,postgresql,postgresql-contrib
        state: present

  roles:
    - user

It may also make sense to perform the system update before all other tasks. For this, you can rename the block tasks in which they are defined to pre_tasks.

Nginx Configuration

Nginx should already be installed; it needs to be configured and started. Let's do this directly in the role. We create the file structure:

- ansible
  - roles
    - nginx
      - files
      - tasks
        - main.yml
      - templates

Now we need the files and templates. The difference between them is that Ansible copies files directly as they are. Templates, on the other hand, must have a .j2 extension and can utilize variable values using the same double curly braces.

Let's enable nginx in the main.yml file. For this, we have the systemd module:

# Copy nginx configs and start it
- name: enable service nginx and start
  systemd:
    name: nginx
    state: started
    enabled: yes

Here, we specify not only that nginx should be started (meaning we are launching it), but we also state that it should be enabled.
Now let's copy the configuration files:

# Copy nginx configs and start it
- name: enable service nginx and start
  systemd:
    name: nginx
    state: started
    enabled: yes

- name: Copy the nginx.conf
  copy:
    src: nginx.conf
    dest: /etc/nginx/nginx.conf
    owner: root
    group: root
    mode: '0644'
    backup: yes

- name: Copy template my_app.conf
  template:
    src: my_app_conf.j2
    dest: /etc/nginx/sites-available/my_app.conf
    owner: root
    group: root
    mode: '0644'

We create the main nginx configuration file (you can take it directly from the server or write it yourself). Additionally, we create the configuration file for our application in the sites_available directory (this is optional but useful). In the first case, we use the copy module to copy the files (the file must be located in /ansible/roles/nginx/files/nginx.conf). In the second case, we copy the template, substituting variable values. The template must be placed in /ansible/roles/nginx/templates/my_app.j2). And it may look something like this:

upstream {{ app_name }} {
  server unix:{{ app_path }}/shared/tmp/sockets/puma.sock;
}

server {
  listen 80;
  server_name {{ server_name }} {{ inventory_hostname }};
  root {{ app_path }}/current/public;

  try_files $uri/index.html $uri.html $uri @{{ app_name }};
  ....
}

Note the inserts {{ app_name }}, {{ app_path }}, {{ server_name }}, {{ inventory_hostname }} — these are all variables, the values of which Ansible will substitute into the template before copying. This is useful for using the playbook for different groups of hosts. For example, we can enhance our inventory file:

[production]
123.123.123.123

[staging]
231.231.231.231

[all:vars]
user=my_user
user_password=123qweasd

[production:vars]
server_name=production
app_path=/home/www/my_app
app_name=my_app

[staging:vars]
server_name=staging
app_path=/home/www/my_stage
app_name=my_stage_app

If we now run our playbook, it will perform the specified tasks for both hosts. However, for the staging host, the variables will differ from production, not only in roles and playbooks but also in nginx configs. {{ inventory_hostname }} there's no need to specify in the inventory file — this is a special ansible variable that holds the host for which the playbook is currently being executed.
If you want to have an inventory file for several hosts, but run it only for one group, you can do so with the following command:

ansible-playbook -i inventory ./playbook.yml -l "staging"

another option is to have separate inventory files for different groups. Or you can combine both approaches if you have many different hosts.

Let's return to the configuration of nginx. After copying the configuration files, we need to create a symlink in sites_enabled to my_app.conf from sites_available. And restart nginx.

... # old code in mail.yml

- name: Create symlink to sites-enabled
  file:
    src: /etc/nginx/sites-available/my_app.conf
    dest: /etc/nginx/sites-enabled/my_app.conf
    state: link

- name: restart nginx
  service:
    name: nginx
    state: restarted

It's quite simple here — again, the ansible modules have a fairly standard syntax. But there's one point. Restarting nginx every time doesn't make sense. Did you notice that we don't write commands like: 'do this like that', the syntax looks more like 'this should have this state'. And most often this is how ansible works. If the group already exists, or the system package is already installed, ansible will check this and skip the task. Also, files will not be copied if they completely match what is already on the server. We can take advantage of this and restart nginx only if the configuration files have been changed. For this, there is a register directive:

# Copy nginx configs and start it
- name: enable service nginx and start
  systemd:
    name: nginx
    state: started
    enabled: yes

- name: Copy the nginx.conf
  copy:
    src: nginx.conf
    dest: /etc/nginx/nginx.conf
    owner: root
    group: root
    mode: '0644'
    backup: yes
  register: restart_nginx

- name: Copy template my_app.conf
  template:
    src: my_app_conf.j2
    dest: /etc/nginx/sites-available/my_app.conf
    owner: root
    group: root
    mode: '0644'
  register: restart_nginx

- name: Create symlink to sites-enabled
  file:
    src: /etc/nginx/sites-available/my_app.conf
    dest: /etc/nginx/sites-enabled/my_app.conf
    state: link

- name: restart nginx
  service:
    name: nginx
    state: restarted
  when: restart_nginx.changed

If one of the configuration files changes, copying will be performed and a variable will be registered restart_nginx. And only if this variable has been registered, the service restart will occur.

Of course, you also need to add the nginx role to the main playbook.

Postgresql configuration

We need to enable postgresql using systemd just like we did with nginx, and also create a user that we will use to access the database, as well as the database itself.
Let's create a role /ansible/roles/postgresql/tasks/main.yml:

# Create user in postgresql
- name: enable postgresql and start
  systemd:
    name: postgresql
    state: started
    enabled: yes

- name: Create database user
  become_user: postgres
  postgresql_user:
    name: "{{ db_user }}"
    password: "{{ db_password }}"
    role_attr_flags: SUPERUSER

- name: Create database
  become_user: postgres
  postgresql_db:
    name: "{{ db_name }}"
    encoding: UTF-8
    owner: "{{ db_user }}"

I won't detail how to add variables to inventory; this has already been done many times, as have the syntax of the postgresql_db and postgresql_user modules. More information can be found in the documentation. The most interesting directive here is become_user: postgres. The fact is that by default, access to the postgresql database is only available to the postgres user and only locally. This directive allows us to execute commands as this user (provided we have access).
You may also need to add a line in pg_hba.conf to grant the new user access to the database. This can also be done just like we changed the nginx config.

And of course, you need to add the postgresql role to the main playbook.

Installing ruby via rbenv

Ansible does not have modules for working with rbenv, and it is installed by cloning a git repository. Thus, this task becomes quite unconventional. Let’s create a role for it. /ansible/roles/ruby_rbenv/main.yml and start filling it out:

# Install rbenv and ruby
- name: Install rbenv
  become_user: "{{ user }}"
  git: repo=https://github.com/rbenv/rbenv.git dest=~/.rbenv

We again use the become_user directive to operate under the user we created for this purpose. Since rbenv is installed in his home directory and not globally, we also use the git module to clone the repository, specifying repo and dest.

Next, we need to add rbenv init to bashrc and also add rbenv to PATH. For this, we have the lineinfile module:

- name: Add rbenv to PATH
  become_user: "{{ user }}"
  lineinfile:
    path: ~/\.bashrc
    state: present
    line: 'export PATH="${HOME}/\.rbenv/bin:${PATH}"'

- name: Add rbenv init to bashrc
  become_user: "{{ user }}"
  lineinfile:
    path: ~/\.bashrc
    state: present
    line: 'eval "$(rbenv init -)"'

After that, we need to install ruby_build:

- name: Install ruby-build
  become_user: "{{ user }}"
  git: repo=https://github.com/rbenv/ruby-build.git dest=~/\.rbenv/plugins/ruby-build

And finally, install ruby. This is done through rbenv, simply using a bash command:

- name: Install ruby
  become_user: "{{ user }}"
  shell: |
    export PATH="${HOME}/\.rbenv/bin:${PATH}"
    eval "$(rbenv init -)"
    rbenv install {{ ruby_version }}
  args:
    executable: /bin/bash

We specify which command to execute and how. However, here we encounter the fact that ansible does not execute the code contained in bashrc before running commands. Therefore, rbenv will have to be defined directly in this script.

The next problem is that the shell command has no state from Ansible's perspective. That is, there will be no automatic check whether this version of ruby is installed or not — we can do this ourselves:

- name: Install ruby
  become_user: "{{ user }}"
  shell: |
    export PATH="${HOME}/\.rbenv/bin:${PATH}"
    eval "$(rbenv init -)"
    if ! rbenv versions | grep -q {{ ruby_version }}
      then rbenv install {{ ruby_version }} && rbenv global {{ ruby_version }}
    fi
  args:
    executable: /bin/bash

And we still need to install bundler:

- name: Install bundler
  become_user: "{{ user }}"
  shell: |
    export PATH="${HOME}/\.rbenv/bin:${PATH}"
    eval "$(rbenv init -)"
    gem install bundler

And again, add our ruby_rbenv role to the main playbook.

Shared files.

Overall, the setup could have ended here. Next, we just need to run capistrano, and it will copy the code, create the necessary directories, and launch the application (if everything is configured correctly). However, often capistrano requires additional configuration files, such as database.yml or .env They can be copied just like files and templates for nginx. There is just one nuance. Before copying the files, you need to create a directory structure for them, something like this:

# Copy shared files for deploy
- name: Ensure shared dir
  become_user: "{{ user }}"
  file:
    path: "{{ app_path }}/shared/config"
    state: directory

we specify only one directory, and ansible will automatically create the parent directories if needed.

Ansible Vault

We have already encountered situations where secrets such as user passwords may be in the variables. If you have created .env a file for the application, and database.yml there should be even more critical data. It’s best to hide that from prying eyes. To do this, we use ansible vault.

Let's create a file for variables /ansible/vars/all.yml (here you can create different files for different host groups, just like in the inventory file: production.yml, staging.yml, etc.).
In this file, you need to transfer all the variables that should be encrypted, using standard yml syntax:

# System vars
user_password: 123qweasd
db_password: 123qweasd

# ENV vars
aws_access_key_id: xxxxx
aws_secret_access_key: xxxxxx
aws_bucket: bucket_name
rails_secret_key_base: very_secret_key_base

After which this file can be encrypted with the command:

ansible-vault encrypt ./vars/all.yml

Naturally, when encrypting, you will need to set a password for decryption. You can see what will be inside the file after running this command.

Using ansible-vault decrypt the file can be decrypted, modified, and then encrypted again.

For operation, it is not necessary to decrypt the file. You keep it in encrypted form and run the playbook with the argument --ask-vault-pass. Ansible will ask for the password, retrieve the variables, and execute the tasks. All data will remain encrypted.

The complete command for multiple host groups and ansible vault will look something like this:

ansible-playbook -i inventory ./playbook.yml -l "staging" --ask-vault-pass

And I won't provide you with the complete text of playbooks and roles, you'll have to write it yourself. Because ansible is such a thing — if you don't understand what needs to be done, it won't do it for you either.

Source: habr.com

Buy reliable website hosting with DDoS protection, VPS VDS servers 🔥 Buy reliable website hosting with DDoS protection, VPS VDS servers | ProHoster