Skip to content

Architecture

blogwright manages a small, fixed set of AWS resources - an S3 bucket, a CloudFront distribution, two IAM roles, a builder MicroVM image, log groups, and a few pieces of glue - modeled as a reconcilable dependency graph. There is no CloudFormation stack, no Terraform state file on your machine, and no CDK synthesis step: the CLI talks to AWS directly by SigV4-signing raw HTTP requests, and the record of what exists lives in the bucket itself. This page explains each layer, for operators who want to know exactly what is in their account.

Every piece of infrastructure is a ResourceNode - an object with an id, its dependencies, and four operations:

interface ResourceNode {
id: string;
dependsOn: string[];
title: string;
read(ctx): Promise<boolean>; // exists? also hydrates outputs into state
create(ctx): Promise<void>;
update?(ctx): Promise<void>; // optional reconcile of an existing resource
delete(ctx): Promise<void>;
}

bootstrap applies the graph: nodes are topologically sorted (Kahn’s algorithm), then visited in dependency order. For each node the CLI calls read - if the resource exists it is reconciled via update (or left as-is when there is nothing to reconcile); if not, it is created. State is persisted after every node - including when a node fails partway: nodes record identity outputs (ids, ARNs) immediately after the call that creates a resource, and the apply loop saves whatever was recorded before re-throwing the node’s error (best-effort - a failed save is a warning, never a mask over the real error). An interrupted bootstrap therefore resumes where it left off instead of re-creating - or orphaning - resources. destroy walks the same graph in reverse dependency order.

The node set for a standard (non-preview) environment:

Node id Resource
bucket The S3 bucket (<env>-<siteName>-<accountId>) with public access blocked and bucket tags applied. Holds artifacts, the site, and state. Tags and the public-access block are re-applied on every apply (both idempotent), so a bucket left half-configured by an interrupted run converges on the next bootstrap.
microvm-log-group CloudWatch log group for builder MicroVM output (/aws/lambda/microvms/<env>-<siteName>-builder), retention from retention.microvmDays.
cloudfront-log-group CloudWatch log group for CloudFront access logs (/<siteName>/<env>/cloudfront), retention from retention.cloudfrontDays. Always created in us-east-1 - CloudFront vended log delivery exists only there.
iam-build-role IAM role Lambda assumes to build the MicroVM image. It is also the running MicroVM’s ambient identity (via IMDS), so its policy covers reading source zips, writing site/, and writing build logs.
iam-exec-role IAM role passed to each builder MicroVM at launch: read the bucket, write site/ and the build manifests, write logs.
microvm-image The builder MicroVM image, baked from the packaged build-agent bundle. Both create and update run the same reconcile: create if missing, rebuild if the agent bundle or log group changed, otherwise no-op.
oac CloudFront Origin Access Control, so the distribution reads S3 privately.
cloudfront-function A viewer-request CloudFront Function (<env>-<siteName>-router) that resolves directory URLs to index.html - required because the private S3 REST origin, unlike a website endpoint, does no index-document resolution. Preview stacks use a variant that also routes the request’s Host to its previews/<id>/site/ prefix.
cloudfront-distribution The distribution: OAC-secured S3 origin with origin path /site, the router function attached, 403/404 mapped to the site’s 404 page (or to /index.html with a 200 in spa mode). Preview stacks disable caching instead. In SSR mode the distribution instead gains a second, HTTPS-only origin for the Web Function endpoint (60-second origin read timeout, the lambda OAC), a default behavior on it with the <prefix>-ssr cache policy and the AllViewerExceptHostHeader origin-request policy, ordered behaviors for ssr.staticPaths on the S3 origin under CachingOptimized, an empty DefaultRootObject and no custom error responses; the router function is not associated with any behavior. The mode the distribution was left in is recorded in state, and the reconcile that owns this shape runs on every SSR bootstrap and on a mode switch, patching only the fields blogwright owns so a console-managed field survives. When headers is configured, every cache behavior carries the response headers policy: a static stack attaches it with a narrow update that touches nothing else, and the SSR reconcile compares it like any other behavior field. If creation hits a duplicate-alias conflict left by a crashed earlier run, the node adopts the existing distribution - candidates are narrowed by the deterministic comment (<siteName> <env>) and confirmed by the immutable CallerReference; anything unconfirmed re-raises the conflict.
response-headers-policy Only when headers is configured, or a policy is still recorded. The blogwright-managed <env>-<siteName>-headers response headers policy, created before the distribution so a new distribution is born with it attached, and updated in place when the config changes. When headers is removed, its update detaches the policy from the distribution and deletes it. If CloudFront still reports it in use only because the detach has not caught up, the record is kept with the delete pending, and the next bootstrap retries. If another distribution uses it, blogwright warns once and forgets the policy. The distribution node records which distribution the policy is attached to, so deploy can warn about a policy that was never attached.
cloudfront-log-delivery Vended log delivery wiring (delivery source, destination, and delivery in us-east-1) from the distribution to the CloudFront log group.
bucket-policy Bucket policy statement AllowCloudFrontRead: cloudfront.amazonaws.com may s3:GetObject on site/*, conditioned on the distribution’s ARN. Its read always returns false, so the statement is re-applied on every apply and tracks the distribution ARN; it merges into the bucket’s existing policy, preserving the SSR statement below.
web-log-group, web-local-log-group SSR only. The Web Function’s two CloudWatch log groups (/<siteName>/<env>/web and the in-Region companion /<siteName>/<env>/web.local), retention from retention.webFunctionDays. Lambda writes to the .local companion before centralising, and would otherwise create it itself with no retention and outside the graph.
iam-web-role SSR only. The Web Function’s execution role: logs:CreateLogStream and logs:PutLogEvents on those two groups and nothing else. An app that needs DynamoDB or another service is a follow-up, not a config key.
web-oac SSR only. A CloudFront Origin Access Control of origin type lambda with always-amz-auth signing.
web-cache-policy SSR only. The Blogwright-managed <env>-<siteName>-ssr cache policy: minimum/default TTL 0, maximum 31,536,000 seconds, every query string and exactly the operator-declared cacheKeyHeaders in the key, cookies forwarded but excluded from the key, gzip and Brotli on.
web-code-bucket-policy SSR only. Bucket statement AllowLambdaWebFunctionCodeAccess: lambda.amazonaws.com may s3:GetObject and s3:GetObjectVersion on web/artifacts/*, conditioned with ArnLike on aws:SourceArn matching this function’s revision ARN pattern - so no other account’s function can have Lambda read the bucket on its behalf. A separate Sid from the CloudFront statement, each re-applied by its own node.
web-function SSR only. The Lambda Web Function (<env>-<siteName>-web) with its placeholder revision, uploaded to web/artifacts/placeholder.zip first, and the site endpoint (HomeRegion, IamAuth, autoDeploymentMode: LatestRevision). Creation retries while IAM and the bucket policy propagate, records the function ARN immediately, and adopts a same-named function left by a crashed bootstrap. Bootstrap creates no real revision: the endpoint converges to whatever the first deploy publishes.
web-function-policy SSR only. The function’s resource policy statement AllowCloudFrontOACAccess, replacing only that statement with the current distribution ARN - no wildcard principal is ever written.

Four nodes are conditional:

  • acm-certificate - only when a domain is configured. Requested in us-east-1 (CloudFront requires it), validated via DNS; the CLI prints the validation records and polls until ISSUED. One certificate covers domain and every entry of aliases (the primary as subject, the rest as alternative names); a changed name set requests a replacement, which is deleted only after the distribution has switched to it. Preview stacks request a wildcard cert and create the validation records in Route53 automatically.
  • site-dns - production stacks with a domain. With manageDns, A/AAAA alias records in Route53 for each hostname whose hosted zone is in the account (recorded in state so destroy and --prune-aliases remove them); otherwise it prints the routing record each hostname needs.
  • gh-oidc-role - only when githubRepo is set (always present on preview stacks). Creates the account’s GitHub OIDC provider if needed and a repo-scoped deploy role (<env>-<siteName>-gh). See CI deploys.
  • preview-dns - preview stacks only: a Route53 wildcard *.<domain> A/AAAA alias record pointing at the distribution. See PR previews.

The graph’s record of what exists lives at state/<env>.json in the bucket - one JSON document per environment, shared across machines and CI, and the single source of truth:

{
"version": 1,
"env": "production",
"updatedAt": "…",
"resources": { "<nodeId>": { "arn": "…", "id": "…" } }
}

Each node records its outputs (ARNs, ids, domain names) under its id; later nodes and commands read them from there. The bucket name is derived deterministically from env, site name, and account id, which resolves the bootstrap chicken-and-egg: the CLI always knows where state lives before anything exists. A missing state object means a fresh environment; a present-but-corrupt one is a hard error - blogwright refuses to proceed with empty state rather than risk creating duplicate resources. During destroy, state-save failures are tolerated once the bucket itself is gone.

blogwright status diffs this planned graph against what read finds live - see Operations.

One bucket per environment holds everything:

<env>-<siteName>-<accountId>/
├── build/
│ ├── <hash>.zip # source zips, named by the jj/git revision hash
│ ├── agent/agent-<hash>.zip # build-agent bundle the builder image is baked from
│ ├── manifests/<hash>.json # one deployment manifest per build to site/ (history/rollback)
│ ├── staged/<hash>.json # one record per staged build (`stage`), stamped when promoted (history)
│ ├── artifacts/<hash>.json # original artifact manifest: published paths, sizes, SHA-256 (write-once)
│ ├── artifacts/<hash>/<buildId>.json # each static build's own artifact manifest
│ ├── releases/<hash>.json # the artifact manifest a staged release is bound to (`stage` writes, `promote` checks)
│ ├── pending/<target>.json # queued build job, with its own job id (present only mid-build)
│ ├── lock.json # the environment lock: who is deploying, staging, promoting or deleting (present only mid-command)
│ ├── live-release.json # the release the last deploy, promote or rollback published, or "publishing" while one runs
│ ├── changed/<target>/<hash>.json # changed-paths manifest and applied cacheControl rules
│ └── cache-control/<target>.json # cacheControl rules the target's files carry (site, release-<hash>, preview-<id>), or "unconfirmed"
├── site/ # the live site - CloudFront origin, private via OAC
├── releases/<hash>/ # staged releases (`stage`), copied into site/ by `promote`; never served
├── previews/<id>/site/ # per-PR preview sites (preview stack only)
├── web/ # SSR mode only
│ ├── artifacts/<hash>.zip # packaged server, one object version per build of the hash
│ ├── artifacts/placeholder.zip # the placeholder revision bootstrap creates
│ ├── results/<hash>.json # build result: artifact digest and version, stale site keys
│ └── publishing/<fingerprint>.json # in-flight publish note (present only mid-publish)
└── state/<env>.json # topology state - source of truth

site/ is never public: public access is blocked on the bucket, and CloudFront reads it through the Origin Access Control under the bucket policy. Deployment manifests record each build’s hash, source key, status, timing, and MicroVM id - they are what blogwright history lists. blogwright rollback <hash> re-runs the build from the retained source zip at build/<hash>.zip. blogwright delete empties site/ only; destroy empties every prefix and removes the bucket.

In SSR mode the bucket is versioned. A revision pins the exact object version of its web/artifacts/<hash>.zip, which the manifest’s webFunction block records along with the revision’s settings - that pair is what rollback reproduces a revision from. A publish note under web/publishing/ exists from just before a revision is created until it is verified (or restored away from); a deploy that finds one for the revision already serving finishes that publish instead of treating it as unchanged. Because the bucket is versioned, destroy deletes every object version and delete marker, not just current objects.

Sites are built in a Lambda MicroVM, not on your machine. At bootstrap, the microvm-image node bakes a stable builder image: the build-agent bundle shipped inside the CLI package is zipped, uploaded to build/agent/agent-<hash>.zip, and snapshotted onto the Amazon Linux 2023 MicroVM base image (arn:aws:lambda:<region>:aws:microvm-image:al2023-1). The image reconcile also runs before every deploy - a cheap hash compare in the common case - so build-agent fixes in a blogwright upgrade propagate through CI without a separate bootstrap.

Each blogwright deploy takes the environment lock (build/lock.json, a conditional write only one command can win), marks the live-release record publishing, and then:

  1. Zips the repo (honoring .gitignore, plus sourceInclude), names the zip by the jj/git revision hash, and uploads it to build/<hash>.zip.
  2. Launches a MicroVM from the builder image with the exec role, retrying transient gateway errors - the control plane can briefly 502 right after an image update, and the launch’s client token makes retries idempotent.
  3. Writes the job document - including the cacheControl rules, and refresh when they differ from the rules recorded at build/cache-control/<target>.json (the record is then marked unconfirmed first, so an interrupted build is re-applied) - to build/pending/<target>.json (site.json for a normal deploy, release-<hash>.json for a stage), with a job id of its own and the target’s changed-paths key. The agent inside the VM polls the build/pending/ prefix, resolves credentials from IMDS, pulls the zip, and runs pnpm install && pnpm build in paths.app.
  4. Syncs paths.dist to site/, comparing each file’s MD5 to the live object’s S3 ETag so only changed files are uploaded, writing each upload’s Cache-Control from the first rule that matches its site-relative URL path, records the build’s artifact manifest at the per-build key the job names (build/artifacts/<hash>/<buildId>.json), and writes the changed-paths manifest to the job’s build/changed/<target>/<hash>.json, reporting the cacheControl rules it applied. A MicroVM builds every pending job it has not built yet, one at a time, telling jobs apart by their id - so a stage and a deploy of the same hash are both built, never one taken for the other.
  5. Streams the build’s CloudWatch logs, watching for job-scoped markers (##build:done:<hash>@<jobId> / ##build:failed:<hash>@<jobId>) so neither an orphaned VM from another deploy nor another target’s build of the same hash can be mistaken for this build’s result. A bare-hash marker comes from a builder image older than job ids, and fails the build with a request to re-bootstrap rather than being trusted. The target’s changed-paths manifest doubles as a completion signal if log delivery lags.
  6. Terminates the VM, clears the pending job, writes the deployment manifest, records the cacheControl rules at build/cache-control/<target>.json on success if the agent’s manifest confirms them (unconfirmed otherwise), and invalidates only the changed CloudFront paths - falling back to /* past invalidationMaxPaths, and skipping invalidation entirely when nothing changed. It then reads the build’s artifact manifest back from its per-build key and, on a deploy, keeps the first one recorded for the revision as its write-once original at build/artifacts/<hash>.json (a conditional If-None-Match: * write).

In SSR mode step 4 differs: the agent validates the entry file, rejects any symbolic link inside the package (pnpm’s default node_modules is a symlink tree, and Lambda loads nothing that is not in the archive), enforces the 512 MB unzipped bound, zips the package with a fixed timestamp, uploads it to web/artifacts/<hash>.zip, publishes only ssr.staticDir to site/ (upload-only, listing what it did not publish as staleStaticKeys), and writes web/results/<hash>.json before the changed-paths manifest. The CLI then reads that result, compares the revision fingerprint with what is serving, and either creates a revision (polls it Active, probes the endpoint, then deletes stale keys and invalidates /*) or skips creation and invalidates just the static paths. Vended log delivery is unchanged. The builder image request states cpuConfigurations: [{ architecture: 'ARM_64' }] on every create and update - ARM_64 is the only value the MicroVM API accepts, and it matches the Web Function runtime, so a native dependency compiled during the build serves correctly.

The VM self-terminates at microvm.maxDurationSeconds even if the CLI dies mid-deploy, and a failed terminate is only a warning - never something that masks the build outcome. blogwright logs <hash> replays a build’s CloudWatch logs later; see Deploying for the operator-facing flow.

blogwright uses no AWS SDK clients. blogwright-core contains one SigningClient that SigV4-signs raw HTTP requests with @smithy/signature-v4 and sends them over fetch, plus a thin hand-written client per service: S3, STS, IAM, CloudWatch Logs, ACM, CloudFront, Route53, Lambda MicroVMs (served off the standard Lambda endpoint), and Secrets Manager. Every AWS call in the system goes through this single seam.

A few consequences of that design:

  • Nothing to drift from. There is no CloudFormation/Terraform intermediary whose model of the world can diverge from reality. read asks AWS directly, and status compares that answer to the planned graph.
  • Global-service quirks are handled in the client bundle. IAM, CloudFront, and Route53 sign as us-east-1; the ACM, CloudFront, and Route53 clients are pinned to us-east-1 (CloudFront certificates must live there), and a second Logs client is pinned there for vended log delivery.
  • One endpoint override redirects everything. --endpoint <url> (or AWS_ENDPOINT_URL) routes every service to a single origin - how the integration tests run against the floci emulator on http://localhost:4566.
  • Retries are safe by construction. Idempotent methods (GET/HEAD/PUT/DELETE) retry on network errors and 5xx; non-idempotent POSTs retry on network errors, where the request never reached the server, but never on a 5xx - a 5xx on a POST might mean the mutation applied, and retrying could, for example, launch a second MicroVM.
  • Throttling is retried for every method. A request AWS refuses for its rate (Throttling, ThrottlingException, TooManyRequestsException, S3’s SlowDown, Route 53’s PriorRequestNotComplete, any HTTP 429) was rejected before it was acted on, so it is retried even when it is a create - with its own budget of eight attempts and jittered exponential backoff from 1 second up to 20 seconds, about 36-71 seconds in all, so several commands throttled together do not retry in lockstep. Quota errors such as LimitExceededException are not retried: waiting does not clear them.
  • The transport is injectable, so the entire test suite runs against transport-level mocks with no cloud account.

Every AWS resource blogwright creates that supports tagging - the bucket, log groups, IAM roles, certificate, distribution, and log-delivery source - carries two tags:

  • environment - the environment name (production, staging, preview, …).
  • app - the explicit app config option, falling back to the site’s domain, then the repo directory name. Always something a human can trace back to the project from a billing or resource listing.

Site files get the same pair as S3 object tags, written on the PUT itself (the x-amz-tagging header - which is why the build roles grant s3:PutObjectTagging explicitly; s3:PutObject does not imply it). Preview deploys stamp the PR into the object tags (environment: preview-pr-42), so per-PR cost and cleanup queries work. Because object tags ride on the upload, a tag-only change never reaches unchanged objects - deploy --refresh re-uploads everything once to land it.

Resource names are derived deterministically from the environment, siteName, and account id - no random suffixes, so read can always find what a previous run created:

Resource Name
S3 bucket <env>-<siteName>-<accountId> (must fit S3’s 63-character limit)
Build role <env>-<siteName>-build-role
Exec role <env>-<siteName>-exec-role
MicroVM image <env>-<siteName>-builder
MicroVM log group /aws/lambda/microvms/<env>-<siteName>-builder
CloudFront log group /<siteName>/<env>/cloudfront
Origin Access Control <env>-<siteName>-oac
Web Function <env>-<siteName>-web
Web Function role <env>-<siteName>-web-role
Web Function OAC <env>-<siteName>-web-oac
Web Function log groups /<siteName>/<env>/web and /<siteName>/<env>/web.local
SSR cache policy <env>-<siteName>-ssr
Router function <env>-<siteName>-router
Log delivery source / destination <env>-<siteName>-cf-source / <env>-<siteName>-cf-dest
GitHub OIDC role <env>-<siteName>-gh

siteName is the stable slug in every name - see Configuration - and the <env>- prefix is what keeps environments fully isolated stacks in the same account; see Environments.