Make the Easy Thing the Right Thing
When you start copying infrastructure code for the third time, you've already waited too long. A note on extracting shared patterns into a library, and being kind to future-you.
A while ago I deployed my own site. The architecture was deliberate: Angular SSR running on Lambda behind CloudFront, a CDK pipeline that deploys itself, S3 for static assets, the works. It took longer than a static site, but every decision had a reason, and the result was something I was happy to maintain.
Then I started building sites for people in my life: partners, friends, family. And I caught myself doing what software engineers always seem to do when they think nobody's watching. I copied files.
The First Sign
For the second site I copied the Lambda configuration from my own. Same Web Adapter layer, same memory size, same env vars. Then the CloudFront distribution. Then the Route 53 records. Then the bucket deployments with their cache-control strings. By the time I had a working second deploy, I had three hundred lines of nearly-identical CDK code in two places.
It is tempting to feel productive about this. Look how fast the second one went. Look how the second one already worked because the first one already worked. But this is the kind of productivity that looks great today and becomes a liability tomorrow. The cost of copy-paste is not the second instance. It is the moment the two start to drift.
A third site was on the horizon. I knew that if I copied again I would end up with three subtly-different versions of the same infrastructure, and every fix I made in one place would have to be remembered and re-applied in the other two. AWS keeps moving. A Lambda Function URL permission requirement changes here. A Web Adapter layer version bumps there. The maintenance cost of three near-twins would be three times the cost of one twin.
So I stopped and extracted.
What Goes Into the Library
This is where the design lives. Get it wrong and you either build something nobody else can use, or you build something so flexible it does not save anyone any work.
What ended up shared:
- The full request flow: CloudFront, S3 origin with origin access control, Lambda Function URL origin with the IAM dance that makes it actually work in late 2025 after AWS changed how OAC permissions are granted, the viewer-request function that injects
x-forwarded-host. - The cache strategy. Hashed assets get one-year immutable. Unhashed assets get a day with must-revalidate. SSR HTML defers to whatever the origin emits in
Cache-Control. - The security headers. HSTS, framing, referrer policy, the Permissions-Policy header. Everything that should be the same on every site I run.
- The Route 53 alias records and the apex-to-www redirect handled at the edge by a CloudFront Function.
What stayed in the consumer:
- The Content Security Policy. Every site has different third parties. One needs an image CDN, another needs reCAPTCHA, another needs a newsletter embed. The CSP is not something you can predict in a library, so the library takes it as a string and trusts the caller to construct it.
- Additional behaviours. One site has an API behind the same distribution on the
/api/*path. The library accepts a map of extra behaviours and slots them in before the static catch-alls. - Additional domain aliases. One site has a future subdomain that needs to point at the same distribution.
- The pipeline glue. Each project has its own source repos, its own build commands. The library exposes a helper for the post-deploy invalidation step, but it does not try to own the pipeline itself.
The line I drew: anything that should be the same on every site goes in the library. Anything that is a property of the site (what data sources it connects to, what other behaviours it has, what domains it serves) stays in the calling code.
The library has knobs. Not many. Enough that the variations between three real-world sites are expressible. Not so many that the configuration is harder to write than the inline code was.
The Migration Was Different From the Greenfield
The two new sites adopted the library straight away. There was nothing deployed to conflict with, so CFN created the resources cleanly with the library's natural logical IDs. The interesting case was the original. That site was already live, with a Distribution under a CFN logical ID derived from where it sat in the construct tree at the time, and a cache policy with an account-global name attached to it.
CFN does not follow rename instructions. When you change a logical ID, it sees a new resource being created and an old resource being deleted, and it does the create first. If the new resource has an account-global name like a CloudFront cache policy, or if the new distribution wants the same alias as the existing one, the create fails and the changeset rolls back.
For the migration I had two options. I could pin the old logical IDs in the new code so CFN treated the library's resources as in-place updates rather than create-then-delete. Or I could delete the existing stack first and let the next pipeline run recreate everything fresh.
I went with the second. The first approach worked, but it left a residue in the code: a permanent block of "this resource was named differently before we used the library." I would rather take an hour of downtime on a personal site than carry that residue forever. The library-based code is the same on every site. The migration is a one-time event, and the result is consistent.
This is a small principle I keep coming back to. One-time costs are cheap. Permanent costs compound. If I have to choose between an hour of downtime and a permanent block of migration code that future-me has to understand every time I touch this file, the hour is the better trade.
The Reward
The boring, useful kind.
When AWS releases a new version of the Lambda Web Adapter, I bump the default in the library, run the build, publish a patch release. Every site picks it up on its next deploy. When I notice that the static behaviour list does not include .webp, I add it once. Every site starts caching .webp correctly.
There is a smaller, more subtle reward too. I can now reason about the system. With three copies of inline CDK code, "is the cache policy the same on every site?" was a question I had to answer by reading three files and squinting. With one library, the answer is "yes, by definition." The shape of the system is in one place. The variations are explicit.
The Rule, Such As It Is
I do not believe in "always extract on the second use" or "always inline until the third." Rules like that are recipes. The actual question is whether the duplication is going to cost you. Two copies of a five-line helper that is never going to change is fine. Two copies of three hundred lines of infrastructure code that talks to a moving cloud provider is not.
The signal I trust: when I notice myself fixing something in one place and writing a mental note to remember to fix it in the other, I have gone too far. The maintenance burden has overtaken the convenience of copy-paste, and the longer I wait, the more painful the extraction becomes.
You are not building a public library when you do this. You are not optimising for hypothetical users. You are being kind to future-you, who is going to want to make a small change six months from now and would prefer to make it in one place.
That is the entire goal. Make the easy thing the right thing.