Blog Architecture
Last verified: 2026-03-15
kyle.pericak.com is a statically exported Next.js 14 site. Markdown files are processed at build time into HTML pages. The site is deployed as static files with no server-side rendering at runtime.
Pages
- Markdown Pipeline — Remark chain, frontmatter field reference
Hosting
The site is hosted as static files in a Google Cloud Storage bucket
(gs://kyle.pericak.com). There's no application server in production.
Cloudflare sits in front as a CDN and handles HTTPS termination and
DNS proxy.
The GCP project is kylepericak in northamerica-northeast1.
How the GCS bucket maps to the domain
GCS supports serving a bucket as a static website when the bucket name matches the domain name exactly. The setup has three layers:
-
GCS bucket (
gs://kyle.pericak.com). The bucket name must match the fully qualified domain. GCS automatically servesindex.htmlas the default page for the bucket root and for directories. The bucket is configured for static website hosting withMainPageSuffixset toindex.html. GCS serves the content over plain HTTP onhttp://c.storage.googleapis.com/kyle.pericak.com/(or via the CNAME-compatible endpointhttp://kyle.pericak.com.storage.googleapis.com). -
DNS (Cloudflare). A CNAME record in Cloudflare points
kyle.pericak.comtoc.storage.googleapis.com. Cloudflare's DNS proxy mode is enabled (orange cloud), so requests hit Cloudflare's edge first rather than going directly to GCS. -
Cloudflare CDN / HTTPS. Because the DNS proxy is enabled, Cloudflare terminates TLS (provides the HTTPS certificate automatically), caches responses at the edge, and forwards cache-miss requests to GCS over HTTP. The
Cache-Controlheaders set during deployment (no-cache,no-store,must-revalidateon changed files) tell Cloudflare to revalidate on every request, so updates appear immediately after a deploy without a manual cache purge.
Replicating this for a new site
To spin up another static site using the same mechanism:
- Create a GCS bucket named exactly as the target domain (e.g.
gs://other.example.com). Enable static website hosting:gsutil mb -p kylepericak -l northamerica-northeast1 gs://other.example.com gsutil web set -m index.html -e 404.html gs://other.example.com - Make the bucket publicly readable:
gsutil iam ch allUsers:objectViewer gs://other.example.com - Verify domain ownership in Google Search Console for the domain. GCS requires this before it will let you create a bucket whose name is a domain. You can verify via a DNS TXT record.
- Add a CNAME in Cloudflare (or your DNS provider) pointing the
domain to
c.storage.googleapis.com. If using Cloudflare, enable the proxy (orange cloud) for automatic HTTPS and caching. - Deploy static files using
gsutil rsync(same pattern asbin/prod-deploy.sh). - Optional: automate with a Cloud Build trigger that fires on
pushes to your branch, mirroring the
cloudbuild.yamlpattern.
Deployment
Deployment happens two ways: automated via Cloud Build, or manually
via bin/prod-deploy.sh.
Cloud Build (automated)
A Cloud Build trigger (apps-blog-trigger) fires on pushes to main
that touch apps/blog/**. The pipeline is defined in
apps/blog/cloudbuild.yaml and managed by Terraform in
apps/blog/tf/cloudbuild.tf.
The pipeline has three steps:
- Docker build. Builds the blog's Docker image
(
gcr.io/$PROJECT_ID/kylepericakdotcom) fromapps/blog/Dockerfile. The image is Ubuntu Noble with Node.js and npm. It copies the blog source and.gitdirectory (needed for the git ref in the footer), then runsnpm install. - Static export. Runs
bin/build-blog-files.shinside the container to produce the static HTML output. - Upload. Uses
gsutil -m rsync -r -c -dto sync the built output togs://kyle.pericak.com. The-dflag deletes files in the bucket that aren't in the build output.
Manual deploy
bin/prod-deploy.sh does the same gsutil rsync from a local build.
It checks that blog/out/index.html exists first, runs a dry-run to
identify changed files, syncs them, then sets Cache-Control: no-cache,no-store,must-revalidate on changed files so Cloudflare
picks up updates immediately.
Only Kyle runs manual deploys.
Design system and Storybook
The site is built on a token-driven design system: Tailwind v4 with every
token in apps/blog/blog/design-system/tokens.css, and shadcn-style
components owned in apps/blog/blog/components/. Components are developed
and previewed in Storybook 10 (Webpack builder).
The Storybook static build ships with the site at
/storybook/, served from the same bucket. Its built
index.html uses relative asset paths, so it works from that subpath with
no extra config.
Why /storybook/ is not rebuilt on every deploy
Storybook only changes when components, tokens, stories, or their build
config change — far less often than blog posts. bin/build-blog-files.sh
hashes those inputs (.storybook, components, design-system, lib,
and the build configs) and compares the digest against
gs://kyle.pericak.com/storybook/.build-hash:
- Hash matches → the deployed Storybook is restored into
out/storybook/withgsutil rsync(no slow Webpack rebuild). - Hash differs, or the bucket hash is unreadable → Storybook is
rebuilt and the new hash is written to
out/storybook/.build-hash.
out/storybook/ is always populated either way. This is mandatory:
prod-deploy.sh and Cloud Build both sync with gsutil rsync -d, which
deletes anything in the bucket missing from the local build, so an empty
out/storybook/ would delete the live workshop.
Related:
wiki/devops