← Back to Blog

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 install hits 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/** or bootstrap/** 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.