A production write-up: 300+ page WordPress site → S3 + CloudFront, with full editorial workflow preserved.

The site: a WordPress installation with 300+ pages and posts, multiple taxonomies, Mega Menu, SEO plugins, layout/responsive plugins, and CRM-integrated forms (contact, booking, project scoping, lead magnets). Single admin user. No public registration, no comments.

Hard requirements:

  • Security: eliminate the WordPress attack surface from the public-facing site
  • Performance: sub-2s LCP, minimal server processing overhead
  • Editorial continuity: content team keeps WordPress; no CMS migration
  • Cost: run on AWS free-tier-adjacent infrastructure

Result: static HTML served from S3 via CloudFront. WordPress runs privately on EC2, starting only when content is published.

Architecture

[Local Docker / Bitnami]          ← theme dev, plugin testing, WP updates
        │
        ▼
[EC2 t2.micro / Bitnami WP]       ← content editing, SEO, publish trigger
        │
        ├── Staatic plugin ──────► [S3 bucket]
        │                               │
        └── Custom WP plugin ──────────►│ (sitemap, robots.txt, feed)
                                        │
                                   [CloudFront]
                                        │
                              Route 53 + Certificate Manager
                                        │
                                  Public website

EC2 instance design: t2.micro is sufficient because the instance handles traffic only during editorial sessions — not visitor load. It can be stopped between publishing cycles. Keep the latest AMI snapshot as a backup before stopping.

Local Docker environment: Bitnami WordPress image. All template development, plugin evaluation, and core/plugin updates happen here first. Nothing goes to EC2 without local validation.

Static Hosting Layer

Standard S3 static website hosting + CloudFront distribution. The setup is well-documented. Key configuration decisions:

CloudFront: set Default Root Object to index.html

Staatic exports each WordPress permalink as a directory with index.html as the index file. Without this setting, direct URL requests return a 403 from S3.

IAM: create a dedicated low-privilege user for deployment

The deploy user needs only:

{
  "Version": "2012-10-17",
  "Statement": [
    {
      "Effect": "Allow",
      "Action": ["s3:PutObject", "s3:DeleteObject", "s3:ListBucket"],
      "Resource": [
        "arn:aws:s3:::YOUR-BUCKET",
        "arn:aws:s3:::YOUR-BUCKET/*"
      ]
    },
    {
      "Effect": "Allow",
      "Action": ["cloudfront:CreateInvalidation"],
      "Resource": "arn:aws:cloudfront::ACCOUNT_ID:distribution/DIST_ID"
    }
  ]
}

No console access, no other permissions.

Export: Staatic Plugin

Staatic crawls WordPress and publishes static output directly to S3.

Build configuration:

SettingValue
Destination URLProduction domain
Additional URLsNone (see sitemap section below)
Additional Paths/wp-content/uploads/, CSS, JS, images, fonts
Excluded URLs/wp-json/*

Deployment configuration: S3 bucket, region, CloudFront distribution ID, IAM credentials from the dedicated deploy user.

What Staatic does on publish: crawls the full site starting from the homepage following all internal links, exports each page as a static file, syncs to S3, and triggers CloudFront invalidation. As a side effect, it surfaces every 404 on the site during the crawl — useful for ongoing link hygiene.

Permalink structure note: each WordPress permalink becomes a directory (/services/pentest/ → /services/pentest/index.html). This is handled by the Default Root Object setting above.

The Sitemap Problem

This is the main edge case Staatic doesn’t handle out of the box.

The issue: Staatic rewrites internal URLs to relative paths during export. This is correct behavior for HTML pages but breaks sitemap.xmlrobots.txt, and RSS feeds, which must contain absolute URLs.

Adding these files to Staatic’s “Additional URLs” doesn’t help — the relative-path transformation still applies to their content.

Solution: a custom WordPress plugin, built with Claude.ai. 

  1. Fetches the target files via wp_remote_get() from the local WordPress instance
  2. Runs a string replacement: relative domain → production domain
  3. Uploads to S3 using direct AWS API calls (Signature Version 4, via wp_remote_request() — no SDK dependency)
  4. Creates a CloudFront invalidation for the affected paths

Triggers: manually from the WP admin Tools menu, and optionally auto-triggered on post publish.

Alternative considered: a CloudFront Function that rewrites paths on every GET request for the affected files. This would work but adds latency on each request and is harder to debug. The plugin approach is more transparent and runs once per publish cycle rather than on every request.

Dynamic Content: Forms

Forms are the only genuinely dynamic element. The approach: use forms provided by the CRM directly, embedded as iframes.

This works identically on static pages. CRM-native forms also integrate naturally into CRM workflows without any custom webhook or serverless layer.

No Lambda, no API Gateway, no custom backend. The iframe load time is now the primary remaining performance overhead (visible in INP metrics).

Redirects for Removed Pages

CloudFront handles redirects via custom error responses. For pages that have been removed or relocated, configure CloudFront to return a 301 redirect rather than serving the 404 page. This preserves SEO link equity for renamed URLs.

Staging

New staging environment = new S3 bucket + new CloudFront distribution. Can be provisioned in under 10 minutes. Point Staatic at the staging distribution ID for a preview deploy without touching production.

Production Performance

Current metrics on the live site:

MetricValue
Largest Contentful Paint (LCP)1.3s
Interaction to Next Paint (INP)190ms
Cumulative Layout Shift (CLS)0

Remaining bottlenecks, in order of impact:

  1. Image sizes (unoptimized originals uploaded to media library)
  2. CRM iframe load time (third-party dependency)
  3. JS loaded by plugins active during the WordPress build phase — these are irrelevant to the static output but affect Staatic’s crawl time

WordPress server processing is entirely removed from the visitor request path.

Operational Notes

All plugins run on free tiers. No premium licenses required for this architecture.

Plugin updates: tested locally in Docker first, then applied to EC2, then published.

Backup strategy: export EC2 AMI snapshot before stopping the instance. Staatic’s S3 sync is idempotent — re-publishing rebuilds the full site from WordPress state.

The site reads as WordPress externally — same URL structure, same routing patterns. Scanners looking for /wp-admin or /wp-login.php will get 404s from a static file host, with no useful information returned.

Summary

LayerTool
Content editingWordPress on EC2 t2.micro (Bitnami), stopped when idle
Local developmentDocker (Bitnami WordPress)
Static export + deployStaatic plugin
File hostingAWS S3
CDN + HTTPSAWS CloudFront
DNSAWS Route 53
CertificatesAWS Certificate Manager
Sitemaps / robots.txt / feedCustom WP plugin → S3 + CloudFront invalidation
FormsCRM iframe embeds
RedirectsCloudFront custom error responses

The architecture removes the WordPress attack surface from public exposure entirely, improves performance by eliminating server-side processing from the request path, and costs near-nothing to run at this scale.