The WordPress installation behind this site (Physical AI Lab) runs on AWS and is described by a CDK app written in TypeScript: Fargate runs the official WordPress image, the whole of /var/www/html lives on EFS, the database is Aurora Serverless v2 that can pause down to 0 ACU, and in front sit an existing shared ALB and a CloudFront distribution. This article explains, component by component, why each choice was made and what it costs, so that readers who want to host WordPress in a similar way can reuse it directly or avoid the same pitfalls.
Overall architecture
Visitor ─TLS─> CloudFront ─TLS─> shared ALB HTTPS listener
└─ rule: Host = site domain AND the secret header added by CloudFront is present
└─> Fargate task (Graviton, 0.5 vCPU / 1 GB, official wordpress:php8.3-apache image)
├─ /var/www/html mounted from EFS (core, plugins, uploads, wp-config.php)
├─ Aurora MySQL Serverless v2, 0–2 ACU, pauses after 30 idle minutes
└─ task role calls Amazon Bedrock (no keys stored in WordPress)
This architecture was designed around the site’s chat plugin, AI Chat for Amazon Bedrock. A handful of requirements drove the main choices:
| Requirement | Approach |
|---|---|
| Chat answers stream back via SSE | The ALB passes the stream straight through. The project README records the judgment that API Gateway + Lambda would buffer the whole answer before returning it, so Lambda was not chosen |
| Install and upgrade plugins from wp-admin | The whole of /var/www/html is on EFS, so nothing is lost when tasks restart or are redeployed |
| Credentials for calling Bedrock | The ECS task role, granted only bedrock:InvokeModel* and permission to list models |
| Low cost when idle | Aurora can pause to 0 ACU; the ALB health check reads a static file and does not wake PHP or the database |
| No NAT gateway | Tasks run in public subnets with public IPs (inbound allowed only from the ALB security group); the database and EFS are in two subnets with no outbound route |
Files on EFS: keeping the “mutable parts” of WordPress
WordPress is not a read-only application: plugins, themes, uploads and wp-config.php are all written at runtime. The whole of /var/www/html is mounted from EFS, rather than just wp-content/uploads, because plugins need to be installable and upgradable from wp-admin.
This changes the role of the container image. The official image’s docker-entrypoint.sh copies WordPress into the current directory only if it contains neither index.php nor wp-includes/version.php, and it never overwrites existing files. After the first start there is already a copy of WordPress on EFS, so the image tag php8.3-apache effectively only determines the PHP and Apache versions. WordPress core upgrades are left to WordPress itself (WP_AUTO_UPDATE_CORE is set to 'minor', meaning minor releases install automatically).
Some details:
- The EFS access point sets the root directory to
/htmlwith owner uid/gid 33 (www-datain the image). Encryption in transit and IAM authorization are both on, and the task role gets onlyClientMountandClientWrite. - The first start has to copy WordPress to EFS, so the ECS service’s health check grace period is set to 3 minutes. The deployment circuit breaker is on, so a failed deployment rolls back automatically.
- The built-in theme and plugin file editors are disabled (
DISALLOW_FILE_EDIT).
The project has not measured how EFS affects page latency; high-traffic sites need to evaluate this themselves.
Database: Aurora Serverless v2 pausing to 0 ACU
Setting the minimum capacity to 0 ACU turns on auto-pause: after a period with no user connections the instance pauses, instance charges drop to zero, and storage is billed as usual. This project pauses after 30 idle minutes, with a maximum of 2 ACU. The cost is the cold start: the AWS documentation gives a typical resume time of about 15 seconds, and 30 seconds or more if the instance has been paused for over 24 hours. For this reason, the origin read timeout in the CloudFront variant is set to 60 seconds, which covers the database resume time.
For the database to actually stop, no periodic request may reach it. The ALB health check runs every 30 seconds; if it checked the home page, it would keep PHP and the database awake permanently. The project checks a static file instead:
healthCheck: { path: "/wp-includes/images/blank.gif", healthyHttpCodes: "200", interval: Duration.seconds(30) }
Backups: Aurora keeps 7 days of point-in-time recovery, deletion protection is on, and a final snapshot is taken when the stack is deleted. EFS is enrolled in AWS Backup, and the file system is retained when the stack is deleted.
Letting only CloudFront reach the origin
The ALB is reachable from the internet. If it forwarded on the Host header alone, anyone could bypass CloudFront and send requests to the ALB directly. The approach AWS recommends: CloudFront adds a custom header to origin requests, the ALB rule forwards only requests that carry it, and everything else falls through to the listener’s default action. Treat the header name and value like a password.
The shared ALB in this project also serves other services. The app does not modify its security group (the code imports it read-only) or its default action; it only adds one rule:
- Condition 1: Host equals the site domain;
- Condition 2: the secret header from the CloudFront origin configuration is present, with an exactly matching value;
- Action: forward to the WordPress target group.
The secret header is not committed to the repository. The distribution was created outside this app, so at deploy time an AwsCustomResource calls CloudFront’s GetDistributionConfig, reads the first custom header of the first origin, and puts it into the listener rule’s condition:
const path = "DistributionConfig.Origins.Items.0.CustomHeaders.Items.0";
const origin = new cr.AwsCustomResource(this, "OriginHeader", {
onUpdate: {
service: "CloudFront",
action: "getDistributionConfig",
parameters: { Id: distributionId },
region: "us-east-1",
physicalResourceId: cr.PhysicalResourceId.of(`${distributionId}-origin-header`),
outputPaths: [`${path}.HeaderName`, `${path}.HeaderValue`],
},
policy: cr.AwsCustomResourcePolicy.fromSdkCalls({
resources: [`arn:aws:cloudfront::${this.account}:distribution/${distributionId}`],
}),
});
const headerCondition = elbv2.ListenerCondition.httpHeader(
origin.getResponseField(`${path}.HeaderName`),
[origin.getResponseField(`${path}.HeaderValue`)],
);
Trade-offs: after the header value is changed on the distribution, the stack has to be redeployed before the rule picks it up. Also, the value in the rule is visible to anyone with permission to read the ALB configuration, which is true of every secret-header approach. After deployment the project verified the setup: a request sent directly to the ALB with the correct Host but no secret header got the listener’s default 404.
The code also keeps another variant, in which the app creates its own VPC, ALB and CloudFront, and the ALB security group allows only the CloudFront managed prefix list com.amazonaws.global.cloudfront.origin-facing. The prefix list blocks non-CloudFront traffic at the network layer, but it contains the origin-facing addresses of all of CloudFront, including other people’s distributions. That is why the AWS documentation lists it as an optional addition to the secret header.
HTTPS and visitor IP behind a proxy
WordPress needs to know whether the current request is HTTPS; otherwise it generates http links or ends up in a redirect loop. The two front-door setups rely on different signals:
- Shared ALB: CloudFront connects to the ALB over TLS, and the ALB terminates TLS and sends
X-Forwarded-Proto: https. The official image’swp-config-docker.phpalready sets$_SERVER['HTTPS'] = 'on'when this header containshttps, so nothing extra is needed. - Self-built ALB variant: CloudFront connects to the ALB over HTTP, so the ALB sends
X-Forwarded-Protoashttp. In this case you have to look atCloudFront-Forwarded-Proto, which CloudFront adds. Also, CloudFront removes anyX-Forwarded-Protosent by the visitor.
Extra configuration is injected through the image’s WORDPRESS_CONFIG_EXTRA environment variable, which the image evals inside wp-config.php. The project uses it to pin WP_HOME, WP_SITEURL (both https URLs) and FORCE_SSL_ADMIN. Handling the visitor IP has an easy-to-miss pitfall, covered separately in “Getting the visitor IP behind CloudFront + ALB: why you should not parse X-Forwarded-For in PHP”.
Pages are not cached: the origin request policy of CloudFront’s default behavior is AllViewerAndCloudFrontHeaders-2022-06, which passes the visitor’s Host, cookies and CloudFront headers to the origin, and pages come back as Miss every time. Static assets are served from CloudFront’s cache from the second request on; this holds for CSS under wp-includes, the theme and plugins alike. The query string is part of the cache key, because WordPress versions static assets with ?ver=. The self-built variant’s CDK code spells the rules out: the default behavior uses CachingDisabled, and wp-includes/* and wp-content/uploads/* get their own policy that caches by query string.
A pitfall: VPC interface endpoints and wp_safe_remote_*()
The site’s VPC has a bedrock-runtime interface endpoint with private DNS enabled, so Bedrock’s domain names resolve to private addresses inside the VPC. The stack adds a port 443 inbound rule to the endpoint’s security group, with the source limited to the WordPress task. But WordPress’s wp_safe_remote_*() rejects private addresses, so the plugin’s diagnostics page and non-streaming calls reported “could not be reached”; streaming calls use curl directly and were not affected. As a stopgap, the project first used the http_request_host_is_external filter in wp-config.php to allow only bedrock*.<region>.amazonaws.com. From plugin version 1.47.1 the plugin allows its own AWS requests, and the filter was removed. Readers running WordPress in a VPC with private DNS endpoints may want to check this first.
Operations
- Do the initial install immediately: until WordPress is installed, anyone who opens the site can complete the installation first. The project runs
scripts/install.shright after deployment to finish the install. The admin password is generated by the stack in Secrets Manager; the script reads it and passes it to curl through process substitution, so it is never printed and never appears in command-line arguments. - Logs: container logs go to CloudWatch and are kept for one month; use
aws ecs execute-commandto get into the container when needed. - Scheduled tasks: WordPress’s built-in WP-Cron, triggered on page visits. A task that is due waits for the next visit before it runs; add an external scheduled trigger if that is not good enough.
- Settle the domain first: WordPress stores absolute URLs in posts, so changing the domain after content has been written means running a search-and-replace on the database.
Limitations
- There is only one Fargate task (
desiredCount: 1), with no multi-replica high availability; Aurora also has only one writer. - The shared ALB’s idle timeout is 60 seconds. That is fine while the model is continuously emitting tokens, but if the model thinks silently for more than 60 seconds the connection is cut. Raising this value affects every service behind the ALB.
- The first request after the database pauses waits about 15 seconds longer.
- Costs are rough estimates from the project README (Tokyo Region, excluding the free tier, low traffic): the shared-ALB setup is about USD 25–35 per month, of which Fargate is about USD 18 and the task’s public IPv4 address about USD 4; database time while awake is extra. The variant with its own ALB and CloudFront is about USD 55–70. Bedrock is billed separately per token.
Links
- CloudFront: Restrict access to Application Load Balancers
- Aurora Serverless v2: auto-pause and resume
- CloudFront managed origin request policies
- How CloudFront processes requests to custom origins
- AWS CDK: AwsCustomResource
- docker-library/wordpress (source of the official image)
- WordPress: wp-config.php constants
- AI Chat for Amazon Bedrock (wordpress.org)



