Ansible Automation for PHP Infrastructure
Building robust, repeatable infrastructure deployment pipelines using Ansible for PHP applications.
Manual server configuration is a recipe for disaster. Inconsistent environments, configuration drift, and human error create maintenance nightmares that slow down development and increase downtime. After years of managing PHP infrastructure, I've found Ansible to be the most effective tool for automating PHP application deployments.
This article covers proven Ansible strategies for PHP applications, from basic server provisioning to complex multi-environment deployments.
Why Ansible for PHP Infrastructure?
Simplicity and Readability
Ansible playbooks are written in YAML, making 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
No need to install agents on target servers. Ansible uses SSH, which is already available on all Linux servers.
Idempotency
Run playbooks multiple times safely. Ansible only makes changes when needed, ensuring 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
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
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
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
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 }}:{{ deploy_env == 'blue' ? '8080' : '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'
# 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 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 dividends in 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.