Contact us: info@tenendo.com
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:
| Setting | Value |
|---|---|
| Destination URL | Production domain |
| Additional URLs | None (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.xml, robots.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.
- Fetches the target files via
wp_remote_get()from the local WordPress instance - Runs a string replacement: relative domain → production domain
- Uploads to S3 using direct AWS API calls (Signature Version 4, via
wp_remote_request()— no SDK dependency) - 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:
| Metric | Value |
|---|---|
| Largest Contentful Paint (LCP) | 1.3s |
| Interaction to Next Paint (INP) | 190ms |
| Cumulative Layout Shift (CLS) | 0 |
Remaining bottlenecks, in order of impact:
- Image sizes (unoptimized originals uploaded to media library)
- CRM iframe load time (third-party dependency)
- 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
| Layer | Tool |
|---|---|
| Content editing | WordPress on EC2 t2.micro (Bitnami), stopped when idle |
| Local development | Docker (Bitnami WordPress) |
| Static export + deploy | Staatic plugin |
| File hosting | AWS S3 |
| CDN + HTTPS | AWS CloudFront |
| DNS | AWS Route 53 |
| Certificates | AWS Certificate Manager |
| Sitemaps / robots.txt / feed | Custom WP plugin → S3 + CloudFront invalidation |
| Forms | CRM iframe embeds |
| Redirects | CloudFront 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.