DEVOPS Mastery: Automating PHP Web app Deployment with GitHub Actions, AWS, and Ansible

Nitesh Rijal - Oct 20 - - Dev Community

A comprehensive guide to building a secure, scalable CI/CD pipeline for any PHP-based application, including WordPress, Drupal, and custom solutions

In today’s fast-paced web development world, automating deployment processes isn’t just a luxury — it’s a necessity. Whether you’re managing a popular CMS like WordPress, a robust e-commerce platform, or a custom PHP application, the challenges of consistent, secure, and efficient deployments remain the same. This guide will walk you through creating a powerful, flexible automation solution that can handle any PHP-based web application with ease.

We’ll use SuiteCRM, an open-source customer relationship management system, as our primary example. However, the principles and techniques we’ll cover are universally applicable to any PHP application. From setting up a CI/CD pipeline with GitHub Actions to provisioning infrastructure with AWS CloudFormation, and configuring servers with Ansible, we’ll explore a comprehensive DevOps approach that will revolutionize your deployment process.

Introduction

This tutorial provides a comprehensive guide to automating the deployment of SuiteCRM using GitHub Actions, AWS CloudFormation, and Ansible. We'll cover the entire process, including all necessary code for GitHub Actions workflows and Ansible roles.

But we won’t stop at just automation. We’ll delve into crucial aspects like securely managing secrets and variables, ensuring that your deployment pipeline is not only efficient but also adheres to the highest security standards. Whether you’re a seasoned DevOps engineer or a PHP developer looking to streamline your workflow, this guide will equip you with the tools and knowledge to master modern deployment strategies.

Ready to transform your PHP application deployments from time-consuming manual processes to streamlined, automated workflows? Let’s dive in and explore the world of DevOps mastery!

Let’s understand how our process looks like from the figures below

Figure 1: Overview of our automated PHP application deployment pipeline, from code push to server configuration.

Image description

Figure 2: Detailed view of the AWS infrastructure created by our CloudFormation template, including VPC, subnets, and security groups.

Image description

Figure 3: Flowchart of our GitHub Actions workflow, showing the steps from initial setup to deployment of infrastructure and application.

Image description

Why Automate SuiteCRM Deployment?

Automating SuiteCRM deployment offers numerous benefits:

  1. Consistency across deployments
  2. Rapid deployment and updates
  3. Scalability for growing businesses
  4. Version control for infrastructure and configuration
  5. Reduced human error
  6. Cost-effectiveness in the long run
  7. Improved security through standardized processes
  8. Easier application of updates and patches

Overview of the Automation Stack

Our automation stack consists of:

  1. GitHub Actions for CI/CD pipeline
  2. AWS CloudFormation for infrastructure as code
  3. Ansible for configuration management and application deployment

Step-by-Step Tutorial

1. Setting Up the GitHub Repository

Create a GitHub repository with the following structure:

.
├── .github
│   └── workflows
│       ├── main.yaml
│       ├── infra.yaml
│       └── crm.yaml
├── infrastructure
│   ├── prod.yaml
│   └── common
│       ├── vpc
│       │   ├── vpc.yaml
│       │   └── eip.yaml
│       └── ec2
│           └── ec2.yaml
└── crm
    └── ansible
        ├── ansible.cfg
        ├── main.yml
        └── roles
            ├── apache2
            ├── php
            ├── sql
            └── suitecrm
Enter fullscreen mode Exit fullscreen mode

2. Setting Up AWS IAM Roles

Create two IAM roles in AWS:

  1. A role for GitHub Actions with permissions to manage CloudFormation, EC2, S3, and Route53.
  2. A role for CloudFormation with permissions to create and manage AWS resources.

3. Configuring GitHub Secrets and Variables

In your GitHub repository settings, add the following:

Secrets:

  • AWS_ROLE_TO_ASSUME: ARN of the GitHub Actions IAM role
  • CLOUDFORMATION_ROLE_ARN: ARN of the CloudFormation IAM role
  • CRM_KEY: SSH key for EC2 access

Variables:

  • PROD_STACK_NAME: Name for your CloudFormation stack
  • PROD_CFN_S3_BUCKET: S3 bucket for CloudFormation templates
  • CHANGE_SET_NAME: Name for CloudFormation change sets
  • HOSTED_ZONE_ID: Your Route 53 hosted zone ID
  • CRM_DomainName: Domain for your SuiteCRM installation

4. Setting Up GitHub Actions Workflows

Create the following workflow files in the .github/workflows/ directory:

main.yaml

name: Main Class File

on:
  workflow_call:
    inputs:
      aws-region:
        required: false
        type: string
        default: 'us-east-1'
      role-session-name:
        required: false
        type: string
        default: 'GitHubActions'
    secrets:
      AWS_ROLE_TO_ASSUME:
        required: true
    outputs:
      cache-hit:
        description: "Whether the cache was hit"
        value: ${{ jobs.setup.outputs.cache-hit }}

jobs:
  setup:
    runs-on: ubuntu-latest
    outputs:
      cache-hit: ${{ steps.cache.outputs.cache-hit }}
    steps:
      - name: Checkout
        uses: actions/checkout@v3

      - name: Cache AWS configuration and custom scripts
        id: cache
        uses: actions/cache@v3
        with:
          path: |
            ~/.aws
            ~/.local/bin
          key: ${{ runner.os }}-aws-config-${{ hashFiles('**/package-lock.json', '**/requirements.txt') }}

      - name: Configure AWS Credentials
        if: steps.cache.outputs.cache-hit != 'true'
        uses: aws-actions/configure-aws-credentials@v4
        with:
          role-to-assume: ${{ secrets.AWS_ROLE_TO_ASSUME }}
          aws-region: ${{ inputs.aws-region }}
          role-session-name: ${{ inputs.role-session-name }}

      - name: Install aws cli and other packages
        if: steps.cache.outputs.cache-hit != 'true'
        run: |
          sudo apt-get update
          sudo apt-get install -y unzip zip curl awscli net-tools ansible

      - name: Save environment variables
        if: steps.cache.outputs.cache-hit != 'true'
        run: |
          env | grep -E "AWS_|ANSIBLE_" > $GITHUB_ENV
Enter fullscreen mode Exit fullscreen mode

infra.yaml

name: Deploy or Update SuiteCRM

permissions:
  id-token: write
  contents: read
on:
  push:
    branches: 
     - main
    paths:
      - 'infrastructure/**'

jobs:
  setup:
    uses: ./.github/workflows/main.yaml
    secrets:
      AWS_ROLE_TO_ASSUME: ${{ secrets.AWS_ROLE_TO_ASSUME }}
    with:
      aws-region: us-east-1
      role-session-name: 'InfraDeployment'

  deploy:
    needs: setup
    runs-on: ubuntu-latest
    steps:
      - name: Checkout
        uses: actions/checkout@v3

      - name: Configure AWS Credentials
        uses: aws-actions/configure-aws-credentials@v4
        with:
          role-to-assume: ${{ secrets.AWS_ROLE_TO_ASSUME }}
          aws-region: us-east-1
          role-session-name: "SuiteCRM"

      - name: Package the CloudFormation template
        run: | 
          aws cloudformation package --debug \
          --template-file prod.yaml  \
          --output-template-file ${{ vars.PROD_STACK_NAME }}-packaged.template \
          --s3-bucket ${{ vars.PROD_CFN_S3_BUCKET }} \
          --s3-prefix ${{ vars.PROD_STACK_NAME }}
        working-directory: infrastructure

      - name: Deploy the CloudFormation stack
        run: |
          MY_IP=$(curl -s https://ifconfig.me)
          MY_IP_CIDR="${MY_IP}/32"
          aws cloudformation deploy \
            --s3-bucket ${{ vars.PROD_CFN_S3_BUCKET }} \
            --s3-prefix ${{ vars.PROD_STACK_NAME }} \
            --template-file ${{ vars.PROD_STACK_NAME }}-packaged.template \
            --stack-name ${{ vars.PROD_STACK_NAME }} \
            --capabilities CAPABILITY_IAM CAPABILITY_NAMED_IAM CAPABILITY_AUTO_EXPAND \
            --role-arn ${{ secrets.CLOUDFORMATION_ROLE_ARN }} \
            --parameter-overrides \
              MyIp=$MY_IP_CIDR \
              HostedZoneId=${{ vars.HOSTED_ZONE_ID }} \
              CRMDomainName=${{ vars.CRM_DomainName }} \
            --no-fail-on-empty-changeset
        working-directory: infrastructure
Enter fullscreen mode Exit fullscreen mode

crm.yaml

name: Configure SuiteCRM

permissions:
  id-token: write
  contents: read

on:
  push:
    branches: 
     - main
    paths:
      - 'crm/**'

jobs:
  setup:
    uses: ./.github/workflows/main.yaml
    secrets:
      AWS_ROLE_TO_ASSUME: ${{ secrets.AWS_ROLE_TO_ASSUME }}
    with:
      aws-region: us-east-1
      role-session-name: 'InfraDeployment'
  deploy:
    needs: setup
    runs-on: ubuntu-latest
    steps:
      - name: Checkout
        uses: actions/checkout@v3

      - name: Configure AWS Credentials
        uses: aws-actions/configure-aws-credentials@v4
        with:
          role-to-assume: ${{ secrets.AWS_ROLE_TO_ASSUME }}
          aws-region: us-east-1
          role-session-name: "SuiteCRM"

      - name: Get CloudFormation Outputs
        id: cfn_outputs
        run: |
          OUTPUTS=$(aws cloudformation describe-stacks --stack-name ${{ vars.PROD_STACK_NAME }} --query "Stacks[0].Outputs" --output json)
          echo "Outputs: $OUTPUTS"
          echo "::set-output name=elastic_ip::$(echo $OUTPUTS | jq -r '.[] | select(.OutputKey == "ElasticIp") | .OutputValue')"
          echo "::set-output name=domain_name::$(echo $OUTPUTS | jq -r '.[] | select(.OutputKey == "Route53DomainName") | .OutputValue')"

      - name: Create Ansible Inventory File
        run: |
          echo "[crm]" > inventory.ini
          echo "${{ steps.cfn_outputs.outputs.elastic_ip }} ansible_user=ubuntu ansible_ssh_private_key_file=id_rsa" >> inventory.ini
        working-directory: crm/ansible

      - name: Get ssh keys
        env:
          CRM_KEY: ${{ secrets.CRM_KEY }}
        run: |
          echo "$CRM_KEY" > id_rsa
          chmod 400 id_rsa
        working-directory: crm/ansible

      - name: Run Ansible Playbook
        run: |
          ansible-galaxy install mahdi22.mariadb_install
          ansible-playbook -i inventory.ini main.yml --extra-vars "domain_name=${{ steps.cfn_outputs.outputs.domain_name }}"
        working-directory: crm/ansible
Enter fullscreen mode Exit fullscreen mode

5. Creating Ansible Roles

Create the following Ansible roles in the crm/ansible/roles/ directory:

apache2/tasks/main.yml

---
# tasks file for apache2

- name: Update the Cache
  apt:
    update_cache: yes

- name: Install Apache2
  apt: name=apache2 state=present

- name: Start Apache2
  service: name=apache2 state=started enabled=yes
Enter fullscreen mode Exit fullscreen mode

php/tasks/main.yml

---
# tasks file for php

- name: Add PHP PPA repository
  apt_repository:
    repo: ppa:ondrej/php
    state: present

- name: Update the Cache
  apt:
    update_cache: yes

- name: Install PHP Packages
  apt:
    name: "{{ item }}"
    state: present
  loop: "{{ packages }}"
  tags: packages

- name: Set upload_max_filesize to 100M
  lineinfile:
    path: /etc/php/{{ php_version }}/apache2/php.ini
    regexp: '^upload_max_filesize ='
    line: 'upload_max_filesize = 100M'
  notify: Restart Apache

- name: Set post_max_size to 100M
  lineinfile:
    path: /etc/php/{{ php_version }}/apache2/php.ini
    regexp: '^post_max_size ='
    line: 'post_max_size = 100M'
  notify: Restart Apache

- name: Set memory_limit to 500M
  lineinfile:
    path: /etc/php/{{ php_version }}/apache2/php.ini
    regexp: '^memory_limit ='
    line: 'memory_limit = 500M'
  notify: Restart Apache
Enter fullscreen mode Exit fullscreen mode

php/vars/main.yml

---
packages:
  - php8.1 
  - php8.1-cli 
  - php8.1-common 
  - php8.1-imap 
  - php8.1-redis 
  - php8.1-snmp 
  - php8.1-xml 
  - php8.1-zip 
  - php8.1-mbstring 
  - php8.1-curl 
  - libapache2-mod-php8.1 
  - php8.1-gd 
  - php8.1-intl 
  - php8.1-mysql 
  - php8.1-ldap
  - php8.1-soap

php_version: "8.1"
Enter fullscreen mode Exit fullscreen mode

sql/tasks/main.yml

---
- name: Install MariaDB packages
  apt:
    name:
      - mariadb-server
      - python3-pymysql
    state: present
    update_cache: yes

- name: Start MariaDB service
  service:
    name: mariadb
    state: started
    enabled: yes

- name: Copy MariaDB configuration file
  template:
    src: mariadb.cnf.j2
    dest: /etc/mysql/mariadb.conf.d/50-server.cnf
  notify: Restart MariaDB

- name: Check if .my.cnf file exists
  stat:
    path: /root/.my.cnf
  register: mycnf_file

- name: Attempt to access MariaDB as root
  command: mysql -u root -e "SELECT 1"
  register: mysql_access
  ignore_errors: yes
  changed_when: false
  when: not mycnf_file.stat.exists
  become: true

- name: Reset MariaDB root password (Ubuntu)
  block:
    - name: Stop MariaDB
      service:
        name: mariadb
        state: stopped

    - name: Start MariaDB with skip-grant-tables
      shell: mysqld_safe --skip-grant-tables &
      async: 45
      poll: 0
      become: true

    - name: Wait for MariaDB to start
      wait_for:
        port: 3306

    - name: Update root password
      shell: |
        mysql -e "FLUSH PRIVILEGES; ALTER USER 'root'@'localhost' IDENTIFIED BY '{{ mariadb_root_password }}'; FLUSH PRIVILEGES;"
      become: true

    - name: Stop MariaDB
      shell: mysqladmin -u root -p'{{ mariadb_root_password }}' shutdown
      become: true

    - name: Start MariaDB normally
      service:
        name: mariadb
        state: started
  when: 
    - mysql_access is failed
    - not mycnf_file.stat.exists

- name: Create .my.cnf file with root credentials
  template:
    src: my.cnf.j2
    dest: /root/.my.cnf
    mode: '0600'
  when: not mycnf_file.stat.exists
  become: true

- name: Create MariaDB database
  mysql_db:
    name: "{{ mariadb_db }}"
    state: present
    login_unix_socket: /var/run/mysqld/mysqld.sock
  become: true

- name: Create MariaDB user
  mysql_user:
    name: "{{ mariadb_user }}"
    password: "{{ mariadb_password }}"
    priv: "{{ mariadb_db }}.*:ALL"
    host: 'localhost'
    state: present
    login_unix_socket: /var/run/mysqld/mysqld.sock
  become: true
Enter fullscreen mode Exit fullscreen mode

suitecrm/tasks/main.yml

---
# tasks file for suitecrm

- name: Install unzip
  apt:
    name: unzip
    state: present

- name: Download SuiteCRM
  get_url:
    url: https://suitecrm.com/download/147/suite86/564202/suitecrm-8-6-2.zip
    dest: /tmp/suitecrm.zip

- name: Unzip SuiteCRM
  unarchive:
    src: /tmp/suitecrm.zip
    dest: /var/www/html
    remote_src: yes
    mode: '0755'
  become: true

- name: Set ownership of /var/www/html to www-data
  file:
    path: /var/www/html
    state: directory
    recurse: yes
    owner: www-data
    group: www-data
  become: true

- name: Create SuiteCRM Apache config file
  template:
    src: suitecrm.conf.j2
    dest: /etc/apache2/sites-available/suitecrm.conf

- name: Enable SuiteCRM site
  command: a2ensite suitecrm.conf
  become: true

- name: Install Certbot and python3-certbot-apache
  apt:
    name:
      - certbot
      - python3-certbot-apache
    state: present

- name: Obtain SSL certificate using Certbot for SuiteCRM
  command: certbot --apache -d {{ domain_name }} --non-interactive --agree-tos --email {{ email }}
  become: true

- name: Reload Apache to apply SSL certificate
  service:
    name: apache2
    state: reloaded
  become: true

- name: Set permissions for directories to 755
  find:
    paths: /var/www/html
    recurse: yes
    file_type: directory
  register: directories

- name: Apply 755 permissions to directories
  file:
    path: /var/www/html
    mode: '0755'
  become: true

- name: Reload Apache with SSL configuration
  service:
    name: apache2
    state: reloaded
  become: true
Enter fullscreen mode Exit fullscreen mode

suitecrm/templates/suitecrm.conf.j2

<VirtualHost *:80>
ServerName {{ domain_name }}
DocumentRoot /var/www/html/public

<Directory /var/www/html/public>
AllowOverride All
</Directory>

ErrorLog ${APACHE_LOG_DIR}/error.log
CustomLog ${APACHE_LOG_DIR}/access.log combined

</VirtualHost>
Enter fullscreen mode Exit fullscreen mode

suitecrm/vars/main.yml

---
# vars file for suitecrm
domain_name: "crm.thebudgetbuys.com"
email: "rijalnitesh78@gmail.com"
Enter fullscreen mode Exit fullscreen mode

6. Additional Configuration Files

ansible.cfg

Create this file in the crm/ansible/ directory:

[defaults]
host_key_checking = False
Enter fullscreen mode Exit fullscreen mode

crm/ansible/main.yml

This is the main Ansible playbook that orchestrates the roles:

---
- hosts: crm
  become: true
  roles:
    - role: sql
    - role: apache2
    - role: php
    - role: suitecrm
Enter fullscreen mode Exit fullscreen mode

roles/sql/defaults/main.yml

---
# defaults file for sql
mariadb_root_password: "suitecrm"
mariadb_db: "suitecrm"
mariadb_user: "suitecrm"
mariadb_password: "suitecrm"
Enter fullscreen mode Exit fullscreen mode

roles/sql/vars/main.yml

---
# vars file for sql

mariadb_root_password: suitecrm
mariadb_database: suitecrm
mariadb_user: suitecrm
mariadb_password: suitecrm
ansible_python_interpreter: /usr/bin/python3
Enter fullscreen mode Exit fullscreen mode

roles/sql/handlers/main.yml

---
- name: Restart MariaDB
  service:
    name: mariadb
    state: restarted
Enter fullscreen mode Exit fullscreen mode

roles/sql/templates/mariadb.cnf.j2

[mysqld]
datadir=/var/lib/mysql
socket=/var/run/mysqld/mysqld.sock
bind-address=0.0.0.0
port=3306

# InnoDB settings
innodb_buffer_pool_size=1G
innodb_log_file_size=256M
innodb_flush_log_at_trx_commit=1
innodb_flush_method=O_DIRECT

# MyISAM settings
key_buffer_size=256M

# Query cache
query_cache_size=0
query_cache_type=0

# Logging
general_log_file=/var/log/mysql/mysql.log
log_error=/var/log/mysql/error.log

# Character set
character-set-server=utf8mb4
collation-server=utf8mb4_general_ci

[mysql]
default-character-set=utf8mb4

[client]
default-character-set=utf8mb4
Enter fullscreen mode Exit fullscreen mode

roles/sql/templates/my.cnf.j2

[client]
user=root
password={{ mariadb_root_password }}
Enter fullscreen mode Exit fullscreen mode

7. Deployment Process

With all these files in place, the automated deployment process works as follows:

  1. When changes are pushed to the infrastructure/ directory, the infra.yaml workflow is triggered.

    • This workflow deploys or updates the AWS infrastructure using CloudFormation.
  2. When changes are pushed to the crm/ directory, the crm.yaml workflow is triggered.

    • This workflow runs the Ansible playbook, which:
      • Installs and configures MariaDB
      • Installs and configures Apache2
      • Installs and configures PHP
      • Downloads and sets up SuiteCRM
      • Configures SSL using Certbot
  3. After the playbook runs successfully, SuiteCRM should be accessible at the specified domain (https://crm.thebudgetbuys.com in this case).

Benefits of This Approach

  1. Infrastructure as Code (IaC): All AWS resources are defined in CloudFormation templates, allowing for version control and easy replication.
  2. Configuration as Code: Ansible roles define the exact configuration of the server and application, ensuring consistency across deployments.
  3. Automated Testing: You can easily add testing steps in the GitHub Actions workflows to ensure quality before deployment.
  4. Easy Rollbacks: If an issue occurs, you can quickly roll back to a previous version of either the infrastructure or the application configuration.
  5. Scalability: This setup can be easily adapted to deploy multiple environments or multiple instances of SuiteCRM.
  6. Audit Trail: All changes to infrastructure and configuration are tracked in Git, providing a clear audit trail.
  7. Reduced Downtime: Automated deployments are faster and can be scheduled during off-peak hours to minimize disruption.
  8. Knowledge Sharing: New team members can quickly understand the entire setup by reviewing the code in the repository.

Conclusion

This comprehensive setup provides a fully automated deployment pipeline for SuiteCRM. By leveraging GitHub Actions, AWS CloudFormation, and Ansible, we've created a robust, scalable, and maintainable solution that can significantly streamline the deployment and management of SuiteCRM instances.

Remember to regularly update your dependencies, review security best practices, and continuously improve your automation scripts to keep your SuiteCRM deployment efficient and secure.

. .