Ansible Playbooks for PHP Server Provisioning

Khimananda Oli 9 min read CI/CD and Automation
Ansible Playbooks for PHP Server Provisioning

By Khimananda Oli | Last reviewed: August 2026

Manually configuring web servers introduces drift, security gaps, and recovery delays that compound as your infrastructure scales. Ansible Playbooks for PHP Server Provisioning solve this by codifying your entire LEMP stack into version-controlled, idempotent automation that produces identical environments every run. This guide walks through building a production-grade provisioning system for Ubuntu 24.04, covering everything from base hardening to optimized PHP-FPM tuning.

How do you structure Ansible Playbooks for PHP Server Provisioning?

Effective Ansible server automation relies on modular architecture rather than monolithic scripts. A well-structured playbook separates concerns into roles, variables, and environment-specific configurations, making the codebase maintainable and reusable across multiple projects.

Playbook Directory Structuresite.ymlinventory/group_vars/roles/common/roles/nginx/roles/php-fpm/roles/database/tasks/main.ymlhandlers/main.ymltemplates/*.j2defaults/main.ymlModular roles enable reuse across dev, staging, and production
Ansible Playbooks for PHP Server Provisioning organized into reusable roles with separated concerns

The entry point site.yml orchestrates role execution order. Each role encapsulates a specific component—common hardening, Nginx, PHP-FPM, or database—with its own tasks, handlers, templates, and default variables. Environment-specific values live in group_vars/production.yml or group_vars/staging.yml, keeping secrets and tuning parameters out of role logic.

Defining the main playbook

---
# site.yml - Entry point for PHP server provisioning
- name: Provision PHP Application Server
  hosts: webservers
  become: true
  gather_facts: true

  pre_tasks:
    - name: Validate target OS version
      ansible.builtin.assert:
        that:
          - ansible_distribution == "Ubuntu"
          - ansible_distribution_version is version('24.04', '>=')
        fail_msg: "This playbook requires Ubuntu 24.04 or newer"

  roles:
    - role: common
      tags: [common, security]
    - role: nginx
      tags: [nginx, webserver]
    - role: php-fpm
      tags: [php, application]
    - role: database
      tags: [database]
      when: db_install | default(false)

  post_tasks:
    - name: Verify PHP-FPM is responding
      ansible.builtin.uri:
        url: "http://localhost/health"
        status_code: 200
      register: health_check
      retries: 3
      delay: 5

This structure enforces execution order while allowing selective runs via tags. The pre_tasks block validates prerequisites before any changes occur, preventing partial configurations on unsupported systems. Post-tasks confirm service health, providing immediate feedback on provisioning success.

How do you automate Nginx and PHP-FPM configuration with Ansible?

Nginx and PHP-FPM form the core runtime for modern PHP applications. Automating their configuration ensures consistent performance tuning, security headers, and virtual host setups across every server. Templates with Jinja2 variables allow a single configuration source to adapt to different domains, worker counts, and memory limits.

ClientHTTPS RequestNginxReverse ProxySSL TerminationStatic FilesPHP-FPMApplication RuntimeWorker ProcessesOPcacheApp Code/var/www/appUnix SocketAnsible manages configs, sockets, workers, and permissions atomically
Nginx to PHP-FPM request flow with Ansible-managed configuration boundaries

Nginx virtual host template

A parameterized template eliminates copy-paste errors across environments. This Jinja2 template handles SSL, FastCGI proxying, and security headers:

# templates/vhost.conf.j2
server {
    listen {{ nginx_listen_port | default(80) }};
    server_name {{ server_name }};

{% if ssl_enabled | default(false) %}
    listen 443 ssl http2;
    ssl_certificate     {{ ssl_cert_path }};
    ssl_certificate_key {{ ssl_key_path }};
    ssl_protocols       TLSv1.2 TLSv1.3;
    ssl_ciphers         ECDHE-ECDSA-AES128-GCM-SHA256:ECDHE-RSA-AES128-GCM-SHA256;
{% endif %}

    root {{ document_root }};
    index index.php index.html;

    # Security headers managed by Ansible variables
    add_header X-Frame-Options "{{ x_frame_options | default('SAMEORIGIN') }}" always;
    add_header X-Content-Type-Options "nosniff" always;
    add_header Referrer-Policy "strict-origin-when-cross-origin" always;

    location / {
        try_files $uri $uri/ /index.php?$query_string;
    }

    location ~ \.php$ {
        fastcgi_pass unix:{{ php_fpm_socket }};
        fastcgi_param SCRIPT_FILENAME $realpath_root$fastcgi_script_name;
        include fastcgi_params;
        fastcgi_buffer_size {{ fastcgi_buffer_size | default('16k') }};
        fastcgi_buffers {{ fastcgi_buffers | default('4 16k') }};
    }

    location ~ /\.ht {
        deny all;
    }
}

PHP-FPM pool configuration

PHP-FPM tuning directly impacts application throughput. Rather than using distribution defaults, define pool parameters based on available RAM and expected concurrency. For guidance on baseline OS preparation, see initial Ubuntu server setup.

# templates/php-fpm-pool.conf.j2
[{{ app_name }}]
user = {{ php_user }}
group = {{ php_group }}
listen = {{ php_fpm_socket }}
listen.owner = www-data
listen.group = www-data

pm = dynamic
pm.max_children = {{ php_max_children | default(50) }}
pm.start_servers = {{ php_start_servers | default(10) }}
pm.min_spare_servers = {{ php_min_spare | default(5) }}
pm.max_spare_servers = {{ php_max_spare | default(20) }}
pm.max_requests = {{ php_max_requests | default(1000) }}

; Performance tuning
php_admin_value[memory_limit] = {{ php_memory_limit | default('256M') }}
php_admin_value[max_execution_time] = {{ php_max_execution_time | default(30) }}
php_admin_value[upload_max_filesize] = {{ php_upload_max | default('64M') }}
php_admin_flag[opcache.enable] = 1
php_admin_value[opcache.memory_consumption] = {{ opcache_memory | default(128) }}

Calculate pm.max_children by dividing available RAM (minus OS and database overhead) by average PHP process memory. Monitor actual usage with ps --no-headers -o rss -C php-fpm | awk '{sum+=$1} END {print sum/NR/1024 " MB avg"}' and adjust iteratively. Over-provisioning wastes memory; under-provisioning causes request queuing.

How do you handle secrets and sensitive configuration safely?

Database passwords, API keys, and SSL certificates must never appear in plaintext within playbooks or version control. Ansible Vault encrypts sensitive variables at rest, while runtime injection prevents secrets from landing in logs or process listings. This discipline is essential for compliance frameworks like SOC 2 and ISO 27001.

Encrypting variables with Ansible Vault

  1. Create an encrypted variable file: ansible-vault create group_vars/production/vault.yml
  2. Add sensitive values using standard YAML syntax inside the vault file
  3. Reference vault variables normally in tasks—Ansible decrypts transparently during execution
  4. Store the vault password in a CI/CD secret store, never in the repository
  5. Rotate vault passwords periodically using ansible-vault rekey
# group_vars/production/vault.yml (encrypted)
vault_db_password: "Str0ng!P@ssw0rd-2026"
vault_app_key: "base64:abc123..."
vault_redis_auth: "r3d1s-s3cur3-t0k3n"
vault_ssl_cert: |
  -----BEGIN CERTIFICATE-----
  MIIFazCCBFOgAwIBAgISA2...
  -----END CERTIFICATE-----

For complex deployments involving multiple environments, consider Ansible Vault best practices to manage separate encryption keys per environment. Never use a single vault password across dev, staging, and production—a compromise in one environment should not expose others.

Runtime secret injection

When possible, retrieve secrets at runtime from HashiCorp Vault, AWS Secrets Manager, or Azure Key Vault rather than embedding them in Ansible Vault. This reduces the blast radius of credential exposure and enables automatic rotation without playbook modifications:

- name: Retrieve database credentials from AWS Secrets Manager
  amazon.aws.aws_secret:
    name: "prod/php-app/db-credentials"
    region: ap-south-1
  register: db_secrets
  no_log: true

- name: Configure application environment file
  ansible.builtin.template:
    src: .env.j2
    dest: "{{ app_path }}/.env"
    owner: "{{ php_user }}"
    mode: '0600'
  vars:
    db_password: "{{ db_secrets.secret.db_password }}"
  no_log: true

How does Ansible compare to manual provisioning for PHP servers?

Understanding the operational trade-offs between automated and manual approaches helps justify the initial investment in playbook development. The following comparison reflects real-world outcomes from managing PHP infrastructure across dozens of production environments.

CriteriaManual ProvisioningAnsible Playbooks for PHP Server Provisioning
Initial Setup Time2–4 hours per server15–30 minutes after playbook maturity
Configuration DriftInevitable within weeksEliminated via idempotent convergence
Disaster RecoveryDocumentation-dependent, error-proneFully reproducible from version control
Security ConsistencyVaries by operator skill and fatigueIdentical hardening on every run
Multi-environment ParityRarely achieved reliablyGuaranteed through shared roles
Audit TrailShell history, if retainedGit commits + Ansible execution logs
Learning CurveLow initial, high long-term costModerate upfront, compounding returns

Manual provisioning may seem faster for a single throwaway server, but the cumulative cost of inconsistency becomes severe beyond two or three hosts. Teams managing PHP applications for Nepali businesses or global clients alike find that automation pays for itself within the first major incident or compliance audit.

Manual ProvisioningServer A ━━━━━━━━━━━━ Config v1Server B ━━━━━━━━╲━━ Config v1 + hotfixServer C ━━━━━╲━━━━━ Config v2 (partial)Server D ━╲━━━━━━━━━ Config v1 + drift✗ Inconsistent packages✗ Untracked config changes✗ Recovery requires tribal knowledgeAnsible AutomationServer A ━━━━━━━━━━━━ Desired State ✓Server B ━━━━━━━━━━━━ Desired State ✓Server C ━━━━━━━━━━━━ Desired State ✓Server D ━━━━━━━━━━━━ Desired State ✓✓ Idempotent convergence✓ Version-controlled configs✓ Reproducible in minutesTime →
Configuration drift accumulation in manual provisioning versus Ansible idempotent convergence

What testing and validation practices prevent provisioning failures?

Untested playbooks are liabilities. Adopt a validation pipeline that catches errors before they reach production servers. This mirrors application CI/CD practices and aligns with idempotent infrastructure principles.

  • Syntax check: Run ansible-playbook --syntax-check site.yml before every commit to catch YAML errors and undefined variable references
  • Linting: Use ansible-lint with custom rules to enforce team conventions, detect non-idempotent modules, and flag deprecated syntax
  • Dry runs: Execute ansible-playbook --check --diff site.yml against staging to preview changes without applying them
  • Molecule testing: Spin up ephemeral containers or VMs to validate roles in isolation before integration
  • Idempotence verification: Run the playbook twice in CI; the second run must report zero changes
  • Service health checks: Include post-task validations that confirm PHP-FPM responds, Nginx serves content, and database connections succeed
# .github/workflows/ansible-test.yml (excerpt)
- name: Test playbook idempotence
  run: |
    ansible-playbook site.yml --check --diff
    ansible-playbook site.yml
    CHANGES=$(ansible-playbook site.yml | grep -c "changed=0")
    if [ "$CHANGES" -eq 0 ]; then
      echo "FAIL: Playbook is not idempotent"
      exit 1
    fi

Integrate these checks into your CI pipeline so that every pull request receives automated validation. For teams adopting DevSecOps practices, add security scanning of templates and variable files to detect hardcoded secrets or insecure configurations before merge.

Implementing Ansible Playbooks for PHP Server Provisioning

Start with a minimal viable playbook covering base OS hardening, Nginx, and PHP-FPM. Expand incrementally to include database setup, SSL certificate management, monitoring agents, and application deployment. Store everything in version control, encrypt secrets with Vault, and validate every change through automated tests before applying to production.

The initial investment in Ansible Playbooks for PHP Server Provisioning compounds quickly: faster recovery, consistent security posture, reliable multi-environment parity, and audit-ready documentation generated automatically. If your team manages more than two PHP servers or plans to scale, automation is not optional—it is the foundation of operational reliability.

Ready to automate your PHP infrastructure or need help designing a provisioning strategy tailored to your stack? Get in touch to discuss your requirements.

Frequently Asked Questions

Use Ansible Core 2.18 or later for PHP server provisioning. This version includes updated Python 3.12 support and improved idempotency checks essential for reliable LAMP stack deployments on modern Ubuntu and Debian systems.

Use the geerlingguy.php role with php_version set to 8.4. Ensure your playbook adds the Ondrej PPA repository first, as default OS repositories often lack the latest PHP releases needed for current Laravel applications.

Yes.

Template individual pool files into /etc/php/8.4/fpm/pool.d using Jinja2. Define variables for pm.max_children and listen directives per site, then notify the php-fpm handler to restart the service only when configuration changes occur.

Ansible excels at configuring bare metal or VMs with persistent state, while Docker suits ephemeral microservices. For traditional monolithic Laravel apps requiring specific OS-level tuning, Ansible playbooks provide more direct control over the underlying PHP runtime environment.

Apply the geerlingguy.security role to configure firewalls and SSH hardening. Disable dangerous PHP functions like exec and shell_exec via templated php.ini files, and enforce strict file permissions on web roots to prevent unauthorized code execution.

Missing handler flushes cause services to run outdated configs. Hardcoding paths instead of using distribution-specific variables breaks cross-platform compatibility. Always validate syntax with ansible-playbook --syntax-check before running against production PHP infrastructure.

Typically five to ten minutes.

Store environment variables in separate group_vars files for staging and production. Reference these variables within your php.ini and FPM pool templates to maintain identical playbook logic while applying distinct memory limits and error reporting levels per environment.

Yes, use the composer module with the working_dir parameter pointing to your application root. Set no_dev to true for production environments to skip development packages, ensuring faster deployments and reduced attack surface on live PHP servers.

Run Molecule tests against Docker or Vagrant instances before touching production. Verify PHP version, loaded extensions, and FPM socket status using assertion modules. This catches configuration drift and dependency conflicts without risking live application downtime or data loss.

Community roles save time but require auditing. Pin specific versions in requirements.yml to avoid breaking changes. Custom tasks offer tighter security control, whereas maintained Galaxy roles provide tested defaults for standard PHP-FPM and web server configurations across multiple distributions.

Create a dedicated upgrade playbook that installs the new PHP version alongside the old one. Update FPM pools and web server configs atomically, then remove the legacy version only after verifying application health through automated smoke tests and monitoring checks.

Include opcache, mbstring, xml, curl, and zip as baseline extensions. Add redis or memcached for caching, and pdo_mysql or pdo_pgsql based on your database backend. Always verify extension loading via php -m in a post-provisioning validation task.

Run playbooks with -vvv verbosity to inspect module parameters and return values. Check remote logs in /var/log/php-fpm and syslog. Use the debug module to print variable states before failing tasks, isolating whether issues stem from templates, permissions, or missing dependencies.