Historic turbine hall at a power station, showing a wide row of nominally identical turbine-generator units built and operated to the same specification
Infrastructure

Ansible Automation for PHP Infrastructure

Building repeatable infrastructure deployment pipelines using Ansible for PHP applications.

Manual server configuration is a recipe for disaster, since you get inconsistent environments, configuration drift, and human errors, and those create maintenance nightmares that slow down development and increase downtime. After years of managing PHP infrastructure, I've found Ansible to be one of the most effective tools for automating PHP application deployments.

This article covers proven Ansible strategies for PHP applications, and we'll go from basic server provisioning to complex multi-environment deployments.

Why Ansible for PHP Infrastructure?

Simplicity and Readability

Ansible playbooks are written in YAML, which makes them readable by both developers and operations teams:

---
- name: Install and configure PHP application
  hosts: webservers
  become: yes
  tasks:
    - name: Install PHP packages
      apt:
        name:
          - php8.2-fpm
          - php8.2-mysql
          - php8.2-curl
          - php8.2-xml
        state: present
        update_cache: yes

Agentless Architecture

You don't need to install agents on target servers, since Ansible uses SSH, which is already available on all Linux servers.

Idempotency

You can run playbooks multiple times safely, since Ansible only makes changes when needed, which keeps things in a consistent state.

Essential Ansible Structure for PHP Projects

Directory Structure

ansible/
├── inventories/
│   ├── production/
│   │   ├── hosts
│   │   └── group_vars/
│   ├── staging/
│   │   ├── hosts
│   │   └── group_vars/
│   └── development/
│       ├── hosts
│       └── group_vars/
├── roles/
│   ├── common/
│   ├── php/
│   ├── nginx/
│   ├── mysql/
│   └── application/
├── playbooks/
│   ├── site.yml
│   ├── deploy.yml
│   └── maintenance.yml
├── templates/
├── files/
└── ansible.cfg

Inventory Configuration

Here's how to define your servers and groups:

# inventories/production/hosts
[webservers]
web1.example.com ansible_host=192.168.1.10
web2.example.com ansible_host=192.168.1.11
[dbservers]
db1.example.com ansible_host=192.168.1.20
[loadbalancers]
lb1.example.com ansible_host=192.168.1.30
[production:children]
webservers
dbservers
loadbalancers

Core Ansible Roles for PHP Infrastructure

Common Role

This handles base configuration for all servers:

# roles/common/tasks/main.yml
---
- name: Update package cache
  apt:
    update_cache: yes
    cache_valid_time: 3600

- name: Install essential packages
  apt:
    name:
      - curl
      - wget
      - unzip
      - git
      - htop
      - fail2ban
      - ufw
    state: present

- name: Configure firewall
  ufw:
    rule: allow
    port: "{{ item }}"
    proto: tcp
  loop:
    - 22
    - 80
    - 443

- name: Enable firewall
  ufw:
    state: enabled
    policy: deny

- name: Configure SSH security
  lineinfile:
    path: /etc/ssh/sshd_config
    regexp: "{{ item.regexp }}"
    line: "{{ item.line }}"
    backup: yes
  loop:
    - { regexp: '^#?PermitRootLogin', line: 'PermitRootLogin no' }
    - { regexp: '^#?PasswordAuthentication', line: 'PasswordAuthentication no' }
    - { regexp: '^#?PubkeyAuthentication', line: 'PubkeyAuthentication yes' }
  notify: restart ssh

PHP Role

This role handles PHP-FPM installation and configuration:

# roles/php/tasks/main.yml
---
- name: Add PHP repository
  apt_repository:
    repo: "ppa:ondrej/php"
    state: present

- name: Install PHP and extensions
  apt:
    name:
      - php{{ php_version }}-fpm
      - php{{ php_version }}-mysql
      - php{{ php_version }}-curl
      - php{{ php_version }}-xml
      - php{{ php_version }}-json
      - php{{ php_version }}-mbstring
      - php{{ php_version }}-zip
      - php{{ php_version }}-gd
      - php{{ php_version }}-opcache
      - php{{ php_version }}-redis
    state: present

- name: Configure PHP-FPM
  template:
    src: php-fpm.conf.j2
    dest: /etc/php/{{ php_version }}/fpm/pool.d/www.conf
    backup: yes
  notify: restart php-fpm

- name: Configure PHP settings
  template:
    src: php.ini.j2
    dest: /etc/php/{{ php_version }}/fpm/php.ini
    backup: yes
  notify: restart php-fpm

- name: Install Composer
  shell: |
    curl -sS https://getcomposer.org/installer | php
    mv composer.phar /usr/local/bin/composer
    chmod +x /usr/local/bin/composer
  args:
    creates: /usr/local/bin/composer

Nginx Role

Here's the web server configuration:

# roles/nginx/tasks/main.yml
---
- name: Install Nginx
  apt:
    name: nginx
    state: present

- name: Remove default Nginx site
  file:
    path: /etc/nginx/sites-enabled/default
    state: absent
  notify: restart nginx

- name: Configure Nginx main config
  template:
    src: nginx.conf.j2
    dest: /etc/nginx/nginx.conf
    backup: yes
  notify: restart nginx

- name: Create application vhost
  template:
    src: vhost.conf.j2
    dest: /etc/nginx/sites-available/{{ app_name }}
  notify: restart nginx

- name: Enable application vhost
  file:
    src: /etc/nginx/sites-available/{{ app_name }}
    dest: /etc/nginx/sites-enabled/{{ app_name }}
    state: link
  notify: restart nginx

- name: Create SSL certificate directory
  file:
    path: /etc/nginx/ssl
    state: directory
    mode: '0755'

- name: Generate SSL certificate
  command: |
    openssl req -x509 -nodes -days 365 -newkey rsa:2048
    -keyout /etc/nginx/ssl/{{ app_name }}.key
    -out /etc/nginx/ssl/{{ app_name }}.crt
    -subj "/C=US/ST=State/L=City/O=Organization/CN={{ app_domain }}"
  args:
    creates: /etc/nginx/ssl/{{ app_name }}.crt

Application Deployment Playbook

Zero-Downtime Deployment

# playbooks/deploy.yml
---
- name: Deploy PHP application
  hosts: webservers
  become: yes
  serial: "{{ deploy_serial | default(1) }}"
  vars:
    app_path: /var/www/{{ app_name }}
    release_path: "{{ app_path }}/releases/{{ ansible_date_time.epoch }}"
    current_path: "{{ app_path }}/current"
    shared_path: "{{ app_path }}/shared"
  tasks:
    - name: Create application directories
      file:
        path: "{{ item }}"
        state: directory
        owner: www-data
        group: www-data
        mode: '0755'
      loop:
        - "{{ app_path }}"
        - "{{ app_path }}/releases"
        - "{{ shared_path }}"
        - "{{ shared_path }}/logs"
        - "{{ shared_path }}/uploads"

    - name: Clone application repository
      git:
        repo: "{{ app_repo }}"
        dest: "{{ release_path }}"
        version: "{{ app_version | default('main') }}"
        force: yes
      become_user: www-data

    - name: Install Composer dependencies
      composer:
        command: install
        working_dir: "{{ release_path }}"
        optimize_autoloader: yes
        no_dev: "{{ 'yes' if app_env == 'production' else 'no' }}"
      become_user: www-data

    - name: Create shared symlinks
      file:
        src: "{{ shared_path }}/{{ item }}"
        dest: "{{ release_path }}/{{ item }}"
        state: link
        force: yes
      loop:
        - logs
        - uploads
      become_user: www-data

    - name: Copy environment configuration
      template:
        src: .env.j2
        dest: "{{ release_path }}/.env"
        owner: www-data
        group: www-data
        mode: '0640'

    - name: Run database migrations
      shell: |
        cd {{ release_path }}
        php artisan migrate --force
      become_user: www-data
      when: run_migrations | default(false)

    - name: Clear application cache
      shell: |
        cd {{ release_path }}
        php artisan cache:clear
        php artisan config:clear
        php artisan route:clear
        php artisan view:clear
      become_user: www-data

    - name: Update current symlink
      file:
        src: "{{ release_path }}"
        dest: "{{ current_path }}"
        state: link
        force: yes
      notify: restart php-fpm

    - name: Set proper permissions
      file:
        path: "{{ current_path }}"
        owner: www-data
        group: www-data
        recurse: yes

    - name: Remove old releases
      shell: |
        cd {{ app_path }}/releases
        ls -1dt */ | tail -n +{{ keep_releases | default(5) }} | xargs rm -rf
      become_user: www-data

Advanced Deployment Strategies

Blue-Green Deployment

# playbooks/blue-green-deploy.yml
---
- name: Blue-Green deployment
  hosts: webservers
  become: yes
  vars:
    current_env: "{{ 'blue' if active_env == 'green' else 'green' }}"
  tasks:
    - name: Determine current active environment
      slurp:
        src: /etc/nginx/sites-enabled/{{ app_name }}
      register: current_config

    - name: Set active environment
      set_fact:
        active_env: "{{ 'blue' if 'blue' in current_config.content | b64decode else 'green' }}"

    - name: Deploy to inactive environment
      include_tasks: deploy-to-env.yml
      vars:
        deploy_env: "{{ current_env }}"

    - name: Health check new deployment
      uri:
        url: "http://{{ inventory_hostname }}:{{ '8080' if deploy_env == 'blue' else '8081' }}/health"
        method: GET
        status_code: 200
      retries: 10
      delay: 5

    - name: Switch traffic to new environment
      template:
        src: nginx-{{ current_env }}.conf.j2
        dest: /etc/nginx/sites-enabled/{{ app_name }}
      notify: restart nginx

    - name: Stop old environment
      systemd:
        name: "{{ app_name }}-{{ active_env }}"
        state: stopped

Database Migration Handling

# roles/application/tasks/migrate.yml
---
- name: Check if migrations are needed
  shell: |
    cd {{ app_path }}/current
    php artisan migrate:status | grep -c "N"
  register: pending_migrations
  failed_when: false
  changed_when: false

- name: Create database backup before migration
  mysql_db:
    name: "{{ app_db_name }}"
    state: dump
    target: "/tmp/{{ app_db_name }}_{{ ansible_date_time.epoch }}.sql"
  when: pending_migrations.stdout | int > 0

- name: Run database migrations
  shell: |
    cd {{ app_path }}/current
    php artisan migrate --force
  when: pending_migrations.stdout | int > 0

- name: Seed database if needed
  shell: |
    cd {{ app_path }}/current
    php artisan db:seed --force
  when:
    - pending_migrations.stdout | int > 0
    - app_env != 'production'

Monitoring and Maintenance

Log Rotation

# roles/application/tasks/logs.yml
---
- name: Configure log rotation
  template:
    src: logrotate.conf.j2
    dest: /etc/logrotate.d/{{ app_name }}
    mode: '0644'

The corresponding logrotate template:

# templates/logrotate.conf.j2
{{ app_path }}/shared/logs/*.log {
    daily
    rotate 30
    compress
    delaycompress
    missingok
    notifempty
    copytruncate
    su www-data www-data
}

Performance Monitoring

# roles/monitoring/tasks/main.yml
---
- name: Install monitoring tools
  apt:
    name:
      - htop
      - iotop
      - nethogs
      - sysstat
    state: present

- name: Configure PHP-FPM status page
  lineinfile:
    path: /etc/php/{{ php_version }}/fpm/pool.d/www.conf
    regexp: '^;?pm.status_path'
    line: 'pm.status_path = /status'
  notify: restart php-fpm

- name: Configure Nginx status
  blockinfile:
    path: /etc/nginx/sites-available/{{ app_name }}
    insertafter: "server_name"
    block: |
      location /nginx_status {
          stub_status on;
          access_log off;
          allow 127.0.0.1;
          deny all;
      }
  notify: restart nginx

Security Hardening

SSL/TLS Configuration

# roles/nginx/templates/vhost.conf.j2
server {
    listen 80;
    server_name {{ app_domain }};
    return 301 https://$server_name$request_uri;
}
server {
    listen 443 ssl http2;
    server_name {{ app_domain }};
    ssl_certificate /etc/nginx/ssl/{{ app_name }}.crt;
    ssl_certificate_key /etc/nginx/ssl/{{ app_name }}.key;
    ssl_protocols TLSv1.2 TLSv1.3;
    ssl_ciphers ECDHE-RSA-AES128-GCM-SHA256:ECDHE-RSA-AES256-GCM-SHA384;
    ssl_prefer_server_ciphers off;
    ssl_session_cache shared:SSL:10m;
    ssl_session_timeout 10m;
    add_header Strict-Transport-Security "max-age=31536000; includeSubDomains" always;
    add_header X-Frame-Options DENY always;
    add_header X-Content-Type-Options nosniff always;
    add_header X-XSS-Protection "1; mode=block" always;
    root {{ app_path }}/current/public;
    index index.php;
    location / {
        try_files $uri $uri/ /index.php?$query_string;
    }
    location ~ .php$ {
        fastcgi_pass unix:/var/run/php/php{{ php_version }}-fpm.sock;
        fastcgi_index index.php;
        fastcgi_param SCRIPT_FILENAME $realpath_root$fastcgi_script_name;
        include fastcgi_params;
    }
    location ~ /.(?!well-known).* {
        deny all;
    }
}

Environment-Specific Configuration

Group Variables

# inventories/production/group_vars/all.yml
---
app_name: myapp
app_domain: myapp.com
app_env: production
php_version: 8.2
keep_releases: 5
run_migrations: true
deploy_serial: 1
# Database configuration
app_db_host: db1.example.com
app_db_name: myapp_production
app_db_user: myapp_user
# Performance settings
php_memory_limit: 512M
php_max_execution_time: 300
php_upload_max_filesize: 64M

Staging Environment

# inventories/staging/group_vars/all.yml
---
app_name: myapp
app_domain: staging.myapp.com
app_env: staging
php_version: 8.2
keep_releases: 3
run_migrations: true
deploy_serial: 0
# Different database
app_db_host: staging-db.example.com
app_db_name: myapp_staging
app_db_user: myapp_staging_user
# Debug settings
php_display_errors: "On"
php_log_errors: "On"
app_debug: true

Continuous Integration

GitLab CI Integration

# .gitlab-ci.yml
stages:
  - test
  - deploy

test:
  stage: test
  script:
    - composer install
    - php artisan test
  only:
    - branches

deploy_staging:
  stage: deploy
  script:
    - ansible-playbook -i inventories/staging/hosts playbooks/deploy.yml
  only:
    - develop
  environment:
    name: staging
    url: https://staging.myapp.com

deploy_production:
  stage: deploy
  script:
    - ansible-playbook -i inventories/production/hosts playbooks/deploy.yml
  only:
    - main
  environment:
    name: production
    url: https://myapp.com
  when: manual

Troubleshooting Common Issues

Connection Problems

# Test connectivity
ansible all -m ping -i inventories/production/hosts

# Check SSH configuration
ansible all -m setup -i inventories/production/hosts | grep ansible_ssh

# Debug playbook execution
ansible-playbook -i inventories/production/hosts playbooks/deploy.yml -vvv

Permission Issues

# Fix common permission problems
- name: Fix application permissions
  file:
    path: "{{ item }}"
    owner: www-data
    group: www-data
    mode: '0755'
    recurse: yes
  loop:
    - "{{ app_path }}/current/storage"
    - "{{ app_path }}/current/bootstrap/cache"
    - "{{ shared_path }}/logs"

Best Practices

  • Use version control: Store all Ansible code in Git
  • Test in staging: Always test playbooks in staging first
  • Use handlers: Restart services only when needed
  • Encrypt secrets: Use Ansible Vault for sensitive data
  • Tag tasks: Use tags for selective execution
  • Monitor deployments: Implement health checks and rollback procedures

Conclusion

Ansible transforms PHP infrastructure management from a manual, error-prone process into a reliable, repeatable system. The investment in setting up proper automation pays off with reduced downtime, consistent environments, and faster deployments.

Start with basic server provisioning, then gradually add more sophisticated deployment strategies like blue-green deployments and automated rollbacks. Your future self will thank you for the time invested in proper automation.

Need infrastructure that's actually run this way?

Get in touch