MAY 2026

Legacy System Migration
to AWS

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.

AWS Terraform RDS MySQL EC2 VPC Apache WordPress Secrets Manager IAM PHP mysqldump IaC
18 AWS Resources
0 Data Loss
2 Proof Artifacts
4 Terraform Files
01

Architecture

// NETWORK LAYER — Terraform VPC (10.0.0.0/16) ├── Public Subnet us-east-2a (10.0.1.0/24) │ └── EC2 t3.micro — Apache · PHP · WordPress │ └── user_data bootstrap (no manual config) │ ├── Private Subnet A us-east-2a (10.0.2.0/24) ┐ └── Private Subnet B us-east-2b (10.0.3.0/24) ┘ RDS Subnet Group └── RDS MySQL 8.0 (db.t3.micro · 20GB) // SECURITY LAYER EC2 Security Group ├── Inbound: HTTP 80 · HTTPS 443 · SSH 22 (authorized IP only) └── Outbound: all RDS Security Group └── Inbound: Port 3306 from EC2 Security Group ID only (no CIDR — only members of the EC2 group can reach MySQL) // CREDENTIAL LAYER AWS Secrets Manager ──▶ p1/db-credentials └── username · password · host · db_name └── IAM Instance Profile ──▶ EC2 reads without stored keys
Key design decision: The RDS security group ingress rule references the EC2 security group ID as its source — not a CIDR block. A CIDR rule allows any host in that IP range. A security group rule allows only resources that are explicit members of that group. The database is unreachable from anything except the application server, by design.
Tier separation: The original system ran MySQL on the same box as Apache and WordPress — a single point of failure with no path to scale. Moving the database to RDS in a private subnet means the web tier and database tier can be scaled, patched, and backed up independently. AWS handles RDS patching and automated backups. The application layer doesn't need to care about database operations.
02

What I Built

01
Infrastructure as Code — Terraform VPC · EC2 · RDS · IAM · Secrets Manager

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.

terraform vpc ec2 rds iam secrets manager
02
Database Migration — mysqldump to RDS Export · Validate · Import · Verify

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.

mysqldump mysql client rds mysql 8.0 schema migration
03
Automated Server Bootstrap — user_data Zero Manual Configuration

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.

user_data apache php 8.1 wordpress ubuntu 22.04
04
Credential Management — Secrets Manager No Hardcoded Credentials Anywhere

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.

secrets manager iam instance profile ssm policy zero stored keys
05
Private Subnet Network Architecture Defense in Depth at the Network Layer

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.

private subnet security groups multi-az defense in depth
06
PHP Verification Page — Independent Proof Layer Direct RDS Query · No WordPress Abstraction

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.

php mysqli rds validation wp_posts wp_options
03

Problems I Solved

ISSUE SSH to EC2 hung indefinitely after terraform apply completed. No connection refused — just silence.
RESOLVED The EC2 security group restricts SSH to port 22 from a single IP — the one provided at apply time via the 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.
ISSUE mysqldump -u root returned Access denied for user 'root'@'localhost' without sudo.
RESOLVED Ubuntu's MySQL package configures the root account with the 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
ISSUE WordPress returned a blank white screen after connecting to RDS. Apache error log showed Unknown column 'autoload' in 'field list' and Unknown column 'wp_posts.post_type' in 'where clause' repeatedly.
RESOLVED The source database tables were built against an older schema. WordPress 6.x requires additional columns not present in the imported structure — including 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.
ISSUE Replaying the mysqldump file after WordPress had installed its schema broke the installation again — blank white screen, admin login unresponsive.
RESOLVED A standard mysqldump includes 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.
ISSUE PHP verification page returned HTTP 500. Apache log showed PHP Fatal error: Uncaught mysqli_sql_exception: Access denied.
RESOLVED PHP 8.1 enables 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.
ISSUE WordPress Settings > Reading static front page dropdown only showed the default sample page — migrated content was not available as an option.
RESOLVED WordPress populates the static front page dropdown exclusively from content with 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.
04

Skills Demonstrated

// Cloud & Infrastructure
  • AWS VPC with public/private subnets
  • Amazon RDS MySQL 8.0
  • EC2 with user_data bootstrap
  • AWS Secrets Manager
  • IAM roles and instance profiles
  • Security group architecture
  • Multi-AZ subnet design
// Migration & Database
  • mysqldump export workflow
  • RDS import and validation
  • Schema compatibility analysis
  • Pre-cutover data verification
  • Explicit INSERT with column mapping
  • MySQL client diagnostics
  • WordPress database structure
// DevOps & Tooling
  • Terraform IaC (4 config files)
  • Git version control
  • Apache web server configuration
  • PHP 8.1 application deployment
  • Linux system administration
  • Security group ID vs CIDR rules
  • Credential management patterns