If you're running a multi-tenant WordPress platform on AWS, you've probably built something like this: Terraform spins up an EC2 instance, a user-data script or Ansible playbook installs packages, downloads WordPress, hardens the OS, configures Nginx, starts services, and eventually — 10 to 15 minutes later — you have a working site.
That works for 5 tenants. It falls apart at 50.
The Problem: Provisioning From Scratch Doesn't Scale
The failure modes are predictable:
- Speed. Every
apt-get installhits the network. WordPress downloads from wordpress.org. Plugins download from the plugin repo. A customer is staring at a loading screen while your provisioner fights a CDN. - Drift. Instance #1 got PHP 8.3.12. Instance #47 got PHP 8.3.14 because Ubuntu pushed an update overnight. Now you're debugging inconsistencies that only exist on some tenants.
- Security. CIS hardening scripts run post-deploy — if they run at all. One missed step and you've got an instance with password auth enabled and no firewall.
- Reliability. An apt mirror goes down. GitHub rate-limits you. A WordPress.org CDN node is slow. Your provisioner fails at step 37 of 52 and leaves a half-configured instance behind.
The golden AMI pattern eliminates all of this. Build once. Validate once. Deploy the same proven image every time.
Architecture: The Packer Build Pipeline
We use HashiCorp Packer to build an Ubuntu 24.04 LTS AMI on ARM64 Graviton. Seven provisioner scripts run in sequence, each handling one concern. The output is an encrypted EBS-backed AMI with the full WordPress stack on disk.
┌──────────────────────────────────────────────────────────────┐
│ Packer Build Pipeline │
│ │
│ source "amazon-ebs" "ubuntu" { │
│ ami_name = "45sq-golden-ami-${var.version}" │
│ instance_type = "t4g.small" # Graviton ARM64 │
│ encrypt_boot = true │
│ source_ami = "ubuntu-noble-24.04-arm64-server-*" │
│ } │
│ │
│ Phase 1 ─── system-packages.sh │
│ Phase 2 ─── mariadb-setup.sh │
│ Phase 3 ─── nginx-base.sh │
│ Phase 4 ─── wordpress-cache.sh │
│ Phase 5 ─── cis-harden.sh │
│ Phase 6 ─── aide-init.sh │
│ Phase 7 ─── cleanup.sh │
│ │
│ Output: 20 GB encrypted AMI ──────────────────► AWS AMI │
└──────────────────────────────────────────────────────────────┘
What Each Phase Does
| Phase | Script | What It Does |
|---|---|---|
| 1 | system-packages.sh |
Installs Nginx, PHP 8.3-FPM, MariaDB 11.4 LTS, AWS CLI, CloudWatch Agent, SSM Agent, WP-CLI |
| 2 | mariadb-setup.sh |
Secures MariaDB — removes anonymous users, tunes InnoDB buffer pool for 2 GB RAM (t4g.small) |
| 3 | nginx-base.sh |
Security headers, FastCGI page caching, PHP-FPM socket config, XML-RPC disabled |
| 4 | wordpress-cache.sh |
Pre-downloads WordPress core, Ollie FSE theme, plugins (CF7, Yoast, S3 Offload) to /opt/45sq/wordpress-cache/ |
| 5 | cis-harden.sh |
CIS Ubuntu 24.04 Level 1 benchmark — kernel hardening, SSH lockdown, UFW firewall, auditd, Fail2ban |
| 6 | aide-init.sh |
Initializes AIDE file integrity database, schedules daily integrity checks |
| 7 | cleanup.sh |
Strips build artifacts, SSH keys, logs, shell history — minimizes snapshot size |
Output: a 20 GB encrypted AMI with everything on disk. No network calls at deploy time. No apt-get. No curl. Everything is already there.
CIS Level 1 Hardening: Baked In, Not Bolted On
Most teams treat hardening as a post-deploy step. Run a script after launch, hope it completes, move on. We run the CIS Ubuntu 24.04 Level 1 benchmark as part of the Packer build. Every instance is compliant from the moment it boots.
The cis-harden.sh script covers:
Kernel & Network
- IP forwarding disabled
- ICMP redirects blocked
- Source routing rejected
- Unused filesystems masked (USB, HFS, UDF)
SSH & Access
- Root login disabled
- Key-only authentication
- Session timeouts enforced
- SSM Session Manager for shell access (no SSH key management)
Firewall & Monitoring
- UFW default-deny incoming (allow 80, 443, 22 only)
- Fail2ban on SSH and
wp-login.php - Auditd for privilege escalation events
- AIDE file integrity monitoring with daily checks
On top of this: PHP has dangerous functions disabled and session cookies secured. Nginx enforces X-Frame-Options and X-Content-Type-Options headers, blocks access to wp-config.php and .env at the web server level. Services with no business on a WordPress server — snapd, CUPS, Avahi, Bluetooth — are masked. Unattended-upgrades handles security patches automatically.
This isn't a checklist we run after launch. It's infrastructure that ships compliant by default.
The Bootstrap: AMI to Live Site in 30 Seconds
The AMI handles the what — a hardened, pre-loaded image. The bootstrap handles the who — configuring that image for a specific tenant.
## Bootstrap Flow (tenant-config.sh)
Phase 1: Fetch tenant config JSON from S3
→ domain, DB creds, admin users, AI payload
Phase 2: Create MariaDB database + user
→ isolated DB per tenant, utf8mb4
Phase 3: Configure Nginx vhost
→ rate limiting on wp-login.php, tenant-specific logs
Phase 4: Copy WordPress from cache → wp core install
→ ~10 seconds, no network download
Phase 5: Inject AI-generated content + child theme
→ Home, Services, Contact pages with Gutenberg blocks
→ Contact Form 7, brand colors, template overrides
Phase 6: Activate monitoring + backups
→ CloudWatch Agent, daily S3 backup cron, AIDE
Phase 7: Write completion JSON, clean up temp files
→ Instance is live.
The entire bootstrap is a single shell script triggered via SSM Run Command. No Ansible. No Chef. No orchestration layer. The provisioner sends a tenant ID, the script reads a JSON config from S3, and applies it.
Total wall clock time: 30–45 seconds from EC2 launch to a customer-facing WordPress site with AI-generated content, a custom child theme, and full monitoring.
Why Not Containers?
Fair question. Containers are the default for multi-tenant SaaS. We chose AMIs for WordPress specifically because of what WordPress is.
WordPress expects cron jobs, file permissions, mail utilities, and a database on localhost. Containerizing that means either running multiple processes in a single container (an anti-pattern) or orchestrating 3–4 containers per tenant (expensive and complex at scale).
| Concern | Containers (ECS/EKS) | Golden AMI (EC2) |
|---|---|---|
| Tenant isolation | Shared kernel, shared runtime | Own kernel, own firewall, own filesystem |
| CIS compliance | Container CIS + host CIS + orchestrator CIS | VM-level CIS only — well-documented, maps to audits |
| WordPress compat | Multi-process anti-pattern or 3–4 containers/tenant | Full Linux environment — cron, mail, DB on localhost |
| Cost per tenant | ECS/EKS overhead + NAT Gateway + ALB rules | t4g.small Graviton — ~$12/mo |
| Noisy neighbor | Shared compute, resource contention | Dedicated instance — no contention |
Golden AMIs give you container-like reproducibility with VM-level isolation. For WordPress multi-tenancy, that's the right tradeoff.
CI/CD: The AMI Stays Fresh
A stale golden AMI is a liability. Unpatched kernel, outdated PHP, old WordPress — you've just traded deploy-time drift for image-level drift. We automate the entire lifecycle:
- Weekly rebuilds. GitHub Actions triggers a Packer build every Sunday at 00:00 UTC. New AMI picks up the latest OS patches, PHP updates, and WordPress releases automatically.
- Change-triggered builds. Push to
packer/**orbootstrap/**triggers a build. ShellCheck lints every script, PHP syntax is verified, Nginx configs are tested before the AMI is registered. - Semantic versioning. Production gets major versions (X.0.0), staging gets minor (1.X.0). Current version lives in SSM Parameter Store — the provisioner always knows which AMI to launch.
- Zero long-lived credentials. CI/CD authenticates to AWS via OIDC federation. No access keys in GitHub Secrets. Build instances use IMDSv2 exclusively — SSRF-based credential theft is off the table.
Old AMIs are cleaned up automatically. We keep the last two for rollback.
# GitHub Actions workflow (simplified)
on:
schedule:
- cron: '0 0 * * 0' # Weekly Sunday 00:00 UTC
push:
paths:
- 'packer/**'
- 'bootstrap/**'
jobs:
build-ami:
runs-on: ubuntu-latest
permissions:
id-token: write # OIDC federation — no access keys
steps:
- uses: aws-actions/configure-aws-credentials@v4
with:
role-to-assume: ${{ secrets.AWS_ROLE_ARN }}
- run: packer init packer/
- run: packer build packer/ubuntu-24-04.pkr.hcl
Multi-Tenant Operations
Launching a site is half the problem. Running hundreds of them without a dedicated ops team requires operational tooling baked into the image from day one.
Per-Tenant Monitoring
CloudWatch Agent ships Nginx access/error logs, MariaDB slow query logs, and custom metrics to tenant-specific log groups. Each tenant is independently observable.
Automated Backups
Daily cron at 02:00 UTC dumps the database and syncs the WordPress directory to a tenant-specific S3 bucket. The bootstrap configures this — no manual setup per tenant.
File Integrity Detection
AIDE runs daily checks against the WordPress directory baseline. Modified files outside of a deployment trigger CloudWatch alerts. This catches compromised plugins, injected backdoors, and unauthorized edits.
Dunning Lockout
A must-use plugin handles delinquent accounts. Day 3: admin notice with billing link. Day 7: wp-login.php returns 402 Payment Required. Password reset links pass through for account recovery.
The Numbers
| Metric | Before (Terraform + Ansible) | After (Golden AMI) |
|---|---|---|
| Provisioning time | 10–15 minutes | 30–45 seconds |
| Configuration drift | Varies per deploy | Zero — immutable image |
| CIS compliance | Post-deploy hardening (if remembered) | Baked in — compliant at boot |
| Network deps at deploy | apt repos, wordpress.org, GitHub, plugin CDNs | S3 only (tenant config JSON) |
| Failure recovery | Debug half-configured instance | Terminate and relaunch — 45 seconds |
| Cost per tenant | Same compute + longer provisioning | t4g.small ~$12/mo |
Every instance is identical. Every instance is compliant. Every instance is monitored from boot. When you need to update the base — new PHP, new security patch, new WordPress — rebuild the AMI and let the next launch pick it up.
You stop fixing servers and start replacing them.
The Stack
| Layer | Technology | Why |
|---|---|---|
| OS | Ubuntu 24.04 LTS (ARM64) | Long-term support, Graviton-native |
| Compute | AWS Graviton t4g.small | 40% better price-performance than x86 |
| Web Server | Nginx + FastCGI Cache | High concurrency, low memory footprint |
| Runtime | PHP 8.3-FPM | JIT compilation, hardened php.ini |
| Database | MariaDB 11.4 LTS | MySQL-compatible, InnoDB tuned for 2 GB |
| Application | WordPress 6.9+ / Ollie FSE Theme | Full Site Editing, AI-ready child themes |
| Image Build | HashiCorp Packer | Declarative, CI/CD-native, multi-provider |
| Security | CIS L1 / AIDE / Fail2ban / Auditd | Defense in depth, compliance-ready |
| Observability | CloudWatch Agent | Native AWS, per-tenant log isolation |
| CDN / TLS | CloudFront | Edge termination, global distribution |
Subscribe for More
We write about AWS infrastructure patterns, AI automation, and operational deep-dives every week. No fluff.
Get Infrastructure Posts in Your Inbox
AWS architecture, Terraform modules, AI automation, and operational patterns. One email per week.
No spam. Unsubscribe anytime.
Have questions about golden AMIs or multi-tenant WordPress architecture? Email us at info@aiopscrew.com.