Automating Multi-Region Redundancy for WordPress Architectures on OVH
Establishing a Multi-Region WordPress Architecture on OVHcloud
This document outlines a robust, automated strategy for achieving multi-region redundancy for WordPress deployments hosted on OVHcloud infrastructure. The primary objective is to ensure business continuity and minimize data loss in the event of a regional outage. We will focus on a solution leveraging OVHcloud’s Public Cloud services, specifically Compute Instances, Block Storage, and Object Storage, orchestrated via Terraform for infrastructure as code and Ansible for configuration management.
Core Components and Strategy
Our multi-region strategy hinges on maintaining a warm standby or active-passive setup across two geographically distinct OVHcloud regions. This involves:
- Primary Region: The active WordPress site, serving live traffic.
- Secondary Region: A replicated environment, kept up-to-date with data and code from the primary, ready to take over.
- Data Synchronization: Continuous replication of WordPress database and filesystem (uploads, themes, plugins).
- Automated Failover: A mechanism to detect primary region failure and promote the secondary region.
- Infrastructure as Code (IaC): Terraform for provisioning and managing identical infrastructure in both regions.
- Configuration Management: Ansible for consistent application deployment and configuration.
Infrastructure Provisioning with Terraform
Terraform is crucial for ensuring that both the primary and secondary regions have identical compute, storage, and networking configurations. We’ll define our resources in Terraform HCL (HashiCorp Configuration Language).
Terraform Configuration for a Single Region
First, let’s define the resources for one region. This will be duplicated for the second region with minor parameter adjustments (e.g., region name, IP addresses).
`main.tf` – Core Infrastructure
# OVHcloud Provider Configuration
provider "ovh" {
endpoint = "ovh-eu" # or "ovh-us", "ovh-ca", "ovh-us/soyoustart" etc.
# Consider using environment variables for credentials
# export OVH_ACCESS_KEY="YOUR_OVH_ACCESS_KEY"
# export OVH_SECRET_KEY="YOUR_OVH_SECRET_KEY"
# export OVH_CONSUMER_KEY="YOUR_OVH_CONSUMER_KEY"
}
# Define variables for region-specific configurations
variable "region" {
description = "The OVHcloud region to deploy resources in (e.g., 'GRA1', 'BHS1')"
type = string
}
variable "instance_name_prefix" {
description = "Prefix for instance names"
type = string
}
variable "ssh_public_key" {
description = "Public SSH key for instance access"
type = string
}
# WordPress Web Server Instance
resource "ovh_compute_instance" "wordpress_web" {
name = "${var.instance_name_prefix}-web"
image = "ubuntu-20.04" # Or your preferred OS image
flavor = "s1-2" # Adjust flavor based on expected load
region = var.region
ssh_key_name = "my-wordpress-key" # Ensure this key is uploaded to OVHcloud
public_cloud_access = true
disk_size = 50 # GB
# Attach a public IP
public_ip_assigned = true
# User data for initial setup (optional, Ansible is preferred for complex setup)
user_data = "#!/bin/bash\necho 'Initial setup script executed.'"
}
# WordPress Database Instance (e.g., MySQL)
resource "ovh_compute_instance" "wordpress_db" {
name = "${var.instance_name_prefix}-db"
image = "ubuntu-20.04"
flavor = "s1-2"
region = var.region
ssh_key_name = "my-wordpress-key"
public_cloud_access = true
disk_size = 50
public_ip_assigned = true # For simplicity, direct IP. Consider private IPs and security groups for production.
}
# Block Storage for WordPress Uploads (e.g., /var/www/html/wp-content/uploads)
resource "ovh_storage_block_volume" "wp_uploads" {
name = "${var.instance_name_prefix}-uploads"
region = var.region
size = 100 # GB
type = "classic" # or "high-iops"
instance_id = ovh_compute_instance.wordpress_web.id
mount_point = "/mnt/wp_uploads" # Mount point on the instance
}
# Output public IPs for easy access
output "wordpress_web_public_ip" {
value = ovh_compute_instance.wordpress_web.public_ip
}
output "wordpress_db_public_ip" {
value = ovh_compute_instance.wordpress_db.public_ip
}
`variables.tf` – Variable Definitions
variable "region" {
description = "The OVHcloud region to deploy resources in (e.g., 'GRA1', 'BHS1')"
type = string
}
variable "instance_name_prefix" {
description = "Prefix for instance names"
type = string
}
variable "ssh_public_key" {
description = "Public SSH key for instance access"
type = string
}
variable "ovh_access_key" {
description = "OVH API Access Key"
type = string
sensitive = true
}
variable "ovh_secret_key" {
description = "OVH API Secret Key"
type = string
sensitive = true
}
variable "ovh_consumer_key" {
description = "OVH API Consumer Key"
type = string
sensitive = true
}
`terraform.tfvars` – Example Values
region = "GRA1" # Example: Gravelines, France instance_name_prefix = "wp-prod" ssh_public_key = "ssh-rsa AAAAB3NzaC1yc2EAAAADAQABAAABAQD..." # Your actual public SSH key ovh_access_key = "YOUR_OVH_ACCESS_KEY" ovh_secret_key = "YOUR_OVH_SECRET_KEY" ovh_consumer_key = "YOUR_OVH_CONSUMER_KEY"
Multi-Region Deployment Strategy
To deploy to two regions, we can use Terraform’s `for_each` meta-argument or create separate configuration files. For clarity and easier management of region-specific outputs and variables, separate configurations are often preferred.
Directory Structure Example
.
├── main.tf
├── variables.tf
├── outputs.tf
├── terraform.tfvars.primary
├── terraform.tfvars.secondary
└── modules/
└── wordpress/ # Optional: Encapsulate WordPress setup logic
├── main.tf
└── variables.tf
`terraform.tfvars.primary`
region = "GRA1" instance_name_prefix = "wp-prod-primary" ssh_public_key = "ssh-rsa AAAAB3NzaC1yc2EAAAADAQABAAABAQD..." ovh_access_key = "YOUR_OVH_ACCESS_KEY" ovh_secret_key = "YOUR_OVH_SECRET_KEY" ovh_consumer_key = "YOUR_OVH_CONSUMER_KEY"
`terraform.tfvars.secondary`
region = "BHS1" # Example: Beauharnois, Canada instance_name_prefix = "wp-prod-secondary" ssh_public_key = "ssh-rsa AAAAB3NzaC1yc2EAAAADAQABAAABAQD..." ovh_access_key = "YOUR_OVH_ACCESS_KEY" ovh_secret_key = "YOUR_OVH_SECRET_KEY" ovh_consumer_key = "YOUR_OVH_CONSUMER_KEY"
Deployment Commands
# Initialize Terraform terraform init # Plan and apply for the primary region terraform plan -var-file="terraform.tfvars.primary" terraform apply -var-file="terraform.tfvars.primary" # Plan and apply for the secondary region terraform plan -var-file="terraform.tfvars.secondary" terraform apply -var-file="terraform.tfvars.secondary"
Application Deployment and Configuration with Ansible
Once the infrastructure is provisioned, Ansible will handle the installation and configuration of the WordPress stack (web server, PHP, database, WordPress core files) on both sets of instances. This ensures consistency and repeatability.
Ansible Inventory
We need an inventory file that lists our instances, distinguishing between primary and secondary regions.
`inventory.ini`
[wordpress_primary]
wp-prod-primary-web ansible_host={{ hostvars['wp-prod-primary-web']['wordpress_web_public_ip'] }} ansible_user=ubuntu
wp-prod-primary-db ansible_host={{ hostvars['wp-prod-primary-db']['wordpress_db_public_ip'] }} ansible_user=ubuntu
[wordpress_secondary]
wp-prod-secondary-web ansible_host={{ hostvars['wp-prod-secondary-web']['wordpress_web_public_ip'] }} ansible_user=ubuntu
wp-prod-secondary-db ansible_host={{ hostvars['wp-prod-secondary-db']['wordpress_db_public_ip'] }} ansible_user=ubuntu
[wordpress:children]
wordpress_primary
wordpress_secondary
[databases]
wp-prod-primary-db
wp-prod-secondary-db
Ansible Playbook for WordPress Setup
This playbook installs Nginx, PHP, MySQL, and WordPress. It also configures the database and sets up the web server.
`playbook.yml`
---
- name: Configure WordPress Web Server
hosts: wordpress_primary:wordpress_secondary # Apply to both regions initially
become: yes
vars:
wordpress_db_host: "{{ hostvars[item]['wordpress_db_public_ip'] }}" # Dynamically get DB IP
wordpress_db_name: "wordpress_db"
wordpress_db_user: "wp_user"
wordpress_db_password: "your_secure_password"
wordpress_site_url: "http://your-domain.com" # Replace with actual domain
wordpress_path: "/var/www/html"
tasks:
- name: Update apt cache
apt:
update_cache: yes
- name: Install Nginx
apt:
name: nginx
state: present
- name: Install PHP and extensions
apt:
name:
- php-fpm
- php-mysql
- php-gd
- php-xml
- php-mbstring
- php-curl
state: present
- name: Install MySQL client
apt:
name: mysql-client
state: present
- name: Ensure WordPress directory exists
file:
path: "{{ wordpress_path }}"
state: directory
owner: www-data
group: www-data
mode: '0755'
- name: Download WordPress
get_url:
url: https://wordpress.org/latest.tar.gz
dest: /tmp/wordpress.tar.gz
mode: '0644'
- name: Extract WordPress
unarchive:
src: /tmp/wordpress.tar.gz
dest: "{{ wordpress_path }}"
remote_src: yes
owner: www-data
group: www-data
creates: "{{ wordpress_path }}/wordpress" # Avoid re-extracting
- name: Move WordPress files to root
command: mv {{ wordpress_path }}/wordpress/* {{ wordpress_path }}/
args:
creates: "{{ wordpress_path }}/wp-config.php" # Prevent running if already configured
changed_when: true # Assume it always changes if it runs
- name: Create wp-config.php
template:
src: templates/wp-config.php.j2
dest: "{{ wordpress_path }}/wp-config.php"
owner: www-data
group: www-data
mode: '0644'
- name: Configure Nginx site
template:
src: templates/nginx.conf.j2
dest: "/etc/nginx/sites-available/wordpress"
notify: Restart Nginx
- name: Enable Nginx site
file:
src: "/etc/nginx/sites-available/wordpress"
dest: "/etc/nginx/sites-enabled/wordpress"
state: link
notify: Restart Nginx
- name: Remove default Nginx site
file:
path: "/etc/nginx/sites-enabled/default"
state: absent
notify: Restart Nginx
handlers:
- name: Restart Nginx
service:
name: nginx
state: restarted
- name: Configure WordPress Database Server
hosts: databases
become: yes
vars:
wordpress_db_name: "wordpress_db"
wordpress_db_user: "wp_user"
wordpress_db_password: "your_secure_password"
tasks:
- name: Install MySQL Server
apt:
name: mysql-server
state: present
- name: Start MySQL service
service:
name: mysql
state: started
- name: Create MySQL database
mysql_db:
name: "{{ wordpress_db_name }}"
state: present
- name: Create MySQL user
mysql_user:
name: "{{ wordpress_db_user }}"
password: "{{ wordpress_db_password }}"
priv: "{{ wordpress_db_name }}.*:ALL"
state: present
host: "%" # Allow connections from any host (adjust for security)
- name: Flush MySQL privileges
command: mysql -u root -e "FLUSH PRIVILEGES;"
`templates/wp-config.php.j2`
<?php
/**
* The name of the database for WordPress
*/
define( 'DB_NAME', '{{ wordpress_db_name }}' );
/**
* MySQL database username
*/
define( 'DB_USER', '{{ wordpress_db_user }}' );
/**
* MySQL database password
*/
define( 'DB_PASSWORD', '{{ wordpress_db_password }}' );
/**
* MySQL hostname
*/
define( 'DB_HOST', '{{ wordpress_db_host }}' );
/**
* Database Charset to use in creating database tables.
*/
define( 'DB_CHARSET', 'utf8' );
/**
* The Database Collate type. In doubt, use utf8.
*/
define( 'DB_COLLATE', '' );
/**
* Authentication Unique Keys and Salts.
*
* All of the keys and salts above are secret. You can generate these using
* the {@link https://api.wordpress.org/secret-key/1.1/salt/ WordPress.org secret-key service}.
*
* <blockquote><p>A unique phrase (word or a random sequence of characters) for the security keys. The secret keys are used to help protect your site by encrypting the information stored in the database. If you need to generate new keys, you can use the {@link https://api.wordpress.org/secret-key/1.1/salt/ WordPress.org secret-key service}.</p></blockquote>
*/
define( 'AUTH_KEY', 'put your unique phrase here' );
define( 'SECURE_AUTH_KEY', 'put your unique phrase here' );
define( 'LOGGED_IN_KEY', 'put your unique phrase here' );
define( 'NONCE_KEY', 'put your unique phrase here' );
define( 'AUTH_SALT', 'put your unique phrase here' );
define( 'SECURE_AUTH_SALT', 'put your unique phrase here' );
define( 'LOGGED_IN_SALT', 'put your unique phrase here' );
define( 'NONCE_SALT', 'put your unique phrase here' );
/**
* For developers: WordPress debugging mode.
*
* Change this to true to enable the display of notices during development.
* It is recommended that plugin and theme developers use WP_DEBUG
* in their development environments.
*/
define( 'WP_DEBUG', false );
/* That's all, stop editing! Happy publishing. */
/** Sets the maximum upload size for media uploads. */
define( 'WP_MEMORY_LIMIT', '256M' );
/** Sets the maximum execution time for scripts. */
@set_time_limit( 300 );
/** Sets the maximum size for file uploads. */
define( 'POST_MAX_SIZE', '64M' );
define( 'UPLOAD_MAX_FILESIZE', '64M' );
/** Sets the maximum size for POST data that will be accepted by PHP. */
define( 'MAX_FILE_uploads', '100' );
/* Add custom WordPress path if not in root */
if ( ! defined( 'WP_HOME' ) ) {
define( 'WP_HOME', '{{ wordpress_site_url }}' );
}
if ( ! defined( 'WP_SITEURL' ) ) {
define( 'WP_SITEURL', '{{ wordpress_site_url }}' );
}
/** Absolute path to the WordPress directory. */
if ( ! defined( 'ABSPATH' ) ) {
define( 'ABSPATH', __DIR__ . '/' );
}
`templates/nginx.conf.j2`
server {
listen 80;
server_name your-domain.com; # Replace with your domain
root /var/www/html;
index index.php index.html index.htm;
location / {
try_files $uri $uri/ /index.php?$args;
}
location ~ \.php$ {
include snippets/fastcgi-php.conf;
fastcgi_pass unix:/var/run/php/php7.4-fpm.sock; # Adjust PHP version if needed
fastcgi_param SCRIPT_FILENAME $document_root$fastcgi_script_name;
include fastcgi_params;
}
location ~ /\.ht {
deny all;
}
# Add caching for static assets
location ~* \.(jpg|jpeg|png|gif|ico|css|js)$ {
expires 30d;
log_not_found off;
}
}
Running the Playbook
# Ensure you have your SSH private key configured for Ansible # e.g., by setting SSH_PRIVATE_KEY_FILE environment variable or using --private-key flag ansible-playbook -i inventory.ini playbook.yml
Data Synchronization Strategy
This is the most critical part of multi-region redundancy. We need to synchronize both the database and the filesystem (uploads, themes, plugins).
Database Synchronization
For database synchronization, we can employ several methods:
- MySQL Replication: Set up master-slave replication from the primary database to the secondary. This is the most robust and real-time solution.
- Scheduled Backups and Restore: Regularly back up the primary database and restore it to the secondary. This introduces latency and potential data loss.
- Logical Replication Tools: Tools like `pglogical` (for PostgreSQL) or custom scripts using `mysqldump` with incremental options.
We will focus on MySQL replication as the preferred method.
Setting up MySQL Master-Slave Replication
On the primary database server:
Primary DB (`my.cnf` or `mysqld.cnf` snippet)
[mysqld] server-id = 1 log_bin = /var/log/mysql/mysql-bin.log binlog_format = ROW binlog_do_db = wordpress_db # Only replicate the WordPress database auto_increment_increment = 2 # Important for active-passive failover to avoid ID conflicts auto_increment_offset = 1 # Important for active-passive failover
On the secondary database server:
Secondary DB (`my.cnf` or `mysqld.cnf` snippet)
[mysqld] server-id = 2 relay_log = /var/log/mysql/mysql-relay-bin.log read_only = 1 # Crucial for standby to prevent accidental writes log_bin = /var/log/mysql/mysql-bin.log # Optional, but good practice for potential promotion binlog_format = ROW binlog_do_db = wordpress_db auto_increment_increment = 2 auto_increment_offset = 2 # Crucial for active-passive failover
After applying these configurations and restarting MySQL on both servers, you’ll need to create a replication user on the primary and configure the slave on the secondary. This typically involves taking a snapshot of the primary database, restoring it to the secondary, and then initiating replication.
Filesystem Synchronization (Uploads, Themes, Plugins)
The WordPress filesystem, particularly the `wp-content` directory, needs to be synchronized. The `wp-content/uploads` directory can grow very large.
- OVHcloud Object Storage with Rclone: Mount Object Storage buckets in both regions and use `rclone sync` to keep them in sync. This is a cost-effective and scalable solution.
- rsync over SSH: A traditional method, but can be slow and complex to manage for continuous sync.
- Distributed Filesystems: Solutions like GlusterFS or Ceph, which are more complex to set up and manage.
We’ll outline the Object Storage approach.
Using OVHcloud Object Storage and Rclone
1. Create Object Storage Containers: In OVHcloud’s control panel, create two containers (e.g., `wp-uploads-primary`, `wp-uploads-secondary`) in separate regions. Obtain the S3 credentials for these containers.
Install Rclone on both Web Servers
curl https://rclone.org/install.sh | sudo bash
Configure Rclone
rclone config
Follow the prompts to set up two remotes, one for each Object Storage container. For example:
Rclone Configuration Snippet (`~/.config/rclone/rclone.conf`)
[ovh-primary] type = s3 provider = OVH env_auth = false access_key_id = YOUR_OVH_OBJECT_STORAGE_ACCESS_KEY secret_access_key = YOUR_OVH_OBJECT_STORAGE_SECRET_KEY endpoint = https://storage.gra.cloud.ovh.net # Example for GRA region [ovh-secondary] type = s3 provider = OVH env_auth = false access_key_id = YOUR_OVH_OBJECT_STORAGE_ACCESS_KEY secret_access_key = YOUR_OVH_OBJECT_STORAGE_SECRET_KEY endpoint = https://storage.bhs.cloud.ovh.net # Example for BHS region
Mount Object Storage
# On the primary web server: sudo mkdir -p /mnt/wp_uploads sudo rclone mount ovh-primary:wp-uploads-primary /mnt/wp_uploads --allow-other & # On the secondary web server: sudo mkdir -p /mnt/wp_uploads sudo rclone mount ovh-secondary:wp-uploads-secondary /mnt/wp_uploads --allow-other & # Add to /etc/fstab for persistence # ovh-primary:wp-uploads-primary /mnt/wp_uploads fuse.rclone defaults,allow_other,vfs_cache_enable=true,vfs_cache_max_age=120s 0 0 # ovh-secondary:wp-uploads-secondary /mnt/wp_uploads fuse.rclone defaults,allow_other,vfs_cache_enable=true,vfs_cache_max_age=120s 0 0
Synchronize Uploads
# On the primary server, sync to Object Storage sudo rclone sync /var/www/html/wp-content/uploads ovh-primary:wp-uploads-primary --progress # On the secondary server, sync from Object Storage sudo rclone sync ovh-secondary:wp-uploads-secondary /var/www/html/wp-content/uploads --progress # Schedule regular syncs using cron # Example: Sync uploads to Object Storage every 5 minutes * * * * * root rclone sync /var/www/html/wp-content/uploads ovh-primary:wp-uploads-primary --log-file=/var/log/rclone_uploads_primary.log # Example: Sync Object Storage to local uploads on secondary every 5 minutes * * * * * root rclone sync ovh-secondary:wp-uploads-secondary /var/www/html/wp-content/uploads --log-file=/var/log/rclone_uploads_secondary.log
Synchronizing Themes and Plugins
For themes and plugins, you have a few options:
- Manual Sync: Periodically sync the `wp-content/themes` and `wp-content/plugins` directories using `rsync` or `rclone` between the primary and secondary web servers.
- Git-based Deployment: Manage themes and plugins via Git. Deploy new versions to both regions simultaneously using CI/CD pipelines.
- Object Storage as a Source: Store theme/plugin archives in Object Storage and extract them on the secondary server during a failover.
A simple `rsync` script run via cron on the primary server to push changes to the secondary is a common approach for smaller sites.
Example `rsync` script for themes/plugins
#!/bin/bash
PRIMARY_WEB_IP="YOUR_PRIMARY_WEB_SERVER_IP"
SECONDARY_WEB_IP="YOUR_SECONDARY_WEB_SERVER_IP"
SSH_KEY="/path/to/your/private/ssh/key" # Ensure this key has access to the secondary server
rsync -avz --delete \
-e "ssh -i ${SSH_KEY} -o StrictHostKeyChecking=no" \
/var/www/html/wp-content/themes/ \
"${SECONDARY_WEB_IP}:/var/www/html/wp-content/themes/"
rsync -avz --delete \
-e "ssh -i ${SSH_KEY} -o StrictHostKeyChecking=no" \
/var/www/html/wp-content/plugins/ \
"${SECONDARY_WEB_IP}:/var/www/html/wp-content/plugins/"
echo "Themes and plugins synced to secondary at $(date)" >> /var/log/wp_sync.log
Automated Failover Mechanism
Automating failover is complex and requires careful consideration. A common approach involves:
- Health Checks: Regularly ping the primary WordPress site and its underlying infrastructure (web server, database).
- Load Balancer / DNS: Use a global load balancer or DNS-based failover (e.g., OVHcloud’s Global Load Balancing, or Cloudflare) to redirect traffic.
- Failover Script: A script that, upon detecting failure, performs the necessary steps to promote the secondary site.
Global Load Balancing (GLB) with OVHcloud
OVHcloud’s Global Load Balancing service can monitor endpoints in different regions and automatically route traffic to healthy ones. You would configure it to monitor your primary WordPress site’s IP address.
Failover Script Logic
The failover script would typically:
- Detect primary site unreachability (e.g., via GLB alerts or custom checks).
- Promote Secondary Database: Stop replication on the secondary DB, set `read_only = 0`, and potentially update its `auto_increment_offset` if it was previously set.
- Update DNS/GLB: If not using automatic GLB failover, update DNS records or GLB configuration to point to the secondary site’s IP.
- Sync Final Data: Perform a final `rsync` or database dump/restore to ensure the secondary has the absolute latest data.
- Start Services: Ensure all necessary services are running on the secondary.
Example Failover Script Snippet (Conceptual)
#!/bin/bash
PRIMARY_WEB_IP="YOUR_PRIMARY_WEB_SERVER_IP"
SECONDARY_DB_HOST="YOUR_SECONDARY_DB_IP"
SSH_KEY="/path/to/your/private/ssh/key"
# --- Health Check ---
if ping -c 1 $PRIMARY_WEB_IP > /dev/null 2>&1; then
echo "Primary site is UP. No failover needed."
exit 0
fi
echo "Primary site is DOWN. Initiating failover to secondary..."
# --- Promote Secondary Database ---
echo "Promoting secondary database..."
ssh -i ${SSH_KEY} -o StrictHostKeyChecking=no ubuntu@${SECONDARY_DB_HOST} "
sudo systemctl stop mysql
sudo sed -i '