LAMP (Linux, Apache, MariaDB, PHP) is the foundation stack behind a massive number of web applications. Setting it up manually across multiple servers is repetitive and error-prone. Ansible solves this by letting you define the entire stack as code and deploy it consistently to any number of Ubuntu or Debian servers in minutes.
This guide walks through building a complete Ansible project with roles to install and configure Apache, PHP 8.x, MariaDB, and UFW firewall on Ubuntu 24.04 or Debian 13. Each component gets its own role for clean separation and reusability. We also cover adding SSL with Certbot as a final step.
Prerequisites
Before starting, make sure you have the following in place:
- A control node (your workstation) with Ansible installed (version 2.14 or later)
- One or more target servers running Ubuntu 24.04 / 22.04 or Debian 13 / 12
- SSH key-based authentication configured between control node and target servers
- A user with sudo privileges on the target servers
- Python 3 installed on target servers (comes pre-installed on Ubuntu/Debian)
Step 1: Create the Ansible Project Structure
Ansible roles organize tasks, handlers, templates, and variables into reusable units. We will create a role for each LAMP component – Apache, PHP, MariaDB, firewall, and SSL.
Create the full project directory tree on your control node:
mkdir -p ~/lamp-ansible/{roles/{apache/{tasks,handlers,templates},php/{tasks,handlers},mariadb/{tasks,handlers,defaults},firewall/tasks,certbot/tasks},group_vars}
Verify the structure looks correct:
tree ~/lamp-ansible
You should see this directory layout:
/home/user/lamp-ansible
├── group_vars
└── roles
├── apache
│ ├── handlers
│ ├── tasks
│ └── templates
├── certbot
│ └── tasks
├── firewall
│ └── tasks
├── mariadb
│ ├── defaults
│ ├── handlers
│ └── tasks
└── php
├── handlers
└── tasks
Step 2: Create the Apache Role
The Apache role installs the web server, enables mod_rewrite, deploys a virtual host configuration from a Jinja2 template, and sets up the document root.
Create the Apache tasks file:
vi ~/lamp-ansible/roles/apache/tasks/main.yml
Add the following content:
---
- name: Install Apache
ansible.builtin.apt:
name: apache2
state: present
update_cache: yes
- name: Enable mod_rewrite
community.general.apache2_module:
name: rewrite
state: present
notify: Restart Apache
- name: Create document root
ansible.builtin.file:
path: "/var/www/{{ domain_name }}"
state: directory
owner: www-data
group: www-data
mode: "0755"
- name: Deploy virtual host configuration
ansible.builtin.template:
src: vhost.conf.j2
dest: "/etc/apache2/sites-available/{{ domain_name }}.conf"
owner: root
group: root
mode: "0644"
notify: Restart Apache
- name: Enable the virtual host
ansible.builtin.command:
cmd: "a2ensite {{ domain_name }}.conf"
creates: "/etc/apache2/sites-enabled/{{ domain_name }}.conf"
notify: Restart Apache
- name: Disable the default site
ansible.builtin.command:
cmd: a2dissite 000-default.conf
removes: /etc/apache2/sites-enabled/000-default.conf
notify: Restart Apache
- name: Ensure Apache is started and enabled
ansible.builtin.systemd:
name: apache2
state: started
enabled: yes
Next, create the handler that restarts Apache when configuration changes:
vi ~/lamp-ansible/roles/apache/handlers/main.yml
Add the restart handler:
---
- name: Restart Apache
ansible.builtin.systemd:
name: apache2
state: restarted
Now create the virtual host template with mod_rewrite enabled:
vi ~/lamp-ansible/roles/apache/templates/vhost.conf.j2
Add this Jinja2 template:
<VirtualHost *:80>
ServerName {{ domain_name }}
ServerAlias www.{{ domain_name }}
DocumentRoot /var/www/{{ domain_name }}
<Directory /var/www/{{ domain_name }}>
Options -Indexes +FollowSymLinks
AllowOverride All
Require all granted
</Directory>
ErrorLog ${APACHE_LOG_DIR}/{{ domain_name }}-error.log
CustomLog ${APACHE_LOG_DIR}/{{ domain_name }}-access.log combined
</VirtualHost>
Step 3: Create the PHP Role
This role installs PHP 8.x and the extensions commonly needed for web applications – database connectors, JSON, cURL, and others. It also deploys a test info.php page for verification.
Create the PHP tasks file:
vi ~/lamp-ansible/roles/php/tasks/main.yml
Add the following tasks:
---
- name: Install PHP and common extensions
ansible.builtin.apt:
name:
- php
- libapache2-mod-php
- php-mysql
- php-cli
- php-curl
- php-gd
- php-mbstring
- php-xml
- php-zip
- php-intl
- php-bcmath
state: present
update_cache: yes
notify: Restart Apache
- name: Set PHP as preferred in Apache DirectoryIndex
ansible.builtin.lineinfile:
path: /etc/apache2/mods-enabled/dir.conf
regexp: '^(\s*DirectoryIndex)'
line: ' DirectoryIndex index.php index.html index.cgi index.pl index.xhtml index.htm'
backrefs: yes
notify: Restart Apache
- name: Deploy PHP info test page
ansible.builtin.copy:
content: ""
dest: "/var/www/{{ domain_name }}/info.php"
owner: www-data
group: www-data
mode: "0644"
Create the PHP handler file:
vi ~/lamp-ansible/roles/php/handlers/main.yml
Add the handler:
---
- name: Restart Apache
ansible.builtin.systemd:
name: apache2
state: restarted
Step 4: Create the MariaDB Role
The MariaDB role installs the database server, sets a root password, removes insecure defaults (the equivalent of running mariadb-secure-installation), and creates an application database and user. If you prefer a standalone MariaDB installation on Ubuntu, that guide covers the manual approach.
First, set the default variables for database credentials:
vi ~/lamp-ansible/roles/mariadb/defaults/main.yml
Define the database name, user, and passwords:
---
mariadb_root_password: "ChangeMeR00t!"
app_db_name: "webapp_db"
app_db_user: "webapp_user"
app_db_password: "ChangeMeApp!"
Create the MariaDB tasks file:
vi ~/lamp-ansible/roles/mariadb/tasks/main.yml
Add the installation, security hardening, and database creation tasks:
---
- name: Install MariaDB server and client
ansible.builtin.apt:
name:
- mariadb-server
- mariadb-client
- python3-mysqldb
state: present
update_cache: yes
- name: Ensure MariaDB is started and enabled
ansible.builtin.systemd:
name: mariadb
state: started
enabled: yes
- name: Set MariaDB root password
community.mysql.mysql_user:
name: root
host: localhost
password: "{{ mariadb_root_password }}"
login_unix_socket: /run/mysqld/mysqld.sock
state: present
- name: Remove anonymous users
community.mysql.mysql_user:
name: ""
host_all: yes
login_unix_socket: /run/mysqld/mysqld.sock
state: absent
- name: Remove test database
community.mysql.mysql_db:
name: test
login_unix_socket: /run/mysqld/mysqld.sock
state: absent
- name: Disallow root login remotely
community.mysql.mysql_user:
name: root
host: "{{ item }}"
login_unix_socket: /run/mysqld/mysqld.sock
state: absent
loop:
- "{{ ansible_hostname }}"
- "127.0.0.1"
- "::1"
- name: Create application database
community.mysql.mysql_db:
name: "{{ app_db_name }}"
login_unix_socket: /run/mysqld/mysqld.sock
state: present
- name: Create application database user
community.mysql.mysql_user:
name: "{{ app_db_user }}"
password: "{{ app_db_password }}"
priv: "{{ app_db_name }}.*:ALL"
host: localhost
login_unix_socket: /run/mysqld/mysqld.sock
state: present
notify: Restart MariaDB
Create the MariaDB handler:
vi ~/lamp-ansible/roles/mariadb/handlers/main.yml
Add the restart handler:
---
- name: Restart MariaDB
ansible.builtin.systemd:
name: mariadb
state: restarted
Step 5: Create the Firewall Role (UFW)
The UFW firewall role opens only the ports the LAMP stack needs – SSH (22), HTTP (80), and HTTPS (443). Everything else stays blocked by default.
Create the firewall tasks file:
vi ~/lamp-ansible/roles/firewall/tasks/main.yml
Add the UFW configuration tasks:
---
- name: Install UFW
ansible.builtin.apt:
name: ufw
state: present
- name: Set default policy to deny incoming
community.general.ufw:
direction: incoming
default: deny
- name: Set default policy to allow outgoing
community.general.ufw:
direction: outgoing
default: allow
- name: Allow SSH (port 22/tcp)
community.general.ufw:
rule: allow
port: "22"
proto: tcp
- name: Allow HTTP (port 80/tcp)
community.general.ufw:
rule: allow
port: "80"
proto: tcp
- name: Allow HTTPS (port 443/tcp)
community.general.ufw:
rule: allow
port: "443"
proto: tcp
- name: Enable UFW
community.general.ufw:
state: enabled
Step 6: Create the Main Playbook
The main playbook ties all roles together and runs them in the correct order against the web server group.
Create the playbook file:
vi ~/lamp-ansible/site.yml
Add the playbook content:
---
- name: Deploy LAMP Stack on Ubuntu/Debian
hosts: webservers
become: yes
vars:
domain_name: example.com
roles:
- apache
- php
- mariadb
- firewall
Replace example.com with your actual domain name. You can also move this variable to group_vars/webservers.yml for better organization.
Create the group variables file for overriding defaults across the web server group:
vi ~/lamp-ansible/group_vars/webservers.yml
Set your domain and database credentials here instead of scattering them across role defaults:
---
domain_name: example.com
mariadb_root_password: "YourSecureRootPass!"
app_db_name: "myapp_db"
app_db_user: "myapp_user"
app_db_password: "YourSecureAppPass!"
Step 7: Configure the Ansible Inventory
The inventory file tells Ansible which servers to target. Create the inventory in INI format:
vi ~/lamp-ansible/inventory.ini
Add your target servers under the webservers group:
[webservers]
web01 ansible_host=192.168.1.10 ansible_user=deploy
web02 ansible_host=192.168.1.11 ansible_user=deploy
[webservers:vars]
ansible_python_interpreter=/usr/bin/python3
Replace the IP addresses and user with your actual server details. The ansible_python_interpreter variable ensures Ansible uses Python 3 on the target hosts.
Test connectivity before running the playbook:
ansible -i ~/lamp-ansible/inventory.ini webservers -m ping
A successful connection returns this for each host:
web01 | SUCCESS => {
"changed": false,
"ping": "pong"
}
web02 | SUCCESS => {
"changed": false,
"ping": "pong"
}
Step 8: Deploy the LAMP Stack with Ansible
Before running the full deployment, install the required Ansible collections that provide the MySQL and UFW modules:
ansible-galaxy collection install community.mysql community.general
Now run the playbook. Use the --diff flag to see what changes Ansible makes on each server:
ansible-playbook -i ~/lamp-ansible/inventory.ini ~/lamp-ansible/site.yml --diff
Ansible processes each role in order. On a fresh server, the first run typically takes 2-3 minutes. The output shows the task results:
PLAY [Deploy LAMP Stack on Ubuntu/Debian] ************************************
TASK [apache : Install Apache] ***********************************************
changed: [web01]
TASK [apache : Enable mod_rewrite] *******************************************
changed: [web01]
...
TASK [firewall : Enable UFW] *************************************************
changed: [web01]
PLAY RECAP *******************************************************************
web01 : ok=18 changed=18 unreachable=0 failed=0
web02 : ok=18 changed=18 unreachable=0 failed=0
If you want to do a dry run first to preview changes without applying them, add the --check flag:
ansible-playbook -i ~/lamp-ansible/inventory.ini ~/lamp-ansible/site.yml --check --diff
Step 9: Verify the LAMP Stack Installation
After deployment, verify each component is running on the target servers. You can run ad-hoc commands through Ansible or SSH directly.
Check Apache status:
ansible -i ~/lamp-ansible/inventory.ini webservers -m shell -a "systemctl status apache2 --no-pager" --become
Apache should show active (running) on each host:
● apache2.service - The Apache HTTP Server
Loaded: loaded (/usr/lib/systemd/system/apache2.service; enabled; preset: enabled)
Active: active (running) since Sat 2026-03-22 10:15:32 UTC; 2min ago
Verify MariaDB is running and the application database exists:
ansible -i ~/lamp-ansible/inventory.ini webservers -m shell -a "mariadb -e 'SHOW DATABASES;'" --become
The output should include your application database:
+--------------------+
| Database |
+--------------------+
| information_schema |
| myapp_db |
| mysql |
| performance_schema |
+--------------------+
Check the installed PHP version and loaded modules:
ansible -i ~/lamp-ansible/inventory.ini webservers -m shell -a "php -v" --become
PHP 8.x should be installed and active:
PHP 8.3.6 (cli) (built: Apr 15 2024 19:21:47) (NTS)
Copyright (c) The PHP Group
Zend Engine v4.3.6, Copyright (c) Zend Technologies
with Zend OPcache v8.3.6, Copyright (c), by Zend Technologies
Verify the UFW firewall rules are active:
ansible -i ~/lamp-ansible/inventory.ini webservers -m shell -a "ufw status verbose" --become
The output confirms only SSH, HTTP, and HTTPS are allowed:
Status: active
Default: deny (incoming), allow (outgoing), disabled (routed)
To Action From
-- ------ ----
22/tcp ALLOW IN Anywhere
80/tcp ALLOW IN Anywhere
443/tcp ALLOW IN Anywhere
Finally, test the PHP info page from your browser or with curl. Replace the IP with your target server address:
curl -s http://192.168.1.10/info.php | head -5
If PHP is working correctly, you will see HTML output from phpinfo(). Remove the info page after verification since it exposes server details:
ansible -i ~/lamp-ansible/inventory.ini webservers -m file -a "path=/var/www/example.com/info.php state=absent" --become
Step 10: Add SSL with Certbot Role
For production use, every site needs HTTPS. This role installs Certbot, obtains a Let’s Encrypt certificate, and configures Apache to use it. Make sure your domain’s DNS A record points to the target server before running this role.
Create the Certbot tasks file:
vi ~/lamp-ansible/roles/certbot/tasks/main.yml
Add the Certbot installation and certificate tasks:
---
- name: Install Certbot and Apache plugin
ansible.builtin.apt:
name:
- certbot
- python3-certbot-apache
state: present
update_cache: yes
- name: Obtain SSL certificate with Certbot
ansible.builtin.command:
cmd: >
certbot --apache
--non-interactive
--agree-tos
--email {{ certbot_email }}
--domains {{ domain_name }},www.{{ domain_name }}
--redirect
creates: "/etc/letsencrypt/live/{{ domain_name }}/fullchain.pem"
- name: Enable Certbot auto-renewal timer
ansible.builtin.systemd:
name: certbot.timer
state: started
enabled: yes
Update the main playbook to include the Certbot role. Open the playbook:
vi ~/lamp-ansible/site.yml
Add the certbot role and the email variable:
---
- name: Deploy LAMP Stack on Ubuntu/Debian
hosts: webservers
become: yes
vars:
domain_name: example.com
certbot_email: [email protected]
roles:
- apache
- php
- mariadb
- firewall
- certbot
Run the playbook again to apply the SSL configuration:
ansible-playbook -i ~/lamp-ansible/inventory.ini ~/lamp-ansible/site.yml --diff
Verify the SSL certificate is installed and auto-renewal is active:
ansible -i ~/lamp-ansible/inventory.ini webservers -m shell -a "certbot certificates" --become
The output confirms certificate details and expiry date:
Found the following certs:
Certificate Name: example.com
Domains: example.com www.example.com
Expiry Date: 2026-06-20 10:15:32+00:00 (VALID: 89 days)
Certificate Path: /etc/letsencrypt/live/example.com/fullchain.pem
Private Key Path: /etc/letsencrypt/live/example.com/privkey.pem
Test the renewal process to make sure it works before the certificate expires:
ansible -i ~/lamp-ansible/inventory.ini webservers -m shell -a "certbot renew --dry-run" --become
Conclusion
You now have a complete Ansible project that deploys the full LAMP stack – Apache with virtual hosts, PHP 8.x with common extensions, MariaDB with secured defaults and application database, UFW firewall, and SSL via Certbot – on Ubuntu 24.04 or Debian 13 servers. Run the same playbook against new servers to get identical environments every time.
For production hardening, consider adding roles for log rotation, automated backups of the MariaDB database, fail2ban for brute-force protection, and monitoring with tools like Semaphore to manage playbook runs from a web interface.