How to Use Ansible for Seamless Application Deployment

How to Use Ansible for Seamless Application Deployment

This article originated from a task where I was required to deploy a two-tier application using Ansible.

Previously, I had used Ansible primarily for small configuration tasks, but this was my first experience deploying an application with it.

In this article, we'll explore how to use Ansible for seamless application deployment.

Introduction

Ansible, an open-source automation tool, has grown in popularity among DevOps professionals due to its ease of use and robust features.

Ansible is well-known for its configuration management features, but it also supports application deployment.

Ansible can manage an application's whole lifecycle with playbooks, roles, and modules, from installation and updates to scalability and rollback.

This article is centered on using Ansible to deploy a FastAPi application.

If you are new to Ansible, this article will help you streamline your deployment process and guarantee that your applications perform smoothly.

Pre-requisites

  • Install Ansible

  • Install Python

  • Install OpenSSH

Key Ansible Files and Their Functions

"The question in your head might be, 'Where do you start when given an Ansible task?'"

The first thing to do is create a directory. In this project, the directory is called app. Run the command:

zenitugo@Zenitugo:~$ mkdir app && cd app

The next thing to do is to create the files that are required for Ansible to use in the deployment process.

zenitugo@Zenitugo:~/app$ touch hosts.ini  ansible.cfg playbook.yml

Note: The files can be named anything, it's only their extension that matters.

Inventory file

The inventory file (hosts.ini) specifies which hosts (servers) Ansible will manage. It might be a basic text file that contains IP addresses or hostnames organized into parts. Ansible uses this file to identify and categorize the servers it must communicate with.

Sample of a hosts.ini file

[web]
webserver1 ansible_host=192.168.1.10
webserver2 ansible_host=192.168.1.11

[db]
dbserver1 ansible_host=192.168.1.20

Explanation:

  • [web] and [db] are groups of servers.webserver1, webserver2, and dbserver1 are aliases for the servers, making it easier to reference them in playbooks.

  • ansible_host specifies the actual IP address of each server.

Ansible Configuration file

The ansible.cfg file includes Ansible's configuration parameters. This file lets you tweak Ansible's behavior, such as defining the default inventory file, the path to your SSH keys, and configuring connection settings.

Sample of an ansible.cfg file

[defaults]
inventory = ./hosts.ini
remote_user = your_username
host_key_checking = False

Explanation:

  • inventory specifies the path to your inventory file.

  • remote_user sets the default SSH user for connecting to the hosts.

  • host_key_checking is set to False to disable host key checking, which can simplify initial connections to new servers

Playbook file

A playbook is a YAML file that specifies the tasks Ansible will run on the target hosts. Ansible's configuration, deployment, and orchestration capabilities are built around playbooks, which enable you to easily automate complex tasks.

Sample of a playbook file

---
- name: Deploy web application
  hosts: web
  become: yes

  tasks:
    - name: Install Nginx
      apt:
        name: nginx
        state: present

    - name: Copy web application files
      copy:
        src: /path/to/local/webapp/
        dest: /var/www/html

Explanation:

  • name provides a human-readable description of the playbook.

  • hosts specify which group of servers (defined in the inventory) the playbook will run on.

  • become allows the tasks to run with elevated privileges (e.g., using sudo).

  • tasks is a list of actions Ansible will perform, such as installing Nginx and copying web application files.

Ansible Roles

For this project, I utilized Ansible Roles. Before I dive into my solution, let me first provide a brief explanation of what Ansible Roles are.

Ansible roles allow you to organize and reuse Ansible tasks, variables, and handlers in a modular and structured way.

They contribute to the breakdown of complicated playbooks into smaller, more manageable components.

As your infrastructure grows, roles help manage increasing complexity by keeping tasks organized and easier to scale.

A role typically includes:

  1. Tasks: The actions to be performed (e.g., installing packages, configuring services).

  2. Handlers: Special tasks that are triggered by other tasks (e.g., restarting a service after configuration changes).

  3. Variables: Definitions of configuration options used within the role.

  4. Defaults: Default values for variables that can be overridden.

  5. Files: Static files that need to be copied to the remote hosts.

  6. Templates: Jinja2 templates that are processed and copied to the remote hosts.

  7. Meta: Metadata about the role, such as dependencies on other roles.

Alongside the hosts.ini, ansible.cfg, and playbook.yml files, you need to create a directory called roles inside the app directory if you decide to use Ansible Roles

zenitugo@Zenitugo:~/app$ mkdir roles

Each component will have a role inside the role directory.

Example of Ansible Role Structure

To create a role named webserver inside the roles directory, run the command

ansible-galaxy init webserver

Below is the result of this command.

roles/
  └── webserver/
      ├── tasks/
      │   └── main.yml
      ├── handlers/
      │   └── main.yml
      ├── vars/
      │   └── main.yml
      ├── defaults/
      │   └── main.yml
      ├── files/
      ├── templates/
      └── meta/
          └── main.yml

Solution Overview: How I Deployed My Application with Ansible Roles

For this task, I deployed this application in an AWS EC2 instance. You can use any VM in any cloud provider be it AWS, Azure, or GCP.

When you are done launching a new virtual machine, copy the public IP address as you will need to add this in the hosts.ini file.

The Inventory file

[webserver]
52.5.71.178 

[all:vars]
ansible_user=ubuntu 
ansible_connection=ssh 
ansible_python_interpreter=/usr/bin/python3

The [webserver] the section defines a group named webserver containing the EC2 instance with IP 52.5.71.178. The [all:vars] section sets variables for all hosts, specifying that Ansible should connect as the ubuntu user, use SSH for connections and utilize the Python 3 interpreter located at /usr/bin/python3 on the remote host.

The Ansible configuration file

[defaults]
hosts = /home/zenitugo/ansible/hosts.ini
default_playbook = /home/zenitugo/ansible/playbook.yml
private_key_file = /home/zenitugo/.ssh/hng.pem
host_key_checking = False
ask_become_pass = False
become_method = sudo
allow_world_readable_tmpfiles = true

The [defaults] section in your Ansible configuration file specifies the default settings for your Ansible operations. It sets the inventory file /home/zenitugo/ansible/hosts.ini and the default playbook to /home/zenitugo/ansible/playbook.yml.

The private key for SSH authentication is defined as /home/zenitugo/.ssh/hng.pem. Host key checking is disabled, meaning Ansible will not prompt about unknown SSH hosts.

It does not prompt for privilege escalation passwords (ask_become_pass is False), and uses sudo for privilege escalation. Additionally, it allows temporary files created by Ansible to be world-readable. These settings streamline the execution of Ansible tasks and playbooks by predefining key parameters.

The Playbook

---
# Play: To deploy a fast api application
- name: Deploy and Configure FastAPI Boilerplate Application
  hosts: webserver
  become: yes
  become_method: sudo
  roles:
    - user
    - packages
    - clone
    - postgres
    - app_deploy  
    - nginx

The playbook automates the deployment and configuration of a FastAPI application on the webserver hosts.

It uses sudo for administrative tasks and includes roles for managing users, installing packages, cloning the application repository, setting up PostgreSQL, deploying the application, configuring Nginx, and handling logging.

Each role focuses on a specific part of the deployment process, ensuring an organized and modular approach. They are lined up in the order in which they are expected to be created.

The Ansible Roles

User Role

The first task was to create a user with sudo privileges. To achieve this create a role called user with the command ansible-galaxy init user. From the sub-directories generated you only need the var and tasks subdirectory.

vars/main.yml

---
# vars file for user
name: roland
home_path: /home/roland
sudoers: /etc/sudoers
line: "hng ALL=(ALL) NOPASSWD: ALL"
validate: "visudo -cf %s"

tasks/main.yml

---
# tasks file for user
- name: Create Roland user
  ansible.builtin.user:
    name: "{{ name }}"
    create_home: yes
  register: create_user_output 

- name: Set permissions for hng user's home directory
  ansible.builtin.file:
    path: "{{ home_path }}"
    mode: '0750'
    owner: "{{ name }}"
    group: "{{ name }}"
  become: yes
  register: user_permissions_output 

- name: Add roland user to sudoers
  lineinfile:
    path: "{{ sudoers }}"
    line: "{{ line }}"
    validate: "{{ validate }}"
  register: priviledge_action_output

Package Role

At this point, you want to Install all dependencies required for your application to run.

vars/main.yml

---
# vars file for packages
package_list:
  - python3
  - python3-pip
  - python3-venv
  - python3-psycopg2
  - git
  - acl
  - postgresql
  - postgresql-contrib
  - libpq-dev

tasks/main.yml

---
# tasks file for packages

- name: Update apt cache
  apt:
    update_cache: yes
  become: yes

- name: Install common packages
  ansible.builtin.apt:
    pkg: "{{ package_list }}"
    state: present
  register: package_installed_output

This task file is used for managing package installations on a system. It first updates the APT package cache to ensure the latest package information is available.

Then, it installs a list of common packages specified by the package_list variable, ensuring they are present in the system. The result of the package installation is stored in package_installed_output.

Clone Role

The next part of the task was to clone the devops branch of the application's repository into the /opt directory with the name stage_5b (i.e., /opt/stage_5b), and ensure it is owned by the Roland user.

vars/main.yml

---
# vars file for clone
repo: "https://github.com/hngprojects/hng_boilerplate_python_fastapi_web.git"
version: devops
dest: /opt/stage_5b
path: /opt/stage_5b

tasks/main.yml

---
# tasks file for clone
- name: Create a directory if it does not exist
  ansible.builtin.file:
    path: "{{ path }}"
    state: directory
    mode: '0755'
  register: clone_directory_output


- name: Add repository to Git safe.directory
  command: git config --global --add safe.directory /opt/stage_5b
  become: yes
  register: gitrepo_status_output


- name: Check if repository already exists
  stat:
    path: /opt/stage_5b
  register: repo_stat

- name: Clone repository
  ansible.builtin.git:
    repo: "{{ repo }}"
    version: "{{ version }}"
    dest: "{{ dest }}"
    single_branch: yes
  become: yes
  register: clone_repo_output

- name: Set ownership of cloned repository
  ansible.builtin.file:
    path: "{{ path }}"
    owner: rolnd
    group: roland
    recurse: yes
  register: repo_owner_output

This task file handles cloning a Git repository. It first ensures the target directory exists and sets its permissions. It then adds the repository directory to Git's safe.directory configuration.

It checks if the repository already exists, and if not, it clones the repository to the specified location. Finally, it sets the ownership of the cloned repository to a specific user and group, recursively applying these permissions.

Postgres Role

The next part of the task was to set up PostgreSQL, save the admin user and credentials in /var/secrets/pg_pw.txt.

vars/main.yml

dest: /var/secrets/pg_pw.txt
db_user: hng
db_host: localhost 
db_port: 5432
db_name: fury

tasks/main.yml

---
# tasks file for postgres
- name: Generate PostgreSQL password
  command: openssl rand -base64 12
  register: pg_password_output

- name: Ensure /var/secrets directory exists
  ansible.builtin.file:
    path: /var/secrets
    state: directory
    mode: '0750'
    owner: hng
    group: hng
  become: yes
  register: secrets_dir_output

- name: Set password for postgres user
  set_fact:
    pg_password: "{{ pg_password_output.stdout }}"

- name: Save PostgreSQL credentials
  ansible.builtin.copy:
    content: "{{ pg_password }}"
    dest: "{{ dest }}"
    mode: '0600'
  become: yes
  register: postgres_cred_output

- name: Update PostgreSQL authentication method
  ansible.builtin.lineinfile:
    path: /etc/postgresql/16/main/pg_hba.conf
    regexp: '^local\s+all\s+postgres\s+peer'
    line: 'local   all   postgres   trust'
  become: yes
  notify: Restart PostgreSQL
  register: postgres_auth_output

- name: Ensure listen_addresses is set to '*'
  ansible.builtin.lineinfile:
    path: /etc/postgresql/16/main/postgresql.conf
    regexp: "^#listen_addresses = 'localhost'"
    line: "listen_addresses = '*'"
    state: present
    backup: yes

- name: Allow all connections in pg_hba.conf
  ansible.builtin.lineinfile:
    path: /etc/postgresql/16/main/pg_hba.conf
    line: "host       all       all        0.0.0.0/0       trust"
    insertafter: EOF
  become: yes
  notify: Restart PostgreSQL 

- name: Create PostgreSQL database
  community.postgresql.postgresql_db:
    name: "{{ db_name }}" 
    state: present
  become_user: postgres
  register: postgres_db_output

- name: Create PostgreSQL user
  become: yes
  community.postgresql.postgresql_user:
    name: "{{ db_user }}"
  become_user: postgres  
  register: postgres_user_output

- name: Grant all privileges on database
  community.postgresql.postgresql_privs:
    db: "{{ db_name }}"
    role: "{{ db_user }}"
    password: "{{ pg_password }}"
    type: database
    privs: ALL
  become_user: postgres

- name: Create dummy table
  community.postgresql.postgresql_query:
    db: "{{ db_name }}"
    query: "CREATE TABLE IF NOT EXISTS dummy_table (id SERIAL PRIMARY KEY, name VARCHAR(50), age INT);"
  become_user: postgres

- name: Insert dummy data
  community.postgresql.postgresql_query:
    db: "{{ db_name }}"
    query: "INSERT INTO dummy_table (name, age) VALUES ('John Doe', 30), ('Jane Doe', 25);"
  become_user: postgres

handlers/main.yml

- name: Ensure PostgreSQL is running
  ansible.builtin.systemd:
    name: postgresql
    state: started
    enabled: true
  become: yes

- name: Restart PostgreSQL
  ansible.builtin.systemd:
    name: postgresql
    state: restarted
  become: yes

The tasks file for PostgreSQL in Ansible begins by installing the necessary PostgreSQL packages. A random password for the PostgreSQL user is generated openssl and stored securely in the /var/secrets directory. The PostgreSQL user password is set and saved, ensuring secure access.

The PostgreSQL authentication method pg_hba.conf is updated to allow trust authentication for the Postgres user, and the listen_addresses configuration is modified to allow connections from any IP address. Additionally, a rule is added to pg_hba.conf permit all connections. The PostgreSQL service is notified to restart whenever configuration changes are made.

A PostgreSQL database and user are created with the specified names, and all privileges on the database are granted to the user. Finally, a dummy table is created in the database, and sample data is inserted into this table, demonstrating basic CRUD operations and ensuring the database is correctly configured and operational.

App_deploy Role

templates/env.j2

# Add your environment variables here

PYTHON_ENV=dev

DB_TYPE=postgresql
DB_NAME=fury
DB_USER=hng
DB_PASSWORD=fury001
DB_HOST="localhost"
DB_PORT=5432
MYSQL_DRIVER=
DB_URL=postgresql://"{{ db_user }}:"{{ pg_password }}"@localhost:5432/"{{ db_name }}"
SECRET_KEY = ""
ALGORITHM = HS256
ACCESS_TOKEN_EXPIRE_MINUTES = 30
JWT_REFRESH_EXPIRY=5
APP_URL=

GOOGLE_CLIENT_ID=""
GOOGLE_CLIENT_SECRET=""

FRONTEND_URL='http://127.0.0.1:3000/login-success'

TESTING=''

MAIL_USERNAME=""
MAIL_PASSWORD=""
MAIL_FROM=""
MAIL_PORT=465
MAIL_SERVER="smtp.gmail.com"

tasks/main.yml

---
# tasks file for app_deploy\
- name: Create virtual environment
  ansible.builtin.command:
    cmd: python3 -m venv /opt/stage_5b/.venv
    creates: /opt/stage_5b/.venv
  become: yes
  become_user: hng
  register: venv_created

- name: Install project dependencies
  ansible.builtin.pip:
    requirements: /opt/stage_5b/requirements.txt
    virtualenv: /opt/stage_5b/.venv
  become: yes
  become_user: hng

- name: Create an env file
  ansible.builtin.template:
    src: env.j2
    dest: /opt/stage_5b/.env
  become: yes
  become_user: hng

- name: Start FastAPI application
  ansible.builtin.shell: |
    source /opt/stage_5b/.venv/bin/activate
    nohup uvicorn main:app --host 127.0.0.1 --port 3000 > /var/log/stage_5b/out.log 2> /var/log/stage_5b/error.log &
  args:
    executable: /bin/bash
    chdir: /opt/stage_5b
  become: yes
  become_user: hng
  environment:
    PATH: "/opt/stage_5b/.venv/bin:{{ ansible_env.PATH }}"
  register: api_started

- name: Ensure FastAPI application is running
  wait_for:
    port: 3000
    delay: 5
    timeout: 30

handlers/main.yml

---
# handlers file for app_deploy
- name: Restart FastAPI application
  shell: |
    pkill -f "python /opt/stage_5b/main.py"
    source /opt/stage_5b/.venv/bin/activate
    nohup uvicorn main:app --host 127.0.0.1 --port 3000 > /var/log/stage_5b/out.log 2> /var/log/stage_5b/error.log &
  args:
    executable: /bin/bash
    chdir: /opt/stage_5b
  become: yes
  become_user: hng
  environment:
    PATH: "/opt/stage_5b/.venv/bin:{{ ansible_env.PATH }}"

The tasks file for app deployment with Ansible sets up and runs a FastAPI application. It starts by creating a virtual environment in the specified directory. Next, it installs project dependencies from a requirements.txt file within this virtual environment. An environment file is then created using a template.

The FastAPI application is started using uvicorn, with logs directed to specific files. The wait_for module ensures the application is running on the specified port.

The handlers file includes a task to restart the FastAPI application, stopping any running instances and starting it again, ensuring the application runs with the updated configuration and environment.

Nginx Role

templates/nginx.conf.j2

server {
    listen 80;

    server_name localhost;
    location / {
        proxy_pass http://127.0.0.1:3000;
        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_set_header X-Forwarded-Proto $scheme;
    }

    error_log /var/log/stage_5b/error.log;
    access_log /var/log/stage_5b/out.log;
}

tasks/main.yml

---
# tasks file for nginx
- name: Add the Nginx signing key
  ansible.builtin.apt_key:
    url: https://nginx.org/keys/nginx_signing.key
    state: present

- name: Add the Nginx APT repository
  ansible.builtin.apt_repository:
    repo: deb [arch=amd64] http://nginx.org/packages/ubuntu jammy nginx
    state: present
  register: nginx_repo_output

- name: Update the APT cache
  ansible.builtin.apt:
    update_cache: yes

- name: Install Nginx 1.26
  ansible.builtin.apt:
    name: nginx=1.26.0-1~jammy
    state: present
  register: install_nginx_output

- name: Remove default Nginx site configuration
  file:
    path: /etc/nginx/conf.d/default.conf
    state: absent
  notify:
    - Restart Nginx   

- name: Create new default configuration from template
  template:
    src: nginx.conf.j2
    dest: /etc/nginx/conf.d/default.conf
  notify:
    - Restart Nginx

handlers/main.yml

---
# handlers file for nginx
- name: Start and enable Nginx service
  ansible.builtin.systemd:
    name: nginx
    state: started
    enabled: yes
- name: Restart Nginx
  ansible.builtin.systemd:
    name: nginx
    state: restarted

The tasks file for Nginx in Ansible begins by adding the Nginx signing key and APT repository for Ubuntu Jammy. It updates the APT cache and installs Nginx version 1.26.

The default Nginx site configuration is removed, and a new configuration is created from a template. This configuration file sets Nginx to listen on port 80, proxies requests to the FastAPI application running on port 3000, and logs errors and access to specified files. Nginx is notified to restart whenever the configuration changes are made.

Usage

To deploy the application, run this command on your terminal:

ansible-playbook -i hosts.ini playbook.yml

Proof of deployment

Conclusion

Throughout this article, we looked at how to utilize Ansible to deploy a FastAPI application, manage dependencies, configure PostgreSQL, and configure Nginx as a reverse proxy.

Ansible's modular approach, which includes the usage of roles and templates, helps you keep your codebase clean and organized. With Ansible, you can confidently manage your infrastructure while focusing on what is most important—building and enhancing your apps.