WP-001 in Infrastructure

Static Delivery for a Zero-Idle Research Site

CloudFront + S3 origin, deploy-by-pivom, and a pure-Hugo build

Version
1.0
Status
published
Published
4 min read · 772 words

Abstract

research.prosyon.ca is served as a static site from S3 behind CloudFront, with no always-on compute and no per-request cost floor. This paper documents the delivery model end to end: how the bucket and distribution are provisioned, how the CI pipeline resolves the correct deployment target at publish time, and why the build was reduced to a single dependency-free Hugo invocation.

Problem and context

A research library is read far more often than it is written, and it may sit untouched for weeks between publications. Paying for idle compute to serve a handful of HTML documents is exactly the kind of standing cost this project exists to avoid. The delivery model therefore has one hard requirement and two soft ones:

  • Hard: cost trends to $0 when no one is reading.
  • Soft: publishing is a single, reproducible CI step.
  • Soft: the build has as few moving parts as possible.
Scope
This paper covers delivery — provisioning and publishing. The reading experience (the codex theme) and the data-bearing services are documented separately.

The model

The site is a classic static-origin CDN topology: an S3 bucket holds the rendered output, and a CloudFront distribution terminates TLS, caches at the edge, and reaches the bucket through an Origin Access Control so the bucket is never public.1

ReaderCloudFrontedge cache + TLSS3 bucketprivate originCI pipelineupload
Figure 1. Request and publish paths. Readers hit the edge; CI writes to the origin.

The whole topology is one reusable Terraform module, instantiated per site:

Origin
S3 bucket, private, OAC-only access
Edge
CloudFront distribution, HTTPS-only
Certificate
ACM in us-east-1 (see WP-003)
DNS
external provider, ALIAS/ANAME to the distribution
Idle cost
$0 (storage + transfer only when read)

Why not a server

A small VM or container would be simpler to reason about, but it violates the hard requirement: it bills whether or not anyone reads. Static origins invert that — you pay for bytes stored and bytes served, both of which go to zero at idle.2

The approach: deploy-by-pivom

The interesting part is publishing. The CI pipeline does not hard-code the bucket name. Instead it resolves the deployment target at run time from the site’s domain, a pattern referred to internally as deploy-by-pivom:

  1. Terraform provisions (or confirms) the bucket and distribution.
  2. A resolver step maps research.prosyon.ca → the concrete bucket name and exports it to the job environment.
  3. The build artifact is uploaded to that bucket.

Decoupling the publish step from a literal bucket name means the same workflow publishes any domain the resolver knows about, and renaming or recreating the bucket never requires editing the pipeline.

StageActionGuarantee
deployterraform applyInfra exists and matches code
resolvedomain → bucket nameUpload targets the right origin
buildhugo --minifyDeterministic, dependency-free out
publishsync artifact to S3Edge serves the new revision
Table 1. Pipeline stages and what each guarantees.

Implementation: a pure-Hugo build

The original placeholder build shelled out to an “under construction” action. Replacing it, the design choice that mattered most was eliminating Node from the build. The codex theme is written in vanilla CSS and uses Hugo Pipes for concatenation, minification, and fingerprinting3 — so there is no Tailwind step, no npm install, and no package.json to keep in sync.

The build collapses to one hermetic command:

# Render content/ + themes/codex into www/public/
hugo --source www --minify --gc

That single binary invocation honors the build/release/run separation cleanly: the build stage produces an immutable artifact, and the release stage only moves bytes.4

Hermetic builds
Pinning the Hugo version in CI (rather than relying on whatever the runner has) keeps the output byte-stable. A given commit renders identically today and a year from now.

Cache and invalidation

CloudFront caches aggressively at the edge. Because CSS and JS assets are fingerprinted (their filename contains a content hash), a new build emits new asset URLs and the old ones simply age out — no asset invalidation needed. Only HTML documents require a targeted invalidation on publish.

Trade-offs

  • No server-side anything. Search, comments, and dynamic features must be client-side or delegated to a separate service. For a document library this is acceptable, even desirable.
  • External DNS. Apex records point at CloudFront via ALIAS/ANAME; the certificate flow this implies is its own subject (WP-003).
  • Cold author experience. Publishing requires a CI round-trip rather than a live edit. The reproducibility is worth the latency.

Summary

Static origin, edge cache, private bucket, and a one-command build give a site that is cheap to run, boring to operate, and impossible to leave in a half-deployed state. The only standing cost is storage measured in megabytes.


  1. Origin Access Control replaces the older Origin Access Identity and is the current recommended way to keep an S3 origin private while allowing CloudFront to read it. 1 ↩︎

  2. Storage and transfer are usage-metered; with no readers and a few MB of HTML, the monthly bill rounds to zero. ↩︎

  3. Asset processing is handled entirely inside Hugo. 3 ↩︎

  4. Strict separation of build, release, and run stages. 2 ↩︎

References

  1. Amazon Web Services (2024). Restricting access to an Amazon S3 origin (Origin Access Control). Amazon CloudFront Developer Guide.
  2. A. Wiggins (2017). The Twelve-Factor App — Build, release, run. 12factor.net.
  3. Hugo Authors (2025). Hugo Pipes: asset processing. gohugo.io documentation.
Cite this paper
Jon (2026). Static Delivery for a Zero-Idle Research Site (v1.0). Prosyon Research. https://research.prosyon.ca/papers/static-delivery-pipeline/