Migrated a WordPress site from aging on-premises infrastructure to AWS — provisioning the full target environment with Terraform, exporting the legacy database with mysqldump, importing into Amazon RDS MySQL in a private subnet, and validating the migration at the database layer before application cutover. A custom PHP verification page provides a second independent proof layer querying RDS directly.
Four Terraform files provisioning 18 AWS resources: VPC with public and private subnets across two AZs, internet gateway, route tables, EC2 web server with user_data bootstrap, RDS MySQL 8.0 in a private subnet group, security groups with least-privilege rules, IAM role with instance profile, and Secrets Manager secret holding the database credentials. A single terraform apply builds the complete target environment.
The legacy database was exported using mysqldump — the standard MySQL portable export tool. Before importing, network connectivity from EC2 to the private RDS endpoint was validated with a live SHOW DATABASES query. The data was loaded into RDS using explicit INSERT statements with full column mapping to account for schema differences between the source and the WordPress 6.x target. Each migrated table was queried post-import to confirm content matched the source before the application was reconfigured.
The EC2 instance runs a shell script on first boot that installs Apache, PHP, the MySQL client, and WordPress, sets file permissions, and places WordPress files in the web root. By the time terraform apply completes, the web server is fully configured. No SSH session required to set up the box. Infrastructure configuration lives in code, not in runbooks.
The RDS master password is stored in AWS Secrets Manager at p1/db-credentials alongside the host, username, and database name. The EC2 instance retrieves credentials through an IAM instance profile — no access keys stored on the box, no credentials in version-controlled files. This is the correct AWS pattern for application credential management at any scale.
RDS lives in a private subnet with no internet gateway route — there is no path from the public internet to the database. The RDS security group allows port 3306 inbound from the EC2 security group ID only. Two private subnets across two availability zones satisfy the RDS subnet group requirement and position the database for multi-AZ failover if needed. The web tier is publicly accessible; the data tier is not.
A custom PHP page deployed alongside WordPress connects directly to RDS, queries wp_posts and wp_options, and renders the migrated data as a styled HTML page — completely independent of WordPress. Two separate code paths confirming the same data is present and accessible: the WordPress site rendering migrated content as the homepage, and the PHP page returning raw query results from the same RDS instance.
terraform apply completed. No connection refused — just silence.
your_ip variable. The wrong IP had been entered. Security groups silently drop packets that don't match any allow rule rather than sending a rejection, which is why the connection hung instead of failing. Confirmed the correct IP with curl ifconfig.me, then ran terraform apply -var="your_ip=<correct_ip>" to update the rule. No other resources were modified.
mysqldump -u root returned Access denied for user 'root'@'localhost' without sudo.
auth_socket plugin by default, which requires the OS user to match the MySQL user. The ubuntu system user is not root, so the connection is rejected without elevation. Prefixed with sudo: sudo mysqldump -u root --no-tablespaces legacy_wordpress > /tmp/dump.sql
Unknown column 'autoload' in 'field list' and Unknown column 'wp_posts.post_type' in 'where clause' repeatedly.
autoload in wp_options and post_type in wp_posts. Dropped the imported tables, ran the WordPress installation wizard against the empty database to build the correct full schema, then inserted the migrated content using explicit INSERT statements with full column mapping. WordPress builds its own schema — the migration supplies only the data rows.
DROP TABLE IF EXISTS and CREATE TABLE before the data rows. Importing it into a database with a correctly structured WordPress schema drops and replaces those tables with the older source versions. Used explicit INSERT INTO statements with named columns instead — this loads the data into the existing schema without touching the table structure. The --no-create-info flag produces a data-only export but still failed with a column count mismatch because the source had fewer columns than the target. Explicit inserts were the correct solution.
PHP Fatal error: Uncaught mysqli_sql_exception: Access denied.
MYSQLI_REPORT_ERROR | MYSQLI_REPORT_STRICT by default, causing mysqli to throw uncaught exceptions on connection failure rather than setting connect_error. The exception fired before the error handling block could run. Added mysqli_report(MYSQLI_REPORT_OFF) at the top of the script to restore the traditional behavior. A secondary cause was an incorrect database password in the file — corrected after confirming the connection error was network-layer, not code-layer.
post_type = 'page'. The migrated records had been inserted with post_type = 'post'. Updated directly in RDS: UPDATE wp_posts SET post_type='page' WHERE post_title IN ('Welcome to Our New Cloud Home','Legacy Data Confirmed Intact'); — both pages appeared in the dropdown after refreshing the settings screen.