Automate Laravel Application Deployment Across Multiple Servers and Databases Using Ansible

Countyemi
9 min readApr 11, 2024

--

This article will show you step by step guide on how to deploy a laravel application on multiple servers and database, using ansible.

The application is a simple VAT calculator.

Login with e-mail and password

enter amount to calculate and VAT percentage

Even though this is a laravel specific article. You can follow the steps for other applications as long as you understand the dependencies and requirements.

This is what we will be creating together.

I have used AWS for this. You can follow along with GCP or AZURE. Doesn’t matter.

We will host our application on our webservers with apache and expose them on port 80. Because we will be using more than one server. Users will access our application through a load balancer. We will also configure our application to access a database server for data storage.

Since we will be configuring multiple servers, ansible will be our configuration management tool.

Lets get started.

Before we create our resources, lets define and create the security groups.

let us create the security groups and attach them appropriately.

Load-balancer-SG will allow http on port 80 from anywhere

Ansible-server-SG will allow ssh on port 22 from my ip-address

Web-servers-SG will allow http on port 80 from the load balancer security group only

Web servers-SG will also allow ssh on port 22 from ansible security group

Db server-SG will allow TCP on port 3306 from web servers security group.

Now we can create the resources and attach the security groups appropriately.

I have created four servers. All running on Ubuntu linux.

I have also created an ansible user with sudo rights on the db and web servers.

Next, create our load balancer.

For this, we will create a target group, then register our web servers in this target group.

Once this is done, we will create a load balancer and configure it to route http requests to this target group.

Lets create a target group.

Next is to create the load balancer.

Our load balancer is active, so when our application is running, we can access it through the DNS.

Welldone on creating the needed resources.

Ensure no mistakes are made in creating and attaching these security groups.

Once this is done, we are all set to connect to our ansible server to:

Install ansible

Configure host file

Install packages and serve our application.

lets install ansible with the installation script:

#!/bin/bash
sudo apt update
sudo apt install software-properties-common
sudo add-apt-repository --yes --update ppa:ansible/ansible
sudo apt install ansible –y

Configure host file.

I have configured the host file using the private-IP of web and db servers as well as the path to the connection key.

Here is what the host file looks like.

Install packages serve our application.

To achieve this, we will break our process to 3 parts.

- Install and set up db server

- Install and set up web server

- Application deployment

This will be the three roles in our ansible play-book.

Role1: install and set up db server.

Our task is to:

- Install MySQL server

- Create a database for our application

- Create a user to manage the database with

  • Allow remote connection into the MySQL server



---
- name: update cache
ansible.builtin.apt:
update_cache: yes
state: latest

- name: install mysql server
ansible.builtin.apt:
name: mysql-server
state: present
#This is a requirement for the host #https://docs.ansible.com/ansible/2.9/modules/mysql_user_module.html
- name: install PyMySQL for Python3.X on host
ansible.builtin.apt:
name: python3-pymysql
state: present

- name: start mysql server
ansible.builtin.service:
name: mysql
state: started
enabled: yes

- name: create application database
ansible.builtin.mysql_db:
name: "{{ app_db }}"
login_unix_socket: /var/run/mysqld/mysqld.sock
state: present

- name: create app user & grant database privilege
ansible.builtin.mysql_user:
name: "{{ app_user }}"
host: "%"
password: "{{ app_password }}"
priv: "{{ app_db }}.*:ALL"
login_unix_socket: /var/run/mysqld/mysqld.sock
state: present

- name: update mysql to listen on 0.0.0.0
ansible.builtin.replace:
path: /etc/mysql/mysql.conf.d/mysqld.cnf
regexp: '^(bind-address|mysqlx-bind-address)\s*=\s*127\.0\.0\.1'
replace: '\1 = 0.0.0.0'

notify: restart mysql

Role2: Install and set up web server.

Since this is a laravel project, you can find the dependencies and server requirements here:

https://laravel.com/docs/11.x/deployment

For our tasks here:

- We will install apache2, php and git on the web servers

- Install php dependencies for the laravel application

- Configure apache2 to serve php contents

  • Serve web contents form laravel root folder (*/public)
---
- name: update cache
ansible.builtin.apt:
update_cache: yes
state: latest

- name: add php repo
ansible.builtin.apt_repository:
repo: 'ppa:ondrej/php'
state: present


- name: update cache after adding repository
ansible.builtin.apt:
update_cache: yes
state: latest

- name: install dependencies for laravel
apt:
name:
- php8.2
- php8.2-mysql
- php8.2-intl
- php8.2-curl
- php8.2-mbstring
- php8.2-xml
- php8.2-zip
- php8.2-ldap
- php8.2-gd
- php8.2-bz2
- php8.2-sqlite3
- php8.2-redis
- php8.2-cli
- php8.2-fpm
- unzip
- git
- apache2
- libapache2-mod-php8.2
state: present

- name: enable Apache rewriting mode
ansible.builtin.command: a2enmod rewrite
#The dir.conf contains the default order web files will load
#<IfModule mod_dir.c>
# DirectoryIndex index.php index.html index.cgi index.pl index.xhtml #index.htm
#</IfModule>

- name: replace apache dir.conf file
ansible.builtin.copy:
src: dir.conf
dest: /etc/apache2/mods-enabled/dir.conf
owner: root
group: root
mode: '0644'

- name: Change ownership of /etc/apache2/sites-available directory
ansible.builtin.file:
path: /etc/apache2/sites-available/
owner: ansible
group: ansible
recurse: yes


- name: change the root folder for laravel
ansible.builtin.lineinfile:
path: /etc/apache2/sites-available/000-default.conf
regexp: '^(\s*DocumentRoot\s+)(.*)$'
line: 'DocumentRoot /var/www/count/public'

- name: Add <Directory> block to grant access to the files in */public
ansible.builtin.lineinfile:
path: /etc/apache2/sites-available/000-default.conf
insertafter: '^(\s*DocumentRoot\s+.*)$'
line: |
<Directory /var/www/count/public>
Options Indexes FollowSymLinks
AllowOverride All
Require all granted
</Directory>
notify: restart apache2



- name: enable php module in apache
ansible.builtin.command: a2enmod php8.2

Role3: Application deployment.

To deploy our application we will:

- Download composer

- Clone the repository for the application code

- Copy the environment variable file (.env) to the root directory

- Generate the application key for encryption

- Migrate our database schema

  • Populate database with initial data
---
- name: download composer
ansible.builtin.get_url:
url: "https://getcomposer.org/installer"
dest: "/usr/bin/composer.php"

- name: run composer
ansible.builtin.shell: "php /usr/bin/composer.php --install-dir=/usr/bin --filename=composer"

- name: cloning git repository
ansible.builtin.git:
repo: "{{ github_repo }}"
dest: ~/count

- name: Copy the repository to /var/www
ansible.builtin.copy:
src: ~/count
dest: /var/www
remote_src: yes


- name: Change owner of /var/www/count to ansible
ansible.builtin.file:
path: /var/www/count/
owner: ubuntu
group: ubuntu
recurse: yes


- name: Run composer install in the count directory
ansible.builtin.command:
cmd: php /usr/bin/composer install --no-ansi --no-dev --no-interaction --no-progress --optimize-autoloader --no-scripts
chdir: /var/www/count/

- name: copy .env file to app directory
ansible.builtin.copy:
src: ".env.j2"
dest: "/var/www/count/.env"

- name: generate application key
ansible.builtin.command: php artisan key:generate
args:
chdir: "/var/www/count/"


- name: Change owner of storage and cache directories
ansible.builtin.file:
path: "{{ item }}"
owner: www-data
recurse: yes
loop:
- "/var/www/count/storage"
- "/var/www/count/bootstrap/cache"


- name: Migrate database schema
ansible.builtin.command: php artisan migrate
args:
chdir: "/var/www/count/"

- name: Seed initial data
ansible.builtin.command: php artisan db:seed
args:
chdir: "/var/www/count/"




last step here is to define how each tasks and roles will execute.

- name: setup db
become: yes
hosts: db
roles:
- db_setup

- name: setup web
become: yes
hosts: web
roles:
- web_setup
- deploy_setup

With all tasks, roles and play define. This is the structure of my ansible playbook.

Now, lets run our playbook

The error shows ansible server could not connect to the db server.

Cause:

The db security group only currently allows TCP on port 3306 from web servers SG.

Solution:

edit inbound rule to allow ssh from ansible-SG

Now, lets try again.

Goodnews: All DB tasks ran successfully.

Badnews: our update cache task failed due to an unknown reason

After troubleshooting, I realized the servers were unable to download updates because I didn’t configure a way for them to connect to the internet. Since I only assigned private-IPs to them.

I will get into the details of how I resolved this in a different post.

For now, if you encounter this issue. You can relaunch the web-servers with a public-IP.

Lets try again.

Goodnews: all tasks ran

Badnews: theres one bad apple. Looks really bad.

Lets check it out.

fatal: [web2]: FAILED! => {"changed": true, "cmd": ["php", "artisan", "migrate"],
"delta": "0:00:00.238743", "end": "2024-04-11 19:03:41.789734",
"msg": "non-zero return code", "rc": 1,
"start": "2024-04-11 19:03:41.550991", "stderr": "",
"stderr_lines": [], "stdout": "\n INFO Preparing database. \n\n Creating migration table .............. 25ms FAIL\n\nIn Connection.php line 829:\n
\n SQLSTATE[42S01]: Base table or view already exists: 1050 Table 'migrations'
\nalready exists (Connection: mysql, SQL: create table `migrations` (`id` in \n
t unsigned not null auto_increment primary key, `migration` varchar(255) no \n t null, `batch` int not null)
default character set utf8mb4 collate 'utf8mb \n 4_unicode_ci') \n
\n\nIn Connection.php line 587:\n
\n SQLSTATE[42S01]: Base table or view already exists: 1050 Table 'migrations' \n
already exists \n ",
"stdout_lines": ["", " INFO Preparing database. ", "", " Creating migration table ......................................... 25ms FAIL", "",
"In Connection.php line 829:", " ", " SQLSTATE[42S01]: Base table or view already exists: 1050
Table 'migrations' ", " already exists (Connection: mysql, SQL: create table `migrations` (`id` in ", " t unsigned not null auto_increment primary key, `migration` varchar(255) no ",
" t null, `batch` int not null) default character set utf8mb4 collate 'utf8mb ", " 4_unicode_ci')
", " ", "", "In Connection.php line 587:", " ", " SQLSTATE[42S01]: Base table or view already exists: 1050 Table 'migrations' ", " already exists ", "
"]}

This was when migrating the database schema. So, the database schema already exists on the db server because the code already ran on web-server-1.

The solution to this is to modify the application code.

Lets try again.

GOODNEWS!!!

Now it’s time to test our application.

Using the load balancer’s DNS. We are able to access the application, and login with the initial seed data.

And That wraps it up.

--

--