Srikanth Vaddepati
← All Posts

The Power of Ansible: PHP Upgrades and Server Maintenance at Fleet Scale

How agentless, idempotent automation turns risky upgrades and routine maintenance across hundreds of servers into repeatable, boring rollouts

·
ansiblephpdevopsautomationserver maintenance

The Power of Ansible: PHP Upgrades and Server Maintenance at Fleet Scale

Managing one server is easy. Managing two hundred, spread across QA, demo and production, is a different job entirely. OS patches pile up, configs drift, certificates expire, and every few years the PHP version you depend on heads toward end of life. Doing that work by hand over SSH doesn't scale, and every manual step is a chance for one server to end up different from the rest. This is exactly the problem Ansible was built to solve.

What Makes Ansible So Powerful

  • Agentless: it talks to your servers over plain SSH. Nothing to install on the hosts, nothing extra to patch.
  • Idempotent: run the same playbook ten times and you get the same result. Hosts already in the desired state are left alone.
  • Declarative YAML: you describe the end state (packages installed, service running, config in place), not a fragile chain of shell commands.
  • Inventory-driven: group hosts by environment, role or region and target exactly the machines you mean.
  • Batteries included: thousands of modules for packages, services, templates, users, files, clouds and network devices.
  • Version controlled: every procedure lives in Git, gets code-reviewed, and runs the same way every time.

Built for Fleets, Not Just Servers

Ansible thinks in groups, not individual machines. Your inventory can be a simple YAML file or a dynamic source that pulls hosts straight from AWS, Azure, VMware or your CMDB, so new servers are picked up automatically. Ansible runs against many hosts in parallel (the forks setting), and the same inventory powers both full playbooks and quick one-off ad-hoc commands when you just need an answer from the whole fleet.

bash
# Is every host reachable?
ansible all -m ping

# Which PHP version is each web server running?
ansible webservers -a "php -v"

# Disk usage on 50 hosts at a time
ansible all -a "df -h /" --forks 50

# Restart PHP-FPM on QA only
ansible qa -b -m ansible.builtin.service -a "name=php8.3-fpm state=restarted"

Everyday Maintenance, Automated

Most fleet work is routine, which is exactly why it should be automated. A small library of playbooks covers the bulk of it:

  • OS patching with rolling reboots, so the service stays up while hosts update.
  • Configuration drift: templates enforce the same nginx, PHP-FPM and sysctl settings everywhere, and --check --diff reports any host that has wandered.
  • Certificate renewal: deploy new TLS certs and reload the web server across every host in one run.
  • Users and SSH keys: onboard or offboard an engineer on every server in seconds with the authorized_key module.
  • Disk and log hygiene: rotate logs, clear old releases and temp files before they fill a volume.
  • Security hardening: apply CIS-style baselines as roles and re-run them to prove compliance.

Here is what a monthly patch run looks like. Ten hosts at a time are drained from the load balancer, updated, rebooted only if the kernel requires it, and put back into service before the next batch starts:

yaml
- name: Monthly OS patching
  hosts: all
  become: true
  serial: 10
  max_fail_percentage: 10

  pre_tasks:
    # Replace with your load balancer's API or module
    - name: Drain host from the load balancer
      ansible.builtin.uri:
        url: "https://lb.internal/api/pool/{{ inventory_hostname }}/disable"
        method: POST
      delegate_to: localhost

  tasks:
    - name: Apply all package updates
      ansible.builtin.apt:
        upgrade: dist
        update_cache: true

    - name: Check whether a reboot is required
      ansible.builtin.stat:
        path: /var/run/reboot-required
      register: reboot_flag

    - name: Reboot and wait for the host to return
      ansible.builtin.reboot:
        reboot_timeout: 600
      when: reboot_flag.stat.exists

  post_tasks:
    - name: Return host to the load balancer
      ansible.builtin.uri:
        url: "https://lb.internal/api/pool/{{ inventory_hostname }}/enable"
        method: POST
      delegate_to: localhost

The Hardest Case: PHP Upgrades

A PHP upgrade is never just one package. The runtime brings a family of extensions (mbstring, intl, curl, xml, mysql, redis, mongodb), a php.ini that has quietly drifted from server to server, PHP-FPM pools with hand-tuned settings, web server integration, and Composer dependencies that may need a newer platform. Miss a single extension on one host and you get a fatal error in production that never showed up in QA. Ansible makes every host identical, so the server you tested is the server you ship to.

This trimmed-down playbook upgrades PHP on Debian/Ubuntu hosts. The target version is a single variable, so the same playbook carries you to the next release by changing one line. If your web server talks to PHP-FPM over a versioned socket, add one more template task for its config.

yaml
- name: Upgrade PHP
  hosts: webservers
  become: true
  serial: "25%"
  max_fail_percentage: 0
  vars:
    php_version: "8.3"
    php_old_version: "8.1"
    php_extensions: [cli, fpm, mbstring, intl, curl, xml, mysql, redis, mongodb]

  tasks:
    - name: Install target PHP and extensions
      ansible.builtin.apt:
        name: "{{ php_extensions | map('regex_replace', '^', 'php' ~ php_version ~ '-') | list }}"
        state: present
        update_cache: true

    - name: Deploy php.ini from template
      ansible.builtin.template:
        src: php.ini.j2
        dest: "/etc/php/{{ php_version }}/fpm/php.ini"
      notify: Restart PHP-FPM

    - name: Deploy FPM pool config
      ansible.builtin.template:
        src: www.conf.j2
        dest: "/etc/php/{{ php_version }}/fpm/pool.d/www.conf"
      notify: Restart PHP-FPM

    - name: Point the CLI at the new version
      community.general.alternatives:
        name: php
        path: "/usr/bin/php{{ php_version }}"

    - name: Ensure new PHP-FPM is enabled and running
      ansible.builtin.service:
        name: "php{{ php_version }}-fpm"
        state: started
        enabled: true

    - name: Stop and disable old PHP-FPM
      ansible.builtin.service:
        name: "php{{ php_old_version }}-fpm"
        state: stopped
        enabled: false
      failed_when: false

    - name: Smoke test the application
      ansible.builtin.uri:
        url: "http://localhost/health"
        status_code: 200
      register: health
      until: health.status == 200
      retries: 5
      delay: 3

  handlers:
    - name: Restart PHP-FPM
      ansible.builtin.service:
        name: "php{{ php_version }}-fpm"
        state: restarted

Rolling Out Safely

The serial: "25%" line is where Ansible earns its keep. Instead of upgrading everything at once, it works through the fleet in batches. Paired with max_fail_percentage: 0, the run stops the moment a batch fails its health check, leaving the remaining servers untouched on the old, working version.

  • Dry run first: ansible-playbook php-upgrade.yml --check --diff shows exactly what would change without touching anything.
  • Start with one environment: --limit qa upgrades only the QA hosts from your inventory.
  • Canary a single host: --limit web01 lets you watch one production server before the rest follow.
  • Promote with confidence: the same playbook, unchanged, then runs against demo and production.

Tip: keep php_version in group_vars (for example group_vars/qa.yml and group_vars/production.yml). QA can run 8.3 while production stays on 8.1, and promoting the upgrade becomes a one-line pull request.

Rollback Without Panic

Because the old PHP packages are only stopped, not removed, rolling back is just as declarative: swap php_version and php_old_version and run the playbook again. Once the new version has proven itself in production, a small cleanup play removes the old packages for good. No hand-edited servers, and no guessing what someone changed at 2 a.m.

Tips for Running Ansible on a Large Fleet

  • Raise forks in ansible.cfg (the default is 5) so hundreds of hosts finish in minutes, not hours.
  • Turn on SSH pipelining and fact caching to cut connection overhead on every run.
  • Use strategy: free for independent tasks so fast hosts don't wait on slow ones.
  • Run long jobs with async and poll so a single slow host never blocks the play.
  • Organise work into roles and collections, and keep secrets in Ansible Vault.
  • Schedule and audit runs with AWX or Ansible Automation Platform, so maintenance happens on time with a full log of who ran what.

Why It Pays Off

The playbooks you write for one maintenance window keep paying off. They document exactly how your servers are configured, they bring new hosts online in minutes, and they turn the next patch cycle or PHP release into a variable change and a pull request. That's the real power of Ansible: it takes work that used to live in one person's memory and makes it repeatable, reviewable and boring, which is exactly what infrastructure at scale should be.