Ansible

Install LAMP Stack on Ubuntu / Debian with Ansible

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.

Original content from computingforgeeks.com - post 103904

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.

Related Articles

Ansible Ansible Inventory Management: Static and Dynamic Ubuntu Install Kanboard on Ubuntu 24.04 with Nginx Ansible Best Docker and Ansible Books for 2026 CentOS How to reset forgotten WordPress admin password

Leave a Comment

Press ESC to close