Building andrewchemis.dev
This site is about 10KB a page and needs zero JavaScript to work. That part was easy. Writing the HTML took an afternoon, and produced system fonts, one column, 70 characters wide, and a monochromatic palette borrowed from All Things Distributed. First Contentful Paint lands under half a second because there is nothing to load.
The deploy pipeline took most of the effort, and almost all of that went into one constraint I did not see coming.
CloudFront's flat-rate plan has no CloudFormation resource
I wanted the CloudFront flat-rate pricing plan rather than per-request billing. For a blog nobody reads yet, a fixed monthly number is easier to reason about than a traffic-shaped one.
The plan is only available through the CloudFront console. There is no CloudFormation resource for it, which means there is no CDK construct for it either, which means the obvious approach does not work:
// This creates a distribution on the standard per-request plan.
// There is no property that opts it into flat-rate.
new cloudfront.Distribution(this, 'Cdn', {
defaultBehavior: { origin: S3BucketOrigin.withOriginAccessControl(bucket) },
});
I spent a while looking for the property I had missed. There isn't one. The subscription is a console-only action, so CloudFormation has nothing to model.
Adopt the distribution instead of creating it
The way out is to let the console own the distribution's existence and let CDK own everything around it. I create the distribution once by hand, then the stack imports it:
const cdn = cloudfront.Distribution.fromDistributionAttributes(this, 'Cdn', {
distributionId: stage.cloudFrontDistributionId,
domainName: stage.cloudFrontDomainName,
});
fromDistributionAttributes returns a reference, not a
resource. Nothing in the stack can change the distribution through it,
which is the point: CloudFormation never tries to manage something it
cannot fully describe. The distinction shows up in the synthesized
template, where the imported distribution appears in no
Resources block at all. It exists only as the literal id
and domain I passed in, used to build ARNs and alias targets for the
things CDK does own.
Everything else stays in CDK. The frontend stack creates the real S3
origin bucket, a CfnOriginAccessControl, a bucket policy
scoped to the distribution ARN, and the apex and
www Route53 alias records. A separate stack in us-east-1
creates the DNS-validated ACM certificate, because CloudFront only
accepts certificates from that region. There is no cross-region
reference between them; the certificate ARN comes out as a stack
output and gets read back later.
The rule I would give someone starting this is narrow. Import the resource whose existence you cannot express, and keep owning every resource around it. The failure mode to avoid is the opposite reflex, which is to give up on CDK for the whole frontend because one property of one resource does not fit. The bucket, the OAC, the bucket policy, the DNS records, and the certificate all model cleanly. Only the distribution's subscription does not, and that is one line of stage config rather than a reason to hand-manage six resources.
The post-deploy step that reconciles the rest
An imported distribution still needs its mutable configuration to match what the stack just built. A new bucket is useless if the distribution is still pointed at the old origin.
So the pipeline runs an idempotent post-deploy step. It pulls the live config, rewrites the parts CDK owns, and puts it back:
aws cloudfront get-distribution-config --id "$DIST_ID" > cfg.json
ETAG=$(jq -r '.ETag' cfg.json)
jq '.DistributionConfig | <rewrite origin, aliases, cert, error pages>' cfg.json > new.json
aws cloudfront update-distribution --id "$DIST_ID" \
--distribution-config file://new.json --if-match "$ETAG"
The --if-match "$ETAG" is the part that matters. Without
it, two overlapping deploys can each read the config, each rewrite it,
and the second silently discards the first. With it, the stale writer
gets a PreconditionFailed and fails the run instead.
Four fields get rewritten: the origin, set to the S3 bucket plus its
OAC; the default root object; the custom-domain aliases with their ACM
viewer certificate; and 403/404 error pages pointing at
/404.html. Everything else in the config, the pricing
plan included, the step leaves exactly as it found it.
It runs in CI/CD under the GitHub Actions OIDC role and never by hand, so no local AWS profile can drift the live distribution.
What this costs
Authenticating that step directly as the OIDC role rather than an
assumed CDK deploy role means the role itself needs
cloudformation:DescribeStacks plus
cloudfront:GetDistributionConfig and
cloudfront:UpdateDistribution, scoped to the one
distribution and the two stack ARNs. That is a wider role than a pure
cdk deploy would need, and it is the honest price of the
adoption pattern.
The bigger cost is that CDK no longer describes the whole system. Read the stacks and you will not find the distribution's cache behaviors or its pricing plan, because neither lives there. A new environment means opening the console, creating another flat-rate distribution, and adding its id and domain to the stage config before anything will deploy.
Stack identity is name plus region
The IAM role stack originally lived in us-east-1, alongside the certificate, on the reasoning that both were "global-ish" things. That was wrong, and the fix was not a config change.
CloudFormation stack identity is name plus region. Moving a stack
across regions is a delete and a recreate, never an in-place move, and
a deploy aimed at the wrong region does not error helpfully. It
creates a second stack there. In this case that stack owns the
account's only GitHub OIDC provider, shared with two other apps, so
the duplicate failed loudly with
EntityAlreadyExistsException rather than quietly forking
the infrastructure. I would rather have found out that way than the
other way.
The role stack now lives in us-west-2 with everything else, and us-east-1 holds nothing but the certificate, which is the only thing AWS actually requires to be there.
Copying this
The adoption pattern is worth the awkwardness if you want a pricing plan CloudFormation cannot express. Import the resource, keep every mutable field in a reconciling step, and make that step idempotent and concurrency-safe from the first version rather than after the first collision.
If you are copying this, the shape is small: static HTML and CSS in
one directory, a CDK app that creates an S3 bucket and adopts a
console-created distribution, and a GitHub Actions pipeline that
deploys on every push to main. The repository is private
for now. The reconciling step is the only piece that is not obvious,
and it is about fifteen lines of jq.