Skip to content

Part 7 of 7 · Uptime status poster series ~7 min read

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

The uptime status poster drawn with AWS service namesThree boxes across the top outside the AWS account. Your journeys, exercised as real HTTP sequences. Status readers, meaning customers during an outage. And SNS with SES, carrying on-call alerts and drafted notes. Inside the account, three groups. Three regions, each running an EventBridge rule and a Lambda in its own stack. A decider and publisher in a fourth region. And S3 with CloudFront, using a separate bucket and a separate distribution. A note says four regions in total, and that the publishing path shares no region, bucket or hosted zone with the monitored application.AWS ACCOUNTYour journeysreal HTTP sequencesStatus readerscustomers, during an outageSNS + SESon-call, and drafts3 regionsEventBridge + Lambda,one stack eachDecider + publisherin a fourth regionS3 + CloudFrontseparate bucket,separate distributioningroundsoutFour regions. The publishing path shares no region, bucket or zone with the monitored app.
Fig 1. The same shape as Part 1 with the service names filled in. Nothing here is new; it is the same three groups, named.
  • 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 dev and a prod stack 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

FunctionTriggerDoesTimeout / memory
up-check (x3)EventBridge, 1 minute, per regionRuns every journey as an HTTP sequence; writes one result row30s / 512 MB, one per region
up-decideEventBridge, 1 minute, fourth regionReads the three regions, applies agreement and consecutive rules15s / 512 MB
up-publishSQS state-change queue + EventBridge 5 minRenders static HTML to the status bucket; the heartbeat write15s / 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

RoleAllowedOn
up-check-roledynamodb:PutItem, secretsmanager:GetSecretValueThe results table; the check-account credential only
up-decide-roledynamodb:Query/UpdateItem, sns:PublishResults and state; the on-call topic
up-publish-roles3:PutObject, dynamodb:GetItemThe 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