This blog ran on WordPress on a Lightsail instance for years. It’s now static HTML in an S3 bucket behind CloudFront, and if you’re reading this, you didn’t notice the switch. That was the goal.
I know the tools involved, so I wasn’t trying to learn S3, CloudFront, Terraform or GitHub Actions. I wanted to practice the process: assess, decide, build, prove, cut over, decommission. Those are the same steps you’d follow for an app that matters, done on one where a mistake costs me a broken link instead of a pager.
I also didn’t do the hands-on work. Claude Code, running the Superpowers skills, did the planning, coding and testing. I made the decisions and signed off at three gates. The whole migration, from first design question to deleting the old server, happened in one day.
Superpowers did the heavy lifting
Superpowers is a set of skills for coding agents that enforce a disciplined workflow: brainstorm a design, write a plan, then execute it task by task with tests and verification. It turns out that’s also a good shape for a modernization project, and it’s why this was so easy.
Brainstorming turned my vague goal into a spec. I started with “move the blog off WordPress, maybe S3 plus Lambda”. The brainstorming skill asked one question at a time: what is the site used for, what has to keep working, what counts as failure. The result was a written design with goals, non-goals and anti-goals: a broken link is a failure even if the new site otherwise “works”. It also killed the Lambda idea. Nobody can type anything into this site, so it doesn’t need a server at all.
The plan made the work boring, in a good way. The design became a 15-task implementation plan with exact files, tests and commands for each step. It also had a “review focus” list of things likely to go wrong: posts near midnight landing on the wrong date in the URL, code blocks containing Hugo’s own {{ }} syntax, image URLs in four different forms. Every one of those got a test before any code was written. The plan also wrote my approval gates in as hard stops: before cutover, at the DNS switch, and before deleting the old server.
Execution was hands-off. The agent worked through the plan task by task, writing tests first, committing as it went and appending to a migration log at the end of each step. I gave it scoped credentials (an AWS role for Terraform, a Cloudflare token limited to DNS edits on one zone, GitHub access) and mostly watched. When something failed, it debugged the cause instead of patching symptoms, and it didn’t claim a step was done until a check proved it.
Verification came first. The plan had the agent build the checking tools before the new theme: a URL checker that found 297 URLs on the old site, a screenshot diff at desktop and phone widths in light and dark mode, and a text comparison per post. So “does it look the same?” had a number behind it from the start. In the end the front page, Writing and category pages were 0.0% different.
My job was the part that should stay human: answering design questions, deciding what to drop, and approving each gate.
What the process caught
The structure paid off in specific places. These are the ones worth remembering:
- Inventory what’s used, not what’s enabled. Jetpack had forms, subscriptions and stats switched on. No page actually used them.
- DNS was on Cloudflare, not Route 53, and there was an internal record too. My home lab runs split-horizon DNS. Switching only the public record would have left every machine on my network quietly hitting the old server. That record turned out to be stale again right before decommission, and the checklist caught it.
- Export what WordPress renders, not what it stores. Old posts rely on WordPress adding paragraph tags on the fly. Exporting rendered HTML skipped that problem.
- Pin URLs and test them. A test comparing every generated URL to WordPress’s own permalinks caught category pages landing at the wrong path.
- Require Terraform’s first plan to show zero changes. It didn’t, which is how I found I’d turned on Cloudflare’s proxy mid-project and forgotten.
- Check whether your IaC replaces what you think it updates. Changing the DNS record from A to CNAME would have been a delete and create, so the switch was done in place.
- The migration fixed old breakage. Fifteen images in the oldest posts had been broken for years, and a dead
[code]shortcode rendered as raw text. Both work now. - Run the real pipeline before cutover. I didn’t, and its first run failed twice (an OIDC subject mismatch and a missing executable bit). Neither touched the live site, but both were avoidable.
I ended the planned two-week hold after about seven hours. Every check had passed through the public path, and on a site this small the rollback wasn’t protecting much. On a system with real users I’d have kept the full hold. Before deleting the server, the agent took a final backup outside AWS and verified it with checksums. Afterwards, all 296 old URLs still passed.
What it cost
Before: about $5 a month for Lightsail. After: an estimated under $1 a month for S3 and CloudFront, with DNS still on Cloudflare’s free plan. I’ll update this with the real number once the first full bill is in.
The checklist
What I’d carry to a bigger app:
- Start with a written design: goals, non-goals, anti-goals, and a one-step rollback.
- Turn it into a plan with tests and named risks before building anything.
- Inventory features by what’s used, and find every place the name is served, including internal DNS.
- Export what the old system renders, and pin identifiers like URLs explicitly.
- Build verification tools first, prove them against the old system, and measure differences as numbers.
- Import existing infrastructure into IaC and require a zero-change first plan.
- Run the real deploy pipeline end to end before cutover.
- Put human approval gates at cutover, the DNS switch and decommission.
- Keep a final backup off the platform you’re leaving, and confirm savings on the actual bill.
Superpowers made the disciplined version of this the easy version. That’s the lesson I’ll take to a bigger app.
