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.