Engineering reference: the uptime status poster architecture
The first six posts are for the person deciding whether to build this. This one is for the person building it. Same system, no analogies: the services by name, the functions, the multi-region layout, and the independence rules that constrain all of it.
Key takeaways
- Single region, single account. Every resource is regional; nothing is global except the IAM roles.
- 3 Lambda functions, each with its own execution role. No shared role, no wildcards on resources.
- 2 DynamoDB tables, each keyed so the concurrency story is a condition expression rather than a lock.
- One Bedrock model, called once, with a JSON schema it must fill or leave null.
- Nothing always-on: no instance, no container, no provisioned capacity.
The system, by service name
- Compute
- App integration
- Networking
- Front-end & mobile
- People
- Outside AWS
Region and account
- Region:
us-east-1. Chosen because SES inbound receipt rules exist in only a subset of regions and this one has the widest Bedrock model availability. If your data has to stay elsewhere, check both constraints before moving: inbound SES is the binding one. - Account: one. This is a small system, and a separate account per environment costs more in wiring than it saves. A
devand aprodstack in the same account, with distinct resource prefixes, is the right size here. - Everything is regional. The only global resources are the IAM roles and policies. There is no CloudFront, no global table and no cross-region replication, because nothing here has a latency or durability requirement that would justify them.
Lambda inventory
| Function | Trigger | Does | Timeout / memory |
|---|---|---|---|
up-check (x3) | EventBridge, 1 minute, per region | Runs every journey as an HTTP sequence; writes one result row | 30s / 512 MB, one per region |
up-decide | EventBridge, 1 minute, fourth region | Reads the three regions, applies agreement and consecutive rules | 15s / 512 MB |
up-publish | SQS state-change queue + EventBridge 5 min | Renders static HTML to the status bucket; the heartbeat write | 15s / 512 MB |
Splitting this into separate functions is not about modularity. It is that only one of them needs Bedrock permissions and only one is reachable from the public internet, and neither of those is true if it is one handler behind a router.
IAM, scoped
| Role | Allowed | On |
|---|---|---|
up-check-role | dynamodb:PutItem, secretsmanager:GetSecretValue | The results table; the check-account credential only |
up-decide-role | dynamodb:Query/UpdateItem, sns:Publish | Results and state; the on-call topic |
up-publish-role | s3:PutObject, dynamodb:GetItem | The status bucket only; the state table, read |
No role has a Resource: “*” on anything that writes, and every GetSecretValue grant names a single secret arn. That is why there is more than one secret rather than one JSON blob with everything in it.
DynamoDB schemas
Table: results
PK journey_region S checkout#eu-west-1
SK checked_at S 2026-07-31T14:06:00Z
ok BOOL false
failed_step S GET /checkout
status_code N 503
assertion_failed S expected ’Payment’
ms_total N 8021
ms_by_step L per-step timings
ttl N epoch, +90 days
The results table is regional-write, central-read. Each region writes only
its own rows, which means a region losing DynamoDB access degrades the
quorum to two rather than breaking the decider.
Table: state
PK journey S checkout
status S operational | degraded | down
since S 2026-07-31T14:08:00Z
consecutive_fail N 2
incident_id S inc_2026_07_31_a1
note_published S the text a person published
last_update_at S drives the 30-minute promise
monthly_uptime M {2026-07: 99.41}
`last_update_at` is what fires the 25-minute reminder. The promise made in
the first note is enforced by the system rather than by somebody’s memory.
Inbound and outbound
- Three regional stacks, identical, deployed separately. Each has its own EventBridge rule so a regional control-plane problem takes out one opinion rather than all three.
- The decider runs in a fourth region, so it does not share a fate with any single checker.
- The status bucket and distribution are in a different region again, with their own origin access control, and are written only by
up-publish. - The status subdomain has its own hosted zone, delegated from the main zone. It survives a broken record in the parent and not a deleted parent, and that limitation is worth stating rather than glossing.
The model call
- There is no model in this system. Agreement, consecutive counting and uptime arithmetic are all mechanical.
- The incident notes are templates written in advance by a person, because prose written under pressure by anything is where status pages say something wrong.
- Generating an incident note is the obvious use and the worst idea in the series: a plausible sentence about the cause of an outage, published to customers, before anybody knows the cause.
- The three drafts cover almost everything, and anything more specific is written by a person in the moment.
- The cost page assumes none, which is why there is no read band.
Things worth knowing before you build it
- Bake the status into the HTML. A page that fetches its status from an API is only as available as that API, which is frequently the thing that is down.
- Write on a heartbeat as well as on change, and show the timestamp. A stale page that admits it is honest; one that silently says all operational is the worst failure available.
- Exclude the check account from analytics. Twelve runs a minute is seventeen thousand sessions a day and it will quietly ruin your conversion rate.
- Stop checkout before payment. A check that completes an order every minute creates 1,440 orders a day.
- Alarm differently when everything fails at once. It is usually an expired check credential, and publishing a total outage to every customer because of one is an expensive mistake.
That is the whole system. Seven posts, one diagram at a time, and nothing in it that needs a server.
All posts