From cf725a7c0843d2686b40fa9ce7a027c847e15f38 Mon Sep 17 00:00:00 2001 From: Khoi Pro Date: Sat, 9 May 2026 15:09:50 +0700 Subject: [PATCH 1/7] feat(create-site): add --local-db flag for MySQL without SSL (closes #1) Allows running create-site.sh against a local MySQL instance (dev/test hosts) without the RDS-specific REQUIRE SSL enforcement. Validated step-by-step on a fresh Ubuntu 24.04 VPS. Findings from validation: - step_clone_repo: repos using `master` branch need --git-branch=master - step_summary: was printing hardcoded SSL line even in local mode; fixed Co-Authored-By: Claude Sonnet 4.6 --- bash-scripts/create-site.sh | 49 ++++++++++++++++++++++++++++--------- 1 file changed, 38 insertions(+), 11 deletions(-) diff --git a/bash-scripts/create-site.sh b/bash-scripts/create-site.sh index ec0fb7a..da760c2 100644 --- a/bash-scripts/create-site.sh +++ b/bash-scripts/create-site.sh @@ -41,6 +41,7 @@ WEBAPPS_DIR="/home/ubuntu/webapps" RDS_CA_PATH="/etc/ssl/certs/rds-global-bundle.pem" SKIP_DB=0 SKIP_CLONE=0 +LOCAL_DB=0 # skip RDS SSL enforcement (for local MySQL dev/test hosts) FORCE=0 # ---------- Helpers ---------- @@ -82,6 +83,7 @@ RDS / database (db name == db user, both equal to --site): --rds-master-pass=PASS RDS admin pass — or set env RDS_MASTER_PASS --db-pass=PASS App user password (auto-generated if omitted) --skip-db Skip DB creation even if --rds-host is given + --local-db Use local MySQL without SSL (dev/test only — not for RDS) Other: --force Overwrite existing files (NOT existing databases) @@ -128,6 +130,7 @@ parse_args() { --rds-master-pass=*) RDS_MASTER_PASS="${arg#*=}" ;; --db-pass=*) DB_PASS="${arg#*=}" ;; --skip-db) SKIP_DB=1 ;; + --local-db) LOCAL_DB=1 ;; --skip-clone) SKIP_CLONE=1 ;; --force) FORCE=1 ;; --help|-h) usage; exit 0 ;; @@ -226,16 +229,36 @@ step_create_database() { local cnf cnf=$(mktemp) chmod 600 "$cnf" - cat > "$cnf" < "$cnf" < "$cnf" <> "$cnf" if mysql --defaults-extra-file="$cnf" -e "SELECT 1;" "$SITE" >/dev/null 2>&1; then ok "App user '$SITE' can connect to '$SITE'" else rm -f "$cnf" - err "App user '$SITE' cannot connect — check RDS security group + parameter group (require_secure_transport)" + err "App user '$SITE' cannot connect — check host/firewall/SSL config" fi rm -f "$cnf" } @@ -319,7 +342,7 @@ if (!empty(\$_SERVER['HTTP_CF_CONNECTING_IP'])) { } // === RDS SSL connection (CA bundle: /etc/ssl/certs/rds-global-bundle.pem) === -define('MYSQL_CLIENT_FLAGS', MYSQLI_CLIENT_SSL); +$([ "$LOCAL_DB" -eq 0 ] && echo "define('MYSQL_CLIENT_FLAGS', MYSQLI_CLIENT_SSL);" || echo "// MYSQL_CLIENT_FLAGS omitted — local DB mode") // === Site URLs === define('WP_HOME', 'https://${DOMAIN}'); @@ -477,7 +500,11 @@ step_summary() { echo " DB name: $SITE (== DB user)" echo " DB user: $SITE" echo " DB pass: (saved to $SITE_ROOT/.credentials)" - echo " SSL: REQUIRE SSL on user; MYSQLI_CLIENT_SSL in wp-config" + if [ "$LOCAL_DB" -eq 1 ]; then + echo " SSL: skipped (--local-db mode — not for production)" + else + echo " SSL: REQUIRE SSL on user; MYSQLI_CLIENT_SSL in wp-config" + fi fi echo "" echo "Next steps:" From 8ded45259d86dcba38255f8b9c71c3c90d507131 Mon Sep 17 00:00:00 2001 From: Khoi Pro Date: Sat, 9 May 2026 15:26:54 +0700 Subject: [PATCH 2/7] feat(create-site): add --table-prefix flag + validation plan MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - --table-prefix=PREFIX passes custom table prefix to wp config create (default: wp_) — needed when importing DBs with non-default prefixes - Add plans/create-site-validation.md with step-by-step results and migration notes from the masanconsumer validation run Co-Authored-By: Claude Sonnet 4.6 --- bash-scripts/create-site.sh | 4 +++ plans/create-site-validation.md | 58 +++++++++++++++++++++++++++++++++ 2 files changed, 62 insertions(+) create mode 100644 plans/create-site-validation.md diff --git a/bash-scripts/create-site.sh b/bash-scripts/create-site.sh index da760c2..e12718f 100644 --- a/bash-scripts/create-site.sh +++ b/bash-scripts/create-site.sh @@ -37,6 +37,7 @@ RDS_HOST="" RDS_MASTER_USER="admin" RDS_MASTER_PASS="${RDS_MASTER_PASS:-}" DB_PASS="" +TABLE_PREFIX="wp_" WEBAPPS_DIR="/home/ubuntu/webapps" RDS_CA_PATH="/etc/ssl/certs/rds-global-bundle.pem" SKIP_DB=0 @@ -82,6 +83,7 @@ RDS / database (db name == db user, both equal to --site): --rds-master-user=USER RDS admin user (default: admin) --rds-master-pass=PASS RDS admin pass — or set env RDS_MASTER_PASS --db-pass=PASS App user password (auto-generated if omitted) + --table-prefix=PREFIX WordPress table prefix (default: wp_) --skip-db Skip DB creation even if --rds-host is given --local-db Use local MySQL without SSL (dev/test only — not for RDS) @@ -129,6 +131,7 @@ parse_args() { --rds-master-user=*) RDS_MASTER_USER="${arg#*=}" ;; --rds-master-pass=*) RDS_MASTER_PASS="${arg#*=}" ;; --db-pass=*) DB_PASS="${arg#*=}" ;; + --table-prefix=*) TABLE_PREFIX="${arg#*=}" ;; --skip-db) SKIP_DB=1 ;; --local-db) LOCAL_DB=1 ;; --skip-clone) SKIP_CLONE=1 ;; @@ -361,6 +364,7 @@ PHP --dbuser="$SITE" \ --dbpass="$DB_PASS" \ --dbhost="$RDS_HOST" \ + --dbprefix="$TABLE_PREFIX" \ --dbcharset=utf8mb4 \ --dbcollate=utf8mb4_unicode_ci \ --extra-php < "$extra" diff --git a/plans/create-site-validation.md b/plans/create-site-validation.md new file mode 100644 index 0000000..ae703b9 --- /dev/null +++ b/plans/create-site-validation.md @@ -0,0 +1,58 @@ +# Plan — step-by-step validation of `create-site.sh` + +Tracks issue [#1](https://github.com/codetot-web/ec2/issues/1). Branch: `feat/1-validate-create-site`. + +## Why + +`bash-scripts/create-site.sh` scaffolds a full WordPress site (dirs, git clone, RDS DB, vhost, FPM pool, wp-config). Before using it on a production EC2, validate each `step_*` against the same disposable Ubuntu 24.04 host used for bootstrap validation (`sg10.codetot.org`). + +## Validation host + +`sg10.codetot.org` — Ubuntu 24.04 VPS with bootstrap already applied. Not a real EC2, so: +- No RDS available → used local MySQL 8.0 with `--local-db` flag +- No `install-tools.sh` → `ct-fix-perm` absent; inline fallback triggered + +Site: `masanconsumer`, domain: `msc.codetot.org` +Git repo: `git@github.com-masanconsumer:codetot-clients/masanconsumer.git` + +## Phase 1 — step-by-step validation (run 2026-05-09) + +| # | Step | Expected post-condition | Actual | Notes | +|---|------|------------------------|--------|-------| +| 1 | `step_create_dirs` | `/home/ubuntu/webapps/masanconsumer/{public,logs,backups,tmp}` — 2775 ubuntu:www-data | ✓ | | +| 2 | `step_clone_repo` | Repo cloned to `public/` as ubuntu | ✓ | Repo uses `master` branch, not `main` — pass `--git-branch=master` | +| 3 | `step_create_database` | DB + user created, app user can connect | ✓ | Local MySQL, `--local-db` flag; SSL skipped with warn | +| 4 | `step_credentials_file` | `.credentials` mode 600 ubuntu:ubuntu | ✓ | | +| 5 | `step_wp_config` | `wp-config.php` 640 ubuntu:www-data, correct DB + proxy + URL constants | ✓ | `MYSQL_CLIENT_FLAGS` omitted in `--local-db` mode | +| 6 | `step_apache_vhost` | Vhost enabled, `apachectl configtest` clean | ✓ | | +| 7 | `step_php_fpm_pool` | Pool at `/etc/php/8.3/fpm/pool.d/masanconsumer.conf` | ✓ | | +| 8 | `step_reload_services` | Both services reload cleanly | ✓ | | +| 9 | `step_fix_permissions` | Inline fallback (no `ct-fix-perm`) | ✓ | Expected on hosts without `install-tools.sh` | +| 10 | `step_summary` | All values populated, SSL line reflects local mode | ✓ | | + +### Findings patched during validation + +| Finding | Resolution | +|---------|-----------| +| No way to skip RDS SSL for local dev/test | Added `--local-db` flag: skips `REQUIRE SSL` on user, omits `MYSQL_CLIENT_FLAGS` in wp-config, shows warning | +| `step_summary` hardcoded SSL line regardless of mode | Fixed to branch on `LOCAL_DB` | + +### Post-scaffold migration steps (run against sg10) + +- **DB**: exported from `sg3.codetot.org` via `wp db export`, imported on sg10 via `wp db import` +- **Table prefix**: repo uses `B4y_` not `wp_` — fixed `$table_prefix` in `wp-config.php` after scaffold +- **URL search-replace**: `http://masanconsumer.ztnhh1kbmv-oy4wrkng23pw.p.temp-site.link` → `https://msc.codetot.org` across all tables +- **Uploads**: 14 GB rsynced directly sg3→sg10 (`runcloud@sg3` → `ubuntu@sg10`) + +### Known gap: table prefix + +`create-site.sh` always writes `$table_prefix = 'wp_'` in `wp-config.php`. If the imported DB uses a different prefix, the operator must patch it manually. Consider adding a `--table-prefix=PREFIX` flag in a follow-up. + +## Phase 2 — idempotency re-run + +Pending (blocked until uploads rsync completes and permissions are re-applied). + +## What this plan deliberately does not do + +- Does not test against real RDS — that is a production validation concern. +- Does not test `ct-fix-perm` — needs `install-tools.sh` installed first. From afc68ca61b790b0d0d6742cc56d9f3d244045197 Mon Sep 17 00:00:00 2001 From: Khoi Pro Date: Sat, 9 May 2026 15:51:01 +0700 Subject: [PATCH 3/7] feat(tools): add certbot-site.sh for VPS SSL setup MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Installs certbot + python3-certbot-apache and obtains a Let's Encrypt cert for a named site. Idempotent, non-interactive. --no-alias flag skips the www SAN when www DNS isn't configured. For VPS/bare-metal only — production EC2 uses ACM via ALB. Co-Authored-By: Claude Sonnet 4.6 --- tools/certbot-site.sh | 147 ++++++++++++++++++++++++++++++++++++++++++ 1 file changed, 147 insertions(+) create mode 100644 tools/certbot-site.sh diff --git a/tools/certbot-site.sh b/tools/certbot-site.sh new file mode 100644 index 0000000..685e7d3 --- /dev/null +++ b/tools/certbot-site.sh @@ -0,0 +1,147 @@ +#!/bin/bash +# +# certbot-site.sh — Install certbot and obtain/renew a Let's Encrypt cert +# for a site already served by Apache on this host. +# +# Use on VPS/bare-metal hosts only. On production EC2 behind an ALB, +# TLS is terminated by ACM — do NOT run this there. +# +# Usage: +# sudo bash certbot-site.sh --site=NAME --domain=DOMAIN --email=EMAIL [options] +# +# Examples: +# sudo bash certbot-site.sh --site=masanconsumer --domain=msc.example.com --email=ops@example.com +# sudo bash certbot-site.sh --site=masanconsumer --domain=msc.example.com --email=ops@example.com --dry-run + +set -euo pipefail + +SITE="" +DOMAIN="" +ALIAS="" +ALIAS_SET=0 +EMAIL="" +DRY_RUN=0 + +log() { echo -e "\n\033[1;36m==>\033[0m $*"; } +ok() { echo -e " \033[1;32m✓\033[0m $*"; } +warn() { echo -e " \033[1;33m!\033[0m $*"; } +err() { echo -e "\033[1;31mERROR:\033[0m $*" >&2; exit 1; } + +usage() { + cat <.conf) + --domain=DOMAIN Primary domain for the certificate + --email=EMAIL Contact email for Let's Encrypt expiry notices + +Optional: + --alias=LIST Comma-separated extra SANs (e.g. www.domain.com) + Default: www. + --no-alias Skip the default www. alias + --dry-run Run certbot in dry-run mode (no cert issued) + -h, --help Show this and exit +EOF +} + +parse_args() { + for arg in "$@"; do + case "$arg" in + --site=*) SITE="${arg#*=}" ;; + --domain=*) DOMAIN="${arg#*=}" ;; + --alias=*) ALIAS="${arg#*=}"; ALIAS_SET=1 ;; + --no-alias) ALIAS=""; ALIAS_SET=1 ;; + --email=*) EMAIL="${arg#*=}" ;; + --dry-run) DRY_RUN=1 ;; + --help|-h) usage; exit 0 ;; + *) usage; err "Unknown argument: $arg" ;; + esac + done + + [ -n "$SITE" ] || { usage; err "Missing --site"; } + [ -n "$DOMAIN" ] || { usage; err "Missing --domain"; } + [ -n "$EMAIL" ] || { usage; err "Missing --email"; } + + [ "$ALIAS_SET" -eq 0 ] && ALIAS="www.${DOMAIN}" + [ "$EUID" -eq 0 ] || err "Run as root (use sudo)" +} + +step_install_certbot() { + log "Installing certbot + Apache plugin" + if command -v certbot >/dev/null 2>&1; then + ok "certbot already installed ($(certbot --version 2>&1))" + return + fi + apt-get install -y certbot python3-certbot-apache + ok "certbot installed ($(certbot --version 2>&1))" +} + +step_check_vhost() { + log "Checking Apache vhost for $SITE" + local vhost="/etc/apache2/sites-enabled/${SITE}.conf" + [ -f "$vhost" ] || err "Vhost not enabled: $vhost — run create-site.sh first" + ok "Vhost present: $vhost" +} + +step_get_cert() { + log "Obtaining Let's Encrypt cert for $DOMAIN" + + # Build -d flags from domain + comma-separated aliases + local d_flags="-d $DOMAIN" + if [ -n "$ALIAS" ]; then + for san in $(echo "$ALIAS" | tr ',' ' '); do + d_flags="$d_flags -d $san" + done + fi + + local dry_flag="" + [ "$DRY_RUN" -eq 1 ] && dry_flag="--dry-run" + + # shellcheck disable=SC2086 + certbot --apache \ + $d_flags \ + --email "$EMAIL" \ + --agree-tos \ + --non-interactive \ + --redirect \ + $dry_flag + + if [ "$DRY_RUN" -eq 1 ]; then + warn "Dry-run complete — no cert was issued" + else + ok "Certificate obtained and Apache reloaded" + fi +} + +step_verify_renewal() { + [ "$DRY_RUN" -eq 1 ] && return + log "Verifying auto-renewal timer" + if systemctl is-active --quiet snap.certbot.renew.timer 2>/dev/null || \ + systemctl is-active --quiet certbot.timer 2>/dev/null; then + ok "Auto-renewal timer active" + elif crontab -l 2>/dev/null | grep -q certbot; then + ok "Auto-renewal via cron" + else + warn "No renewal timer found — add a cron entry:" + warn " 0 3 * * * certbot renew --quiet" + fi +} + +step_summary() { + [ "$DRY_RUN" -eq 1 ] && return + log "Certificate summary" + certbot certificates --domain "$DOMAIN" 2>/dev/null | grep -E "Domains|Expiry|Certificate Path" || true + echo "" + echo " Renewal command: certbot renew --quiet" + echo " Force renew: certbot renew --force-renewal --cert-name $DOMAIN" + echo " Revoke: certbot revoke --cert-name $DOMAIN" + echo "" +} + +parse_args "$@" +step_install_certbot +step_check_vhost +step_get_cert +step_verify_renewal +step_summary From 4d730526e080828dcd4371bc3ead6e17ef299828 Mon Sep 17 00:00:00 2001 From: Khoi Pro Date: Sat, 9 May 2026 16:02:28 +0700 Subject: [PATCH 4/7] fix(create-site): scaffold default .htaccess for WordPress permalinks step_htaccess writes the standard WordPress mod_rewrite block when no .htaccess exists in public/. Skips if the file is already present (e.g. committed in the repo); --force overwrites. Runs after step_clone_repo so repo-provided rules take precedence. Co-Authored-By: Claude Sonnet 4.6 --- bash-scripts/create-site.sh | 26 ++++++++++++++++++++++++++ 1 file changed, 26 insertions(+) diff --git a/bash-scripts/create-site.sh b/bash-scripts/create-site.sh index e12718f..4afb652 100644 --- a/bash-scripts/create-site.sh +++ b/bash-scripts/create-site.sh @@ -216,6 +216,31 @@ step_clone_repo() { ok "Cloned to $PUBLIC_DIR" } +step_htaccess() { + local htaccess="$PUBLIC_DIR/.htaccess" + + if [ -f "$htaccess" ] && [ "$FORCE" -ne 1 ]; then + ok ".htaccess exists — keeping (use --force to overwrite)" + return + fi + + log "Writing default WordPress .htaccess" + sudo -u ubuntu tee "$htaccess" > /dev/null <<'HTACCESS' +# BEGIN WordPress + +RewriteEngine On +RewriteRule .* - [E=HTTP_AUTHORIZATION:%{HTTP:Authorization}] +RewriteBase / +RewriteRule ^index\.php$ - [L] +RewriteCond %{REQUEST_FILENAME} !-f +RewriteCond %{REQUEST_FILENAME} !-d +RewriteRule . /index.php [L] + +# END WordPress +HTACCESS + ok ".htaccess written (ubuntu:www-data 0664)" +} + step_create_database() { if [ -z "$RDS_HOST" ]; then warn "No --rds-host, skipping DB setup" @@ -537,6 +562,7 @@ parse_args "$@" preflight step_create_dirs step_clone_repo +step_htaccess step_create_database step_credentials_file step_wp_config From dcd9e2c9d791fc437870e0c4cf7beaa6a312071f Mon Sep 17 00:00:00 2001 From: Khoi Pro Date: Sat, 9 May 2026 16:28:18 +0700 Subject: [PATCH 5/7] feat(tools): add migrate-site.sh + .env.sample MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit migrate-site.sh orchestrates a full VPS→EC2+RDS migration: 1. Provisions the site on EC2 via create-site.sh (RDS DB, vhost, FPM, wp-config) 2. Exports DB from staging MySQL, transfers and imports into RDS 3. Search-replaces staging domain → prod domain (when they differ) 4. Rsyncs wp-content/uploads/ staging→EC2 (incremental) 5. Writes .htaccess, flushes rewrites/cache, fixes permissions 6. Smoke-tests siteurl + latest post + uploads size Reads all config from .env (see .env.sample). Safe to re-run. Flags: --skip-provision, --skip-db, --skip-uploads, --dry-run. Co-Authored-By: Claude Sonnet 4.6 --- .env.sample | 42 ++++++ .gitignore | 1 + tools/migrate-site.sh | 315 ++++++++++++++++++++++++++++++++++++++++++ 3 files changed, 358 insertions(+) create mode 100644 .env.sample create mode 100644 tools/migrate-site.sh diff --git a/.env.sample b/.env.sample new file mode 100644 index 0000000..c9fee74 --- /dev/null +++ b/.env.sample @@ -0,0 +1,42 @@ +# Migration configuration — copy to .env and fill in values +# .env is gitignored; never commit real credentials. + +# --------------------------------------------------------------------------- +# Source — staging VPS (where the site lives now) +# --------------------------------------------------------------------------- +STAGING_SSH=ubuntu@sg10.codetot.org +STAGING_SSH_PASS= # leave empty if key-based auth works +STAGING_DOMAIN=msc.codetot.org +STAGING_WP_PATH=/home/ubuntu/webapps/masanconsumer/public + +# --------------------------------------------------------------------------- +# Target — production EC2 +# --------------------------------------------------------------------------- +EC2_SSH=ubuntu@ +# Key-based auth assumed (your ~/.ssh key must be in EC2's authorized_keys). +# EC2_SSH_KEY=/path/to/key.pem # uncomment if using a specific key file + +# --------------------------------------------------------------------------- +# RDS (shared instance — one DB per site, db name == db user == site name) +# --------------------------------------------------------------------------- +RDS_HOST=..rds.amazonaws.com +RDS_MASTER_USER=admin +RDS_MASTER_PASS= + +# --------------------------------------------------------------------------- +# Site +# --------------------------------------------------------------------------- +SITE=masanconsumer +PROD_DOMAIN=masanconsumer.com +GIT_REPO=git@github.com-masanconsumer:codetot-clients/masanconsumer.git +GIT_BRANCH=master +TABLE_PREFIX=B4y_ +PHP_VERSION=8.3 + +# --------------------------------------------------------------------------- +# Optional tuning (create-site.sh defaults shown) +# --------------------------------------------------------------------------- +# MEMORY_LIMIT=512M +# UPLOAD_MAX=64M +# MAX_CHILDREN=20 +# VPC_CIDR=10.0.0.0/16 diff --git a/.gitignore b/.gitignore index 2f556e6..d226620 100644 --- a/.gitignore +++ b/.gitignore @@ -1,6 +1,7 @@ # Secrets — NEVER commit .env .env.* +!.env.sample !.env.example # macOS diff --git a/tools/migrate-site.sh b/tools/migrate-site.sh new file mode 100644 index 0000000..56270fc --- /dev/null +++ b/tools/migrate-site.sh @@ -0,0 +1,315 @@ +#!/bin/bash +# +# migrate-site.sh — Move a WordPress site from a staging VPS to production EC2 + RDS +# +# Reads all config from .env (see .env.sample). Run from the repo root: +# +# bash tools/migrate-site.sh [--env=path/to/.env] [--skip-provision] +# [--skip-db] [--skip-uploads] [--dry-run] +# +# What it does: +# 1. Provisions the site on EC2 via create-site.sh (dirs, vhost, FPM, RDS DB + wp-config) +# 2. Exports DB from staging MySQL and imports it into RDS via EC2 +# 3. Search-replaces staging domain → prod domain in the DB +# 4. Rsyncs wp-content/uploads/ from staging → EC2 +# 5. Writes .htaccess, flushes rewrites, fixes permissions +# +# Safe to re-run: create-site.sh is idempotent; rsync is incremental. +# + +set -euo pipefail + +SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" +REPO_ROOT="$(cd "$SCRIPT_DIR/.." && pwd)" + +# ---------- Defaults ---------- +ENV_FILE="$REPO_ROOT/.env" +SKIP_PROVISION=0 +SKIP_DB=0 +SKIP_UPLOADS=0 +DRY_RUN=0 + +# ---------- Helpers ---------- +log() { echo -e "\n\033[1;36m==>\033[0m $*"; } +ok() { echo -e " \033[1;32m✓\033[0m $*"; } +warn() { echo -e " \033[1;33m!\033[0m $*"; } +err() { echo -e "\033[1;31mERROR:\033[0m $*" >&2; exit 1; } +dry() { [ "$DRY_RUN" -eq 1 ] && echo " [dry-run] $*" || true; } + +for arg in "$@"; do + case "$arg" in + --env=*) ENV_FILE="${arg#*=}" ;; + --skip-provision) SKIP_PROVISION=1 ;; + --skip-db) SKIP_DB=1 ;; + --skip-uploads) SKIP_UPLOADS=1 ;; + --dry-run) DRY_RUN=1 ;; + --help|-h) + sed -n '3,12p' "$0" | sed 's/^# \?//' + exit 0 ;; + *) err "Unknown flag: $arg" ;; + esac +done + +# ---------- Load config ---------- +[ -f "$ENV_FILE" ] || err ".env not found: $ENV_FILE\n Copy .env.sample → .env and fill in values." +# shellcheck disable=SC1090 +source "$ENV_FILE" + +# Required vars +for var in STAGING_SSH STAGING_DOMAIN STAGING_WP_PATH \ + EC2_SSH RDS_HOST RDS_MASTER_USER RDS_MASTER_PASS \ + SITE PROD_DOMAIN; do + [ -n "${!var:-}" ] || err "Missing required .env variable: $var" +done + +# Optional with defaults +GIT_REPO="${GIT_REPO:-}" +GIT_BRANCH="${GIT_BRANCH:-master}" +TABLE_PREFIX="${TABLE_PREFIX:-wp_}" +PHP_VERSION="${PHP_VERSION:-8.3}" +MEMORY_LIMIT="${MEMORY_LIMIT:-512M}" +UPLOAD_MAX="${UPLOAD_MAX:-64M}" +MAX_CHILDREN="${MAX_CHILDREN:-20}" +VPC_CIDR="${VPC_CIDR:-10.0.0.0/16}" +STAGING_SSH_PASS="${STAGING_SSH_PASS:-}" + +# SSH helpers — staging may need sshpass; EC2 uses key-based auth +EC2_KEY_FLAG="" +[ -n "${EC2_SSH_KEY:-}" ] && EC2_KEY_FLAG="-i $EC2_SSH_KEY" + +staging_ssh() { + if [ -n "$STAGING_SSH_PASS" ]; then + sshpass -p "$STAGING_SSH_PASS" ssh -o StrictHostKeyChecking=no -o ServerAliveInterval=30 \ + "$STAGING_SSH" "$@" + else + ssh -o StrictHostKeyChecking=no -o ServerAliveInterval=30 "$STAGING_SSH" "$@" + fi +} + +ec2_ssh() { + # shellcheck disable=SC2086 + ssh -o StrictHostKeyChecking=no -o ServerAliveInterval=30 $EC2_KEY_FLAG "$EC2_SSH" "$@" +} + +staging_scp_to_ec2() { + local src="$1" dst="$2" + staging_ssh "scp -o StrictHostKeyChecking=no $EC2_KEY_FLAG '$src' '${EC2_SSH}:${dst}'" +} + +EC2_SITE_ROOT="/home/ubuntu/webapps/$SITE" +EC2_PUBLIC_DIR="$EC2_SITE_ROOT/public" +DUMP_FILE="/tmp/${SITE}-migrate-$(date +%Y%m%d%H%M).sql" + +# ---------- Step 0 — Pre-flight ---------- +step_preflight() { + log "Pre-flight checks" + + [ "$DRY_RUN" -eq 1 ] && warn "DRY-RUN mode — no changes will be made" + + # Reachability + staging_ssh "echo staging-ok" >/dev/null || err "Cannot reach staging: $STAGING_SSH" + ok "Staging reachable: $STAGING_SSH" + + ec2_ssh "echo ec2-ok" >/dev/null || err "Cannot reach EC2: $EC2_SSH" + ok "EC2 reachable: $EC2_SSH" + + # Staging WP path + staging_ssh "[ -f '${STAGING_WP_PATH}/wp-config.php' ]" \ + || err "wp-config.php not found at $STAGING_WP_PATH on staging" + ok "WordPress found at $STAGING_WP_PATH" + + # EC2 bootstrap + ec2_ssh "[ -d /home/ubuntu/webapps ]" \ + || err "EC2 not bootstrapped — run ct-bootstrap first" + ok "EC2 bootstrapped" + + # Authorize staging → EC2 SSH (needed for direct rsync + scp) + local staging_pubkey + staging_pubkey=$(staging_ssh "cat /home/ubuntu/.ssh/id_ed25519.pub 2>/dev/null || \ + ssh-keygen -t ed25519 -f /home/ubuntu/.ssh/id_ed25519 -N '' -C 'ubuntu@staging-migrate' \ + >/dev/null 2>&1 && cat /home/ubuntu/.ssh/id_ed25519.pub") + ec2_ssh "grep -qF '${staging_pubkey}' ~/.ssh/authorized_keys 2>/dev/null || \ + echo '${staging_pubkey}' >> ~/.ssh/authorized_keys" + ok "Staging → EC2 SSH trust established" +} + +# ---------- Step 1 — Provision on EC2 ---------- +step_provision() { + if [ "$SKIP_PROVISION" -eq 1 ]; then + warn "--skip-provision: skipping EC2 site setup" + return + fi + + log "Provisioning site '$SITE' on EC2" + [ "$DRY_RUN" -eq 1 ] && { dry "Would run create-site.sh on EC2"; return; } + + # Upload create-site.sh + scp -o StrictHostKeyChecking=no $EC2_KEY_FLAG \ + "$REPO_ROOT/bash-scripts/create-site.sh" "${EC2_SSH}:/tmp/create-site.sh" + + # Build args + local args="--site=$SITE --domain=$PROD_DOMAIN" + args="$args --rds-host=$RDS_HOST --rds-master-user=$RDS_MASTER_USER" + args="$args --table-prefix=$TABLE_PREFIX --php-version=$PHP_VERSION" + args="$args --memory-limit=$MEMORY_LIMIT --upload-max=$UPLOAD_MAX" + args="$args --max-children=$MAX_CHILDREN --vpc-cidr=$VPC_CIDR" + [ -n "$GIT_REPO" ] && args="$args --git-repo=$GIT_REPO --git-branch=$GIT_BRANCH" + args="$args --force" # safe re-run: overwrites config, not DB data + + ec2_ssh "sudo RDS_MASTER_PASS='${RDS_MASTER_PASS}' bash /tmp/create-site.sh $args" + ok "Site provisioned on EC2" +} + +# ---------- Step 2 — Sync database ---------- +step_sync_db() { + if [ "$SKIP_DB" -eq 1 ]; then + warn "--skip-db: skipping database sync" + return + fi + + log "Exporting DB from staging" + [ "$DRY_RUN" -eq 1 ] && { dry "Would export $STAGING_WP_PATH DB and import to RDS"; return; } + + staging_ssh "sudo -u ubuntu wp db export $DUMP_FILE \ + --path=$STAGING_WP_PATH --allow-root 2>&1 | tail -2" + ok "DB exported to $DUMP_FILE on staging" + + log "Transferring DB dump staging → EC2" + staging_scp_to_ec2 "$DUMP_FILE" "$DUMP_FILE" + ok "DB dump transferred to EC2" + + log "Importing DB into RDS" + ec2_ssh "cd $EC2_PUBLIC_DIR && sudo -u ubuntu wp db import $DUMP_FILE 2>&1 | tail -2" + ok "DB imported into RDS" + + log "Cleaning up dump files" + staging_ssh "rm -f $DUMP_FILE" + ec2_ssh "rm -f $DUMP_FILE" + ok "Dump files removed" + + # Search-replace only when domains differ + if [ "$STAGING_DOMAIN" != "$PROD_DOMAIN" ]; then + log "Replacing $STAGING_DOMAIN → $PROD_DOMAIN" + ec2_ssh "cd $EC2_PUBLIC_DIR && \ + sudo -u ubuntu wp search-replace 'https://${STAGING_DOMAIN}' 'https://${PROD_DOMAIN}' \ + --all-tables --skip-columns=guid --report-changed-only 2>&1 \ + | grep -v '^WordPress database error' | tail -5" + ok "URL search-replace complete" + else + ok "Staging and prod domains match — no search-replace needed" + fi +} + +# ---------- Step 3 — Sync uploads ---------- +step_sync_uploads() { + if [ "$SKIP_UPLOADS" -eq 1 ]; then + warn "--skip-uploads: skipping uploads sync" + return + fi + + log "Rsyncing uploads staging → EC2 (incremental)" + [ "$DRY_RUN" -eq 1 ] && { dry "Would rsync $STAGING_WP_PATH/wp-content/uploads/ → EC2"; return; } + + local src="${STAGING_WP_PATH}/wp-content/uploads/" + local dst="${EC2_PUBLIC_DIR}/wp-content/uploads/" + local ec2_host="${EC2_SSH#*@}" + local ec2_user="${EC2_SSH%%@*}" + + staging_ssh "sudo -u ubuntu rsync -az \ + -e 'ssh -o StrictHostKeyChecking=no ${EC2_KEY_FLAG}' \ + '$src' '${ec2_user}@${ec2_host}:${dst}'" + ok "Uploads synced" +} + +# ---------- Step 4 — Finalise ---------- +step_finalise() { + log "Writing .htaccess + flushing rewrites + fixing permissions" + [ "$DRY_RUN" -eq 1 ] && { dry "Would finalise site on EC2"; return; } + + ec2_ssh "bash -s" </dev/null <<'HTACCESS_EOF' +# BEGIN WordPress + +RewriteEngine On +RewriteRule .* - [E=HTTP_AUTHORIZATION:%{HTTP:Authorization}] +RewriteBase / +RewriteRule ^index\.php\$ - [L] +RewriteCond %{REQUEST_FILENAME} !-f +RewriteCond %{REQUEST_FILENAME} !-d +RewriteRule . /index.php [L] + +# END WordPress +HTACCESS_EOF + echo " .htaccess written" +fi + +cd $EC2_PUBLIC_DIR +sudo -u ubuntu wp rewrite flush 2>/dev/null || true +sudo -u ubuntu wp cache flush 2>/dev/null || true +echo " rewrites + cache flushed" + +# Inline permissions (ct-fix-perm if available, else fallback) +if command -v ct-fix-perm >/dev/null 2>&1; then + sudo ct-fix-perm --site=$SITE +else + chown -R ubuntu:www-data $EC2_SITE_ROOT + find $EC2_SITE_ROOT -path "*/.git" -prune -o -type d -exec chmod 2775 {} + + find $EC2_SITE_ROOT -path "*/.git" -prune -o -type f -exec chmod 0664 {} + + [ -f $EC2_PUBLIC_DIR/wp-config.php ] && chmod 640 $EC2_PUBLIC_DIR/wp-config.php + [ -f $EC2_SITE_ROOT/.credentials ] && chmod 600 $EC2_SITE_ROOT/.credentials + echo " permissions fixed" +fi +REMOTE + ok "Site finalised" +} + +# ---------- Step 5 — Smoke test ---------- +step_smoke_test() { + log "Smoke test" + local url + url=$(ec2_ssh "cd $EC2_PUBLIC_DIR && sudo -u ubuntu wp option get siteurl 2>/dev/null") + ok "siteurl: $url" + + local post + post=$(ec2_ssh "cd $EC2_PUBLIC_DIR && \ + sudo -u ubuntu wp post list --post_type=post --posts_per_page=1 \ + --fields=post_title --format=csv 2>/dev/null | tail -1") + ok "latest post: $post" + + local upload_size + upload_size=$(ec2_ssh "du -sh $EC2_PUBLIC_DIR/wp-content/uploads/ 2>/dev/null | cut -f1") + ok "uploads: $upload_size" +} + +# ---------- Summary ---------- +step_summary() { + echo "" + echo " ┌─────────────────────────────────────────────┐" + echo " │ Migration complete: $SITE" + echo " │" + echo " │ Site root: $EC2_SITE_ROOT" + echo " │ Domain: https://$PROD_DOMAIN" + echo " │ RDS host: $RDS_HOST" + echo " │ DB/user: $SITE (REQUIRE SSL)" + echo " └─────────────────────────────────────────────┘" + echo "" + echo " Next steps:" + echo " 1. Verify https://$PROD_DOMAIN in a browser" + echo " 2. Update CloudFlare DNS: $PROD_DOMAIN → EC2/ALB" + echo " 3. Add WP-Cron to crontab on EC2:" + echo " */5 * * * * cd $EC2_PUBLIC_DIR && /usr/local/bin/wp cron event run --due-now >/dev/null 2>&1" + echo " 4. Set up backup: ct-backup --site=$SITE --bucket=" + echo "" +} + +# ---------- Main ---------- +step_preflight +step_provision +step_sync_db +step_sync_uploads +step_finalise +step_smoke_test +step_summary From 7190d77688f533ece4b30328bc00c587cff313ee Mon Sep 17 00:00:00 2001 From: Khoi Pro Date: Sat, 9 May 2026 17:16:05 +0700 Subject: [PATCH 6/7] docs: add EC2 setup runbook for masanconsumer End-to-end guide covering bootstrap, create-site, data migration via migrate-site.sh, DNS cutover, and post-cutover tasks (cron, S3 backup, RDS SSL verification). Co-Authored-By: Claude Sonnet 4.6 --- docs/runbook-ec2-masanconsumer.md | 303 ++++++++++++++++++++++++++++++ 1 file changed, 303 insertions(+) create mode 100644 docs/runbook-ec2-masanconsumer.md diff --git a/docs/runbook-ec2-masanconsumer.md b/docs/runbook-ec2-masanconsumer.md new file mode 100644 index 0000000..b4ece0c --- /dev/null +++ b/docs/runbook-ec2-masanconsumer.md @@ -0,0 +1,303 @@ +# Runbook — EC2 setup for masanconsumer + +End-to-end guide: provision a fresh Ubuntu 24.04 EC2 instance, install the +toolchain, create the masanconsumer site, and migrate data from staging. + +## Prerequisites + +Before starting, have these AWS resources ready: + +| Resource | Notes | +|---|---| +| EC2 — Ubuntu 24.04 LTS (x86_64) | t3.medium or larger; at least 20 GB root volume | +| RDS — MySQL 8.0 | `require_secure_transport=ON`; EC2 security group must reach port 3306 | +| ALB | HTTPS listener (ACM cert); HTTP → HTTPS redirect; target group points to EC2 port 80 | +| CloudFlare | DNS proxied → ALB DNS name | +| S3 bucket | For backups (optional at install time) | +| IAM role on EC2 | Policy: `s3:PutObject`, `s3:GetObject`, `s3:ListBucket` on the backup bucket | + +--- + +## Phase 1 — Bootstrap the EC2 + +Run once per EC2 instance. Installs Apache, PHP 8.3-FPM, WP-CLI, Redis, +RDS CA bundle, UFW, Fail2ban, and swap. + +### 1.1 SSH into the EC2 + +```bash +ssh ubuntu@ +``` + +### 1.2 Download and run the bootstrap script + +```bash +curl -fsSL https://raw.githubusercontent.com/codetot-web/ec2/main/bash-scripts/bootstrap-ec2-wordpress.sh \ + | sudo bash +``` + +Expected output ends with a summary table showing all components installed. +The script is idempotent — safe to re-run if interrupted. + +### 1.3 Reboot if a new kernel was installed + +The summary will warn if `/var/run/reboot-required` exists: + +```bash +sudo reboot +``` + +### 1.4 Verify + +```bash +php -v # PHP 8.3.x +wp --info --allow-root # WP-CLI 2.x +redis-cli ping # PONG +sudo ufw status # 22, 80, 443 ALLOW +openssl x509 -noout -in /etc/ssl/certs/rds-global-bundle.pem 2>/dev/null && echo ok +``` + +--- + +## Phase 2 — Create the masanconsumer site + +Run once per site. Scaffolds dirs, vhost, PHP-FPM pool, RDS database, +wp-config.php, and `.htaccess`. + +### 2.1 Set up the GitHub deploy key + +Generate a deploy key on the EC2: + +```bash +sudo -u ubuntu ssh-keygen -t ed25519 -C "deploy@ec2-masanconsumer" \ + -f /home/ubuntu/.ssh/id_ed25519_masanconsumer -N "" +cat /home/ubuntu/.ssh/id_ed25519_masanconsumer.pub +``` + +Add the printed public key to GitHub as a **read-only** deploy key: +`github.com/codetot-clients/masanconsumer/settings/keys` + +Then add the SSH alias on the EC2: + +```bash +sudo -u ubuntu tee -a /home/ubuntu/.ssh/config <<'EOF' + +Host github.com-masanconsumer + HostName github.com + User git + IdentityFile ~/.ssh/id_ed25519_masanconsumer + IdentitiesOnly yes +EOF +``` + +Verify: + +```bash +sudo -u ubuntu ssh -T git@github.com-masanconsumer +# Hi codetot-clients/masanconsumer! You've successfully authenticated... +``` + +### 2.2 Download create-site.sh + +```bash +curl -fsSL https://raw.githubusercontent.com/codetot-web/ec2/main/bash-scripts/create-site.sh \ + -o /tmp/create-site.sh +``` + +### 2.3 Run create-site.sh + +```bash +sudo RDS_MASTER_PASS='' bash /tmp/create-site.sh \ + --site=masanconsumer \ + --domain=masanconsumer.com \ + --git-repo=git@github.com-masanconsumer:codetot-clients/masanconsumer.git \ + --git-branch=master \ + --table-prefix=B4y_ \ + --rds-host=.rds.amazonaws.com \ + --php-version=8.3 \ + --memory-limit=512M \ + --upload-max=64M \ + --max-children=20 +``` + +The script creates: + +| Path | Purpose | +|---|---| +| `/home/ubuntu/webapps/masanconsumer/{public,logs,backups,tmp}` | Site tree (2775 ubuntu:www-data) | +| `/etc/apache2/sites-available/masanconsumer.conf` | Apache vhost | +| `/etc/php/8.3/fpm/pool.d/masanconsumer.conf` | PHP-FPM pool (Unix socket) | +| `/home/ubuntu/webapps/masanconsumer/public/wp-config.php` | DB + proxy + SSL config (640) | +| `/home/ubuntu/webapps/masanconsumer/public/.htaccess` | WordPress mod_rewrite rules | +| `/home/ubuntu/webapps/masanconsumer/.credentials` | Generated DB password (600) | + +### 2.4 Verify the scaffold + +```bash +sudo -u ubuntu wp option get siteurl \ + --path=/home/ubuntu/webapps/masanconsumer/public +# https://masanconsumer.com + +sudo apache2ctl configtest # Syntax OK +systemctl is-active php8.3-fpm apache2 +``` + +--- + +## Phase 3 — Migrate data from staging + +Runs from your **local machine** (the dev machine that has SSH access to both +staging and EC2). Uses `tools/migrate-site.sh` from this repo. + +### 3.1 Clone the repo locally (if not already) + +```bash +git clone https://github.com/codetot-web/ec2.git +cd ec2 +``` + +### 3.2 Configure .env + +```bash +cp .env.sample .env +``` + +Edit `.env`: + +```dotenv +# Staging (current VPS) +STAGING_SSH=ubuntu@sg10.codetot.org +STAGING_SSH_PASS= +STAGING_DOMAIN=msc.codetot.org +STAGING_WP_PATH=/home/ubuntu/webapps/masanconsumer/public + +# Target EC2 +EC2_SSH=ubuntu@ +# EC2_SSH_KEY=/path/to/key.pem # if not in ssh-agent + +# RDS +RDS_HOST=..rds.amazonaws.com +RDS_MASTER_USER=admin +RDS_MASTER_PASS= + +# Site +SITE=masanconsumer +PROD_DOMAIN=masanconsumer.com +GIT_REPO=git@github.com-masanconsumer:codetot-clients/masanconsumer.git +GIT_BRANCH=master +TABLE_PREFIX=B4y_ +PHP_VERSION=8.3 +``` + +### 3.3 Dry-run first + +```bash +bash tools/migrate-site.sh --dry-run +``` + +### 3.4 Run the migration + +```bash +bash tools/migrate-site.sh +``` + +What it does: + +1. Confirms SSH reachability to both hosts +2. Authorises staging → EC2 direct SSH (for rsync) +3. Uploads and runs `create-site.sh` on EC2 with `--force` (re-provision if already done) +4. Exports DB from staging MySQL via `wp db export` +5. Transfers dump staging → EC2, imports into RDS via `wp db import` +6. Search-replaces `https://msc.codetot.org` → `https://masanconsumer.com` +7. Rsyncs `wp-content/uploads/` directly staging → EC2 (incremental) +8. Writes `.htaccess`, flushes rewrites and cache, fixes permissions +9. Smoke-tests siteurl, latest post, and uploads size + +To re-sync data only (skip re-provisioning): + +```bash +bash tools/migrate-site.sh --skip-provision +``` + +To re-sync uploads only: + +```bash +bash tools/migrate-site.sh --skip-provision --skip-db +``` + +--- + +## Phase 4 — DNS cutover + +### 4.1 Verify the site on EC2 before cutover + +Add a temporary entry to your local `/etc/hosts`: + +``` + masanconsumer.com +``` + +Visit `https://masanconsumer.com` — confirm it loads correctly, then remove the hosts entry. + +### 4.2 Update CloudFlare DNS + +In CloudFlare dashboard for `masanconsumer.com`: + +1. Set the `A` (or `CNAME`) record to point to the **ALB DNS name** (not the EC2 IP directly) +2. Enable the orange cloud (proxy) — CloudFlare terminates TLS, passes HTTP to ALB +3. Set SSL/TLS mode to **Full** (not Full Strict — ALB has ACM cert, EC2 is plain HTTP) + +TTL: set to 60s before cutover, restore to Auto after. + +### 4.3 Verify live + +```bash +curl -sI https://masanconsumer.com/ | head -5 +# HTTP/2 200 +``` + +--- + +## Phase 5 — Post-cutover tasks + +### WP-Cron + +Add to ubuntu's crontab on EC2 (`sudo -u ubuntu crontab -e`): + +``` +*/5 * * * * cd /home/ubuntu/webapps/masanconsumer/public && /usr/local/bin/wp cron event run --due-now >/dev/null 2>&1 +``` + +### S3 backups + +```bash +# Manual test +ct-backup --site=masanconsumer --bucket= + +# Add to ubuntu's crontab +0 3 * * * ct-backup --site=masanconsumer --bucket= +``` + +### Verify RDS SSL is active + +```bash +sudo -u ubuntu wp db cli \ + --path=/home/ubuntu/webapps/masanconsumer/public \ + -e "SHOW STATUS LIKE 'Ssl_cipher';" +# Value should be non-empty (e.g. TLS_AES_256_GCM_SHA384) +``` + +--- + +## Quick reference + +| Task | Command (run on EC2 as root unless noted) | +|---|---| +| Re-bootstrap | `sudo bash /tmp/bootstrap-ec2-wordpress.sh` | +| Re-create site config | `sudo RDS_MASTER_PASS='xxx' bash /tmp/create-site.sh --site=masanconsumer ... --force` | +| Fix permissions | `sudo ct-fix-perm --site=masanconsumer` | +| Manual backup | `ct-backup --site=masanconsumer --bucket=` (as ubuntu) | +| Resync uploads only | `bash tools/migrate-site.sh --skip-provision --skip-db` (local) | +| Verify DB SSL | `sudo -u ubuntu wp db cli -e "SHOW STATUS LIKE 'Ssl_cipher';"` | +| Reload services | `sudo systemctl reload apache2 php8.3-fpm` | +| Check error log | `tail -f /home/ubuntu/webapps/masanconsumer/logs/error.log` | From 95d6048c6384ccff2fb32f1335a5db1c75aaf9f9 Mon Sep 17 00:00:00 2001 From: Khoi Pro Date: Sat, 9 May 2026 18:01:13 +0700 Subject: [PATCH 7/7] docs: add white-label runbook for provisioning a new site MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Generic step-by-step guide (bootstrap → create site → RDS DB → sync data → DNS cutover) with placeholder variables instead of site-specific values. Includes table of contents and quick reference table. Co-Authored-By: Claude Sonnet 4.6 --- docs/runbook-new-site.md | 334 +++++++++++++++++++++++++++++++++++++++ 1 file changed, 334 insertions(+) create mode 100644 docs/runbook-new-site.md diff --git a/docs/runbook-new-site.md b/docs/runbook-new-site.md new file mode 100644 index 0000000..bc8c767 --- /dev/null +++ b/docs/runbook-new-site.md @@ -0,0 +1,334 @@ +# Runbook — Provision a new WordPress site on EC2 + RDS + +End-to-end guide: bootstrap a fresh Ubuntu 24.04 EC2, create a site, +set up the RDS database, migrate data from a staging server, and cut over DNS. + +--- + +## Table of contents + +1. [Prerequisites](#1-prerequisites) +2. [Bootstrap the EC2](#2-bootstrap-the-ec2) +3. [Set up the deploy key](#3-set-up-the-deploy-key) +4. [Create the site scaffold](#4-create-the-site-scaffold) +5. [Create the RDS database and user](#5-create-the-rds-database-and-user) +6. [Sync uploads from staging](#6-sync-uploads-from-staging) +7. [Sync the database from staging](#7-sync-the-database-from-staging) +8. [DNS cutover](#8-dns-cutover) +9. [Post-cutover tasks](#9-post-cutover-tasks) +10. [Quick reference](#10-quick-reference) + +--- + +## 1. Prerequisites + +| Resource | Notes | +|---|---| +| EC2 — Ubuntu 24.04 LTS (x86_64) | t3.medium or larger; 20 GB+ root volume | +| RDS — MySQL 8.0 | `require_secure_transport=ON`; EC2 security group allows port 3306 | +| ALB | HTTPS listener (ACM cert); HTTP → HTTPS redirect; target group → EC2 port 80 | +| CloudFlare | DNS proxied → ALB DNS name | +| S3 bucket | For backups (optional at install time) | +| IAM role on EC2 | `s3:PutObject`, `s3:GetObject`, `s3:ListBucket` on the backup bucket | +| GitHub deploy key | Read-only key on the site's private repo | + +**Variables used throughout this document:** + +| Placeholder | Description | Example | +|---|---|---| +| `` | Site identifier — used for path, DB name, DB user, FPM pool | `acmeshop` | +| `` | Production domain | `acmeshop.com` | +| `` | Staging domain to replace during migration | `staging.acmeshop.com` | +| `` | SSH address of staging server | `ubuntu@10.0.0.1` | +| `` | SSH URL of the WordPress repo | `git@github.com-:org/.git` | +| `` | Branch to deploy | `main` or `master` | +| `` | WordPress table prefix in the DB | `wp_` | +| `` | RDS endpoint | `db.xxx.ap-southeast-1.rds.amazonaws.com` | +| `` | RDS admin password | _(from AWS Secrets Manager)_ | +| `` | Generated per-site DB password | _(generated in step 5)_ | +| `` | PHP version for this site | `8.3` | + +--- + +## 2. Bootstrap the EC2 + +Run **once per EC2 instance**. Installs Apache, PHP-FPM, WP-CLI, Redis, +RDS CA bundle, UFW, Fail2ban, and 2 GB swap. + +```bash +curl -fsSL https://raw.githubusercontent.com/codetot-web/ec2-toolkit/main/bash-scripts/bootstrap-ec2-wordpress.sh | sudo bash +``` + +Reboot if the summary warns about a pending kernel upgrade: + +```bash +sudo reboot +``` + +**Verify:** + +```bash +php -v && wp --info --allow-root && redis-cli ping && sudo ufw status +``` + +--- + +## 3. Set up the deploy key + +Generate a deploy key on the EC2 for this site's private repo: + +```bash +sudo -u ubuntu ssh-keygen -t ed25519 -C "deploy@ec2-" -f /home/ubuntu/.ssh/id_ed25519_ -N "" +cat /home/ubuntu/.ssh/id_ed25519_.pub +``` + +Add the printed public key to the GitHub repo as a **read-only** deploy key: +`github.com///settings/keys` + +Add the SSH config alias on EC2: + +```bash +sudo -u ubuntu tee -a /home/ubuntu/.ssh/config < + HostName github.com + User git + IdentityFile ~/.ssh/id_ed25519_ + IdentitiesOnly yes +EOF +``` + +Verify: + +```bash +sudo -u ubuntu ssh -T git@github.com- +# Hi /! You've successfully authenticated... +``` + +--- + +## 4. Create the site scaffold + +Downloads `create-site.sh` and scaffolds dirs, vhost, PHP-FPM pool, +wp-config.php, and `.htaccess`. + +```bash +curl -fsSL https://raw.githubusercontent.com/codetot-web/ec2-toolkit/main/bash-scripts/create-site.sh -o /tmp/create-site.sh +``` + +```bash +sudo RDS_MASTER_PASS='' bash /tmp/create-site.sh \ + --site= \ + --domain= \ + --git-repo= \ + --git-branch= \ + --table-prefix= \ + --rds-host= \ + --php-version= +``` + +**Created by the script:** + +| Path | Purpose | +|---|---| +| `/home/ubuntu/webapps//public/` | WordPress document root | +| `/home/ubuntu/webapps//{logs,backups,tmp}/` | Site directories | +| `/etc/apache2/sites-available/.conf` | Apache vhost | +| `/etc/php//fpm/pool.d/.conf` | PHP-FPM pool | +| `/home/ubuntu/webapps//public/wp-config.php` | DB + proxy + SSL config | +| `/home/ubuntu/webapps//public/.htaccess` | WordPress mod_rewrite rules | +| `/home/ubuntu/webapps//.credentials` | Generated DB password (mode 600) | + +--- + +## 5. Create the RDS database and user + +Connect to RDS as the admin user: + +```bash +mysql -h -u admin -p +``` + +### 5.1 Generate a secure password + +Run this before connecting to MySQL and save the output: + +```bash +openssl rand -base64 32 | tr -d '/+=' | head -c 32 +``` + +### 5.2 Create database, user, and grants + +```sql +CREATE DATABASE IF NOT EXISTS `` + CHARACTER SET utf8mb4 COLLATE utf8mb4_unicode_ci; + +CREATE USER IF NOT EXISTS ''@'%' IDENTIFIED BY '' REQUIRE SSL; +ALTER USER ''@'%' IDENTIFIED BY '' REQUIRE SSL; + +GRANT ALL PRIVILEGES ON ``.* TO ''@'%'; +FLUSH PRIVILEGES; +``` + +> **Note:** `REQUIRE SSL` is mandatory — RDS has `require_secure_transport=ON`. +> DB name and DB user are intentionally identical to the site name. + +### 5.3 Verify collation + +```sql +SHOW CREATE DATABASE ``; +-- Should show: DEFAULT CHARACTER SET utf8mb4 DEFAULT COLLATE utf8mb4_unicode_ci +``` + +### 5.4 Verify app user connection + +```bash +mysql -h -u -p'' -e "SELECT 1;" +``` + +### 5.5 Save credentials to the site + +```bash +sudo tee /home/ubuntu/webapps//.credentials < +DB_NAME= +DB_USER= +DB_PASS= +EOF +sudo chmod 600 /home/ubuntu/webapps//.credentials +sudo chown ubuntu:ubuntu /home/ubuntu/webapps//.credentials +``` + +--- + +## 6. Sync uploads from staging + +### 6.1 One-time: set up SSH trust from EC2 → staging + +```bash +sudo -u ubuntu bash -c '[ -f ~/.ssh/id_ed25519 ] || ssh-keygen -t ed25519 -f ~/.ssh/id_ed25519 -N ""' +``` + +```bash +sudo apt-get install -y sshpass +``` + +```bash +sudo -u ubuntu sshpass -p '' ssh-copy-id -o StrictHostKeyChecking=no +``` + +### 6.2 Rsync uploads (incremental — safe to re-run) + +```bash +sudo -u ubuntu rsync -az --info=progress2 :/home/ubuntu/webapps//public/wp-content/uploads/ /home/ubuntu/webapps//wp-content/uploads/ +``` + +> Adjust the source path to match the staging server's directory structure. + +--- + +## 7. Sync the database from staging + +### 7.1 Export DB on staging + +```bash +ssh "sudo -u ubuntu wp db export /tmp/-$(date +%Y%m%d).sql --path=/home/ubuntu/webapps//public --allow-root" +``` + +### 7.2 Copy dump to EC2 + +```bash +scp :/tmp/-$(date +%Y%m%d).sql /tmp/-$(date +%Y%m%d).sql +``` + +### 7.3 Import into RDS + +```bash +sudo -u ubuntu wp db import /tmp/-$(date +%Y%m%d).sql --path=/home/ubuntu/webapps/ +``` + +### 7.4 Search-replace staging domain → production domain + +```bash +sudo -u ubuntu wp search-replace 'https://' 'https://' --all-tables --skip-columns=guid --path=/home/ubuntu/webapps/ +``` + +### 7.5 Clean up + +```bash +rm /tmp/-$(date +%Y%m%d).sql +``` + +--- + +## 8. DNS cutover + +### 8.1 Verify the site before cutover + +Add a temporary entry to your local `/etc/hosts`: + +``` + +``` + +Visit `https://` in a browser. Confirm the site loads correctly, then remove the entry. + +### 8.2 Update CloudFlare DNS + +1. Point `` CNAME → ALB DNS name +2. Enable the orange cloud (proxy) — CloudFlare terminates TLS +3. Set SSL/TLS encryption mode to **Full** +4. Set TTL to 60s before cutover; restore to Auto afterwards + +### 8.3 Confirm propagation + +```bash +curl -sI https:/// | head -3 +# HTTP/2 200 +``` + +--- + +## 9. Post-cutover tasks + +### WP-Cron + +Add to ubuntu's crontab (`sudo -u ubuntu crontab -e`): + +``` +*/5 * * * * cd /home/ubuntu/webapps/ && /usr/local/bin/wp cron event run --due-now >/dev/null 2>&1 +``` + +### S3 backups + +```bash +# Test manually first +ct-backup --site= --bucket= + +# Then add to ubuntu's crontab +0 3 * * * ct-backup --site= --bucket= +``` + +### Verify RDS SSL is active + +```bash +sudo -u ubuntu wp db cli --path=/home/ubuntu/webapps/ -e "SHOW STATUS LIKE 'Ssl_cipher';" +# Value column must be non-empty +``` + +--- + +## 10. Quick reference + +| Task | Command | +|---|---| +| Re-bootstrap EC2 | `sudo bash /tmp/bootstrap-ec2-wordpress.sh` | +| Re-scaffold site config | `sudo RDS_MASTER_PASS='xxx' bash /tmp/create-site.sh --site= ... --force` | +| Fix permissions | `sudo ct-fix-perm --site=` | +| Reload services | `sudo systemctl reload apache2 php-fpm` | +| Manual backup | `ct-backup --site= --bucket=` | +| Re-sync uploads | _(step 6.2 — rsync is incremental)_ | +| Re-sync DB | _(steps 7.1–7.4)_ | +| Verify DB SSL | `sudo -u ubuntu wp db cli -e "SHOW STATUS LIKE 'Ssl_cipher';"` | +| Tail error log | `tail -f /home/ubuntu/webapps//logs/error.log` | +| Check PHP-FPM socket | `ls /run/php/.sock` |