Local-First Deploy: Retiring CodeBuild for a One-Minute Ship

September 6, 2026 · 6 min · 1246 words · Bryan Chasko

The bryanchasko.com site no longer runs any managed CI to deploy. No GitHub Actions, no Woodpecker, no CodeBuild. A single script on the dev machine — scripts/deploy.sh — builds the site and ships it to production in under a minute, from the same box where the code is written and tested.

This is the third and final move in a CI arc that ran for a year. Each system was a reasonable answer to the question I was asking at the time. The problem is that none of them answered the question I had.

the arc

Three CI systems carried this site, in order:

  • GitHub Actions first — OIDC auth into AWS, no long-lived keys, workflows in .github/workflows/. It worked. It also metered every minute and ran the build on someone else’s runner in someone else’s region.
  • Self-hosted Woodpecker next — 35 repos on one AMD workstation, zero per-minute billing, full hardware control. The self-hosted era has its own post. It died (#79) and was retired.
  • AWS CodeBuild + CodePipeline as the survivor — two pipelines, bryanchasko-com-site (Source, Lint, Deploy) and bryanchasko-com-infra (Source, Approval, terraform). Managed scaling, native credential chains, no single-machine failure mode. The migration story is here.

Each migration solved the failure mode of the one before it. GitHub Actions billed per minute, so Woodpecker ran it free on owned hardware. Woodpecker was a single-machine dependency, so CodeBuild made it managed. Reasonable at every step.

the round-trip is the bottleneck

Here is what none of the three fixed. Every one of them put the same shape between me and a live change:

push -> webhook -> queue -> provision runner -> build -> deploy -> invalidate

Call it five minutes, wall clock, best case. The build itself is the fast part — hugo --minify finishes in under a second. The other four-plus minutes are transport and provisioning: the webhook fires, a runner spins up cold, dependencies install, and only then does the sub-second build happen, followed by the sync and the CloudFront invalidation.

For a solo operator iterating on a Hugo site and a Babylon.js game, that round-trip is not a safety net. It is the bottleneck. I change a line, I want to see it live, and the machine that could show me in seconds instead hands the job to a remote queue and makes me wait five minutes to learn whether the line was right.

CodeBuild also bills per build-minute, on every merge. That was a factor. But cost was the smaller reason. The larger one is that CI-as-a-service was solving a problem I do not have. The build is deterministic. It is fast. It runs on a machine that is already on. The cloud added latency and a bill and nothing else.

the replacement

One script. It is a faithful local port of the CodeBuild deploy phase — the same build, the same two-pass cache split, the same guard, the same invalidation — with the remote round-trip deleted.

The shape:

  • stamp the git short-sha into VERSION-BUILD so the on-page version label proves exactly which commit is live
  • hugo --minify — sub-second build
  • guard: if public/index.html is missing, abort before anything destructive runs
  • two-pass aws s3 sync ... --delete to s3://bryanchasko.com
  • CloudFront /* invalidation

The two-pass sync is not decoration. It splits cache behavior by content type:

# pass 1: non-HTML assets — long cache, prune stale
aws s3 sync public/ s3://bryanchasko.com --delete \
  --cache-control "public, max-age=86400" \
  --exclude "*.html" \
  --exclude "mom/*" --exclude "listen/*" --exclude "bench/*" \
  --exclude "design/*" --exclude "chat/*" --exclude "news/*" --exclude "aws/*"

# pass 2: HTML — no-cache so fixes reach browsers immediately
aws s3 sync public/ s3://bryanchasko.com --delete \
  --cache-control "no-cache, must-revalidate" \
  --exclude "*" --include "*.html" \
  --exclude "mom/*" --exclude "listen/*" --exclude "bench/*" \
  --exclude "design/*" --exclude "chat/*" --exclude "news/*" --exclude "aws/*"

aws cloudfront create-invalidation \
  --distribution-id E2E9BSL5RVN6DI --paths "/*"

Assets get a one-day cache and a long life. HTML always revalidates, so a fix reaches browsers the moment it lands. The excludes matter: the bucket is shared. mom/, listen/, bench/, design/, chat/, news/, and aws/ are each owned by a sibling repo with its own deploy. A --delete sync without those excludes would wipe another repo’s live section. The exclude list is governed by s3-prefix-registry.json — the single source of truth for which prefixes this repo does not own.

The guard against an empty build exists because a destructive --delete sync must never run against a missing public/. That lesson was learned once, the hard way, when a missing Hugo binary let a delete-sync run against an empty directory and wiped the live bucket. The guard is non-negotiable regardless of where the deploy runs.

Whole thing, end to end, on the machine where the code lives: under a minute. Proven — v0.1016 (14bdc87) shipped live in one run.

the philosophy shift

Testing moves local. That is the whole idea.

When the build runs on my machine, I see the break on my machine — in seconds, in the terminal, not in a pipeline log five minutes later behind a webhook. Fail fast means fail here, now, in front of me. The feedback loop collapses from a five-minute remote cycle to a sub-second local one.

CI-as-a-service is a good answer for a team, where a shared pipeline is the coordination point and the forced gate is the thing that stops one person’s mistake from becoming everyone’s. It is a good answer when the build is slow, or non-deterministic, or needs hardware the dev machine does not have. None of those are true here. This is one operator, a fast local machine, and a build that finishes before you can blink.

the honest trade

This is right for a solo operator on a static site. It is not a universal recommendation, and it is a deliberate trade, not an oversight.

What I give up: the guardrails of a shared pipeline. There is no forced lint gate, no merge check that no-one can forget to run, no remote record that the build passed before it went live. On a team, those guardrails are the point. Alone, they were friction guarding against a failure mode — the forgetful teammate — that does not exist here.

What I keep: lint and format still run. biome runs locally, the same as it did in the CodeBuild lint stage. It is just not a merge gate anymore — it is a thing I run because the build is on my machine and running it costs a second. The check did not disappear. Its enforcement moved from a remote gate to a local habit, on a repo with exactly one committer.

dimensionmanaged CI (codebuild)local-first (deploy.sh)
change to live~5 min round-tripunder 1 min, same machine
build timesub-second (buried in cycle)sub-second (the whole cost)
billingper build-minute, every mergenone
lint / formatforced merge gatelocal habit (biome)
failure surfacepipeline log, 5 min laterterminal, seconds
right forteams, slow or shared buildssolo, fast deterministic

The site’s infra pipeline is a separate question — terraform still wants an approval gate and a remote record, and that stays. This retirement is scoped to the site deploy: the thing I iterate on, the thing where five minutes of latency per change was the cost that mattered.

One script. One machine. Under a minute from a saved file to a live page. The cloud was never building it faster — it was only making me wait to find out.