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:
Tasks: The actions to be performed (e.g., installing packages, configuring services).
Handlers: Special tasks that are triggered by other tasks (e.g., restarting a service after configuration changes).
Variables: Definitions of configuration options used within the role.
Defaults: Default values for variables that can be overridden.
Files: Static files that need to be copied to the remote hosts.
Templates: Jinja2 templates that are processed and copied to the remote hosts.
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.