I had an AWS account I’d stopped thinking about. It cost about $1.50 a month, which is
cheap enough that “I should clean that up” never made it to the top of any list. This site
lived there, behind S3 and CloudFront. So did a wedding website, a bus visualisation, and
a pile of things from university that I’d genuinely forgotten existed.
One evening I asked Claude to move all of it to Cloudflare. What follows is less a migration guide than a list of things neither of us expected to find.
Starting with an inventory
The first useful thing was refusing to trust my own memory of what was in there. I gave Claude a read-only IAM user and asked it to enumerate the account properly, which turned up rather more than the two things I would have listed:
- 18 S3 buckets
- 3 CloudFront distributions
- 45 Lambda functions
- 8 API Gateways
- 4 DynamoDB tables
- 2 Route 53 hosted zones
- 3 orphaned snapshots attached to nothing
Cost Explorer turned out to be the sharpest tool: it showed
line items for EC2 - Other and RDS even though I had no EC2 instances and no
databases. Those were orphaned snapshots - an 8 GB EBS snapshot from 2022, a Postgres
snapshot from 2019, a MySQL one from 2024 - quietly billing for storage attached to
nothing at all.
The thing that actually mattered
Buried in the IAM listing:
blog AdministratorAccess key created 2020-05-17ifanalytics AdministratorAccess key created 2020-05-26lilierafa-wedding AdministratorAccess key created 2025-09-15poabus AdministratorAccess key created 2021-08-22serverless-admin AdministratorAccess key created 2020-01-20urlshortener AdministratorAccess key created 2020-12-13website AdministratorAccess key created 2020-05-17windows AdministratorAccess key created 2020-06-02Eight IAM users, every one of them with AdministratorAccess, every one with a live
access key, the oldest from January 2020. Never rotated, which AWS itself will tell you not to do. Six of them were CI users
whose keys sat in GitHub Actions secrets, which means six repositories each held
credentials that could do anything at all to the account.
This would be solved either way because that account would be going away, but damn, was I lazy huh.
A bug that had been lying for six years
This is my favourite part.
Porting the sorting Lambdas meant reading them. Claude got to the Shell sort:
for (let i = sequence.length = 1; i >= 0; i--) {That’s sequence.length = 1. An assignment, not sequence.length - 1. It truncates the
gap sequence to a single element, so sequence[1] is undefined, the inner loop never
runs, and only the h = 1 pass executes.
Which is plain insertion sort.
The API was still live at that point, so it was testable rather than theoretical. On a reversed array of 500 elements:
insertion-sort changes = 124750shell-sort (Shell) changes = 124750shell-sort (Knuth) changes = 124750shell-sort (Tokuda) changes = 124750All identical. And 124750 is exactly n(n-1)/2, textbook insertion sort. Three
different gap sequences, all reporting the same number, on an article whose entire
argument is that gap sequences matter. Every reader who played with that widget since
2018 got insertion sort three times over.
Fixed, on the same input:
Shell 1532 (98.8% fewer)Knuth 1804Tokuda 1714The post had been describing one thing and demonstrating another for six years. Nobody told me - not even the professor who reviewed that assignment lol.
Where Workers get interesting
I wanted the sorting API to stay server-side. Claude said it couldn’t report timings on Workers, which sounded like the kind of claim worth checking, so it deployed a throwaway Worker and measured.
Sorting 20,000 elements. Roughly 200 million operations. Hundreds of milliseconds of real CPU:
{ "performance_now_delta_ms": 0, "date_now_delta_ms": 0, "with_yield_delta_ms": 0, "cpu_actually_did_work": true}Zero. Not approximately zero. Exactly zero, on every clock available, including with an
await in the middle to try to break the freeze.
This is documented, it turns out, and I’d simply never read that page. Cloudflare’s runtime docs say that timers “only advance or increment after I/O occurs”, explicitly to “mitigate against Spectre attacks”. A high-resolution timer plus predictable compute is the raw material for a side-channel attack, so the runtime simply doesn’t let your code observe time passing while it computes. Their security model write-up is worth reading if you want the reasoning in full. Entirely sensible, and completely fatal to a benchmark widget.
The compromise: the Worker does the sorting and returns the operation count, which is exact. The browser times the round trip, which is honest as long as you say so. The page now says so, and the numbers include roughly 100ms of network latency that you should mentally subtract.
While we were there, we also found the ceiling by walking it up until it broke:
n = 35,000 → 200 OK, 2.1sn = 40,000 → error 1102 (CPU limit exceeded)Error 1102 is the Worker exceeding its CPU budget.
So the cap is 25,000 now.
Where everything ended up
| Was | Is |
|---|---|
| S3 + CloudFront | Workers static assets |
| Lambda + API Gateway | one Worker route on the same domain |
| Route 53 | Cloudflare DNS |
| ACM | Universal SSL |
| Zoho Mail | Email Routing |
| GitHub Actions + AWS keys | Workers Builds |
The AWS account is empty. Zero buckets, zero distributions, zero functions, zero tables, zero zones, zero roles.
One row in that table wasn’t part of the plan. I’d been on Zoho for mail and never liked
it, and since the DNS was already moving I figured I’d look at what Cloudflare had.
Email Routing is free, took about two
minutes, and gives you a catch-all: anything @rafaaudibert.dev now lands in my normal
inbox, so I can hand out a throwaway address per service and see who leaks it. Genuinely
one of the nicer things I’ve set up this year, and I only found it because I happened to
be in the neighbourhood.
The deployment story got a lot less silly too. Every one of these repos used to carry its
own deploy.yml: check out, install, build, configure-aws-credentials, aws s3 sync,
then aws cloudfront create-invalidation and sit there blocking on
wait invalidation-completed so the job wouldn’t go green before the cache had actually
turned over. Forty-odd lines, duplicated with small mutations across six repositories,
each needing AWS_ACCESS_KEY_ID, AWS_SECRET_KEY_ID and a DISTRIBUTION id pasted into
GitHub secrets.
Now there is no workflow file at all.
Workers Builds watches the repo
and a push to master builds and deploys it. The variables that genuinely are build
variables - a PostHog key, a Mapbox token - live in the Cloudflare project settings
instead of being handed to a CI runner alongside the keys to my entire cloud account.
Deleting those workflow files felt disproportionately good.
Was the AI actually useful?
Yes, though not in the way I expected. The mechanical parts (rewriting deploy pipelines, porting seven sorting algorithms, swapping DNS records) were fine but not the interesting bit. I could have done those, slowly.
What I couldn’t have done is the noticing. The Shell sort bug was found by reading code that was being ported anyway. The dangling DNS record turned up because something compared the Route 53 zone against the actual CloudFront distribution list. The eight admin keys surfaced from a permissions check for something else entirely. The EventBridge rule appeared in a sweep looking for something different again.
It also disagreed with me usefully. When I said to point DNS at the new setup, it pointed out that removing the Route 53 zone before delegation had propagated would take down my MX records along with the website, and did the website half only. When I said to delete everything, it stopped before the IAM users and asked. When I asked it to add a secret to a public repo to save me a dashboard click, it said no and explained what scrapers do with Mapbox tokens.
So, yeah, Claude was very useful, thank you, buddy.
