Engineering reference: the domain renewal watcher 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 two tables, the RDAP path, and why there is no model in 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
- Database
- App integration
- Networking
- Management
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 |
|---|---|---|---|
dr-check | EventBridge weekly | RDAP per domain; diffs expiry, status and nameservers | 120s / 512 MB |
dr-discover | EventBridge monthly | Certificate logs, hosted zones, and registrar charges | 120s / 512 MB |
dr-escalate | EventBridge daily | The ladder, the post-expiry clock, and the quarterly digest | 30s / 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 |
|---|---|---|
dr-check-role | dynamodb:PutItem, secretsmanager:GetSecretValue | Domains and snapshots; the Sheets credential only |
dr-discover-role | route53:ListHostedZones, dynamodb:PutItem | Zones, read; the domains table |
dr-escalate-role | dynamodb:Query, ses:SendEmail | Domains, read; one verified identity |
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: domains
PK domain S example.com
state S watched | candidate | not_ours
expiry S 2027-03-14
source S rdap | invoice — how confident that date is
registrar S name and IANA id
registrar_account S the login identifier, not a credential
pays_with S card ending 4417
internal_owner S a person on the staff list, checked
depends L [mx, portal, redirect]
nameservers L as last seen
status L EPP status codes as last seen
last_checked S 2026-07-26T06:00:00Z
fail_count N consecutive lookup failures
`last_checked` is what a separate heartbeat scans. A domain not checked in
three weeks alarms whatever the rest of the system believes.
Table: snapshots
PK domain S example.com
SK checked_at S 2026-07-26
rdap S the response, as received
ttl N epoch, +2 years
Keeping the raw response means a nameserver change can be evidenced later
rather than asserted, and a parsing change can be tested against history.
Inbound and outbound
- Outbound HTTPS only. There is no inbound path at all except the signed acknowledgement links in escalation messages.
- The IANA bootstrap is fetched weekly and cached. Fetching it per lookup is both wasteful and impolite to a free public service.
- RDAP requests identify themselves with a user agent naming the business and a contact address, which is the norm for automated registry queries.
- No credential in this account can renew, transfer or modify a domain. The escalation tells a person to go and do it in the registrar’s own dashboard.
The model call
- There is no model in this system. RDAP returns structured JSON and status codes are a fixed vocabulary.
- The one place a model would fit is parsing free-text WHOIS for the registries without RDAP, and the honest choice is to not track those from WHOIS at all.
- Those domains carry an invoice date marked as such, with a longer lead time, rather than a parsed date presented as authoritative.
- Discovery is search and set difference, not classification. A candidate is any domain found that is not in the register.
- This is worth noting because it is a system where adding a model would make everything less reliable and nothing faster.
Things worth knowing before you build it
- Query the registry, not the registrar. They disagree exactly when it matters: a failed renewal payment, a transfer in flight, an acquired registrar with stale records.
- Do not stop reporting at expiry. The redemption and pending-delete windows are where days matter, and it is where most watchers go quiet.
- Escalate to your staff list. The registration contact is the least reliable address in the business and every registrar already emails it.
- Cache the IANA bootstrap. Fetching it per domain per week is rude to a free service and gains nothing.
- Record which card pays for each domain. An expired card is the most common cause of a renewal everybody assumed had happened.
That is the whole system. Seven posts, one diagram at a time, and nothing in it that needs a server.
All posts