Deploy targets
The build pipeline emits artifacts; deploy targets decide where those artifacts go. One env var (STATICOWL_DEPLOY_TARGET) flips between four modes. Operators can run any combination side-by-side.
At a glance
| Target | Output | Hosting model | Use case |
|---|---|---|---|
static-paths (default) |
Plain HTML/CSS/JS at predictable URLs in S3 | Self-host: S3+CF, Netlify, Vercel, GitHub Pages, your own nginx | "Host anywhere" — the front-door promise |
manifest-pointer |
Content-addressed artifacts + sharded manifest + L@E resolver | Managed atomic-release CDN | Atomic blue/green deploys + audit-grade replay |
github |
HTML + JSON sidecars + _meta/ to a Git repo you own |
GitHub Pages / Cloudflare Pages / Vercel auto-publish on push | "Your content + metadata in your repo" |
both |
static-paths + manifest-pointer simultaneously |
Both | Transition / dual-shipping |
How to choose
| Need | Mode |
|---|---|
| Marketing site, indie project, personal blog | static-paths |
| "Host on S3/CloudFront myself, never touch anything" | static-paths |
| Multi-locale, large catalogue, frequent updates | static-paths (it works) |
| "I want my content + metadata in a Git repo I own" | github |
| Auto-deploy via GitHub Pages / Cloudflare Pages / Vercel | github |
| Provable lock-in escape hatch for compliance / procurement | github |
| Atomic deploy + instant rollback + cryptographic audit | manifest-pointer |
| Regulated industry (finance, pharma, gov) | manifest-pointer |
| Both the static export and the managed CDN | both |
static-paths (default)
What it writes
{outputDir}/
index.html ← root page
blog/post-1/index.html
api/articles.json ← rendered API endpoints (if defined)
rss.xml
sitemap.xml
assets/main.css
{outputDir} is per-site, per-env. The CMS server's deployToS3() then syncs the directory to:
- The customer's bucket if
site.publishConfig.bucketis set (BYO bucket / prefix / distribution / custom domain) - Otherwise the shared
staticowl-sitesbucket under<siteId>/<envId>/
Env vars
| Var | Required | Default | Purpose |
|---|---|---|---|
STATICOWL_DEPLOY_TARGET |
yes | static-paths |
Set to static-paths (or both) |
STATICOWL_DEPLOYMENT_MODEL |
no | new |
legacy reads envPublishedAt_<env> scalars; new reads from active Release; dual writes both during transition |
STATICOWL_REGION |
no | us-east-1 |
AWS region for the bucket |
Strengths
- Zero runtime dependency on us
- Compatible with everything that serves files
- Pages can be hosted on S3, CloudFront, Netlify, Vercel, Cloudflare Pages, GitHub Pages, or your own nginx
- "Take your site and leave at any time"
Trade-offs
- No atomic deploy — file-by-file sync has a brief mixed-state window
- No content-addressed dedup — a 1M-page rebuild syncs 1M files
- No replay at the CDN layer — replay still works in admin, just not at serve time
manifest-pointer (managed atomic-release)
What it writes
s3://staticowl-artifacts/
by-hash/<sha256>.<ext> ← every artifact, content-addressed
s3://staticowl-manifests/
<siteId>/<deploymentId>/
manifest-root.json ← totals, errors, hashAlgo
shards/00.json … ff.json ← 256 shards, xxhash32(path) → entry
<siteId>/current/
<envId>.json ← THE POINTER — single S3 write = deploy
s3://staticowl-buildlogs/
<siteId>/<deploymentId>/
log.ndjson ← full build log
summary.json ← top-level summary
A Lambda@Edge resolver in front of CloudFront reads the pointer per-request, looks up the right shard, and serves the artifact. See architecture.md → manifest-pointer for the full flow.
Env vars
| Var | Required | Default | Purpose |
|---|---|---|---|
STATICOWL_DEPLOY_TARGET |
yes | — | Set to manifest-pointer (or both) |
STATICOWL_ARTIFACTS_BUCKET |
yes | staticowl-artifacts |
Where content-addressed artifacts live |
STATICOWL_MANIFESTS_BUCKET |
yes | staticowl-manifests |
Where the manifest + pointer live |
STATICOWL_BUILDLOGS_BUCKET |
yes | staticowl-buildlogs |
Where build logs go |
STATICOWL_REGION |
no | us-east-1 |
AWS region |
STATICOWL_DEPLOYMENT_MODEL |
yes for this target | new |
Must be new — manifest-pointer reads from the active Release |
Provisioning prerequisites
- The three S3 buckets exist + the EC2 IAM role can
Get/Put/Delete/Head/Liston them (seeStaticOwlArtifactPipelineinline policy) - The Lambda@Edge resolver is provisioned (per
ops/lambda-edge/README.md) — this is not yet automated; today the manifest pipeline writes ahead of L@E being deployed, and that's intentional ("parked-and-ready")
Strengths
- Atomic blue/green via single-write pointer flip
- Instant rollback as a new pointer fact — no rebuild, no copy
- Content-addressed dedup — 1M-page change-1-article rebuild = ~5–10 file uploads
- Replay any past state at the CDN layer, not just in admin
- Provable audit chain end-to-end including which artifact bytes were live when
- Foundation for A/B testing — manifest entries can carry weighted variants
Trade-offs
- Depends on our CDN runtime (the L@E resolver)
- Requires bucket provisioning + IAM
- Higher operational complexity than static export
github (mirror to a customer repo)
What it writes
Pushes the compiled site + JSON sidecars + global metadata to a GitHub repo via git:
/index.html
/blog/post-1/index.html
/_meta/pages/index.json
/_meta/pages/blog/post-1.json
/_meta/types.json ← content-type definitions
/_meta/theme.json ← site theme
/_meta/routes.json ← route map (when present)
/_meta/release.json ← this deploy: releaseId, deploymentId, validFrom, env
One commit per deploy. Push-only — we never read the repo back, so there's no conflict-resolution path.
Per-page sidecar JSON shape
For each page artifact at /article/my-post/index.html, the sidecar lives at /_meta/pages/article/my-post.json:
{
"id": "article:my-post",
"slug": "my-post",
"title": "My Post",
"contentType": "article",
"path": "/article/my-post/index.html",
"fields": { ... all field values ... },
"references": { "author": ["author:alice"], ... },
"deployedAt": "2026-04-28T12:00:00.000Z"
}
The customer can fork the repo and reconstruct the site from these sidecars — that's the lock-in escape hatch.
Env vars
| Var | Required | Default | Purpose |
|---|---|---|---|
STATICOWL_DEPLOY_TARGET |
yes | — | Set to github |
STATICOWL_GITHUB_TOKEN |
yes | — | PAT or fine-grained token with contents: write on the target repo |
STATICOWL_GITHUB_REPO |
yes | — | owner/name shape (e.g., octocat/my-site) |
STATICOWL_GITHUB_BRANCH |
no | main |
Target branch |
STATICOWL_GITHUB_AUTHOR_NAME |
no | StaticOwl |
Commit author name |
STATICOWL_GITHUB_AUTHOR_EMAIL |
no | bot@staticowl.com |
Commit author email |
Bonus: free hosting via GitHub-watching services
The GitHub deploy target unlocks free hosting from anyone who watches a Git repo:
- GitHub Pages — point Pages at the same repo
- Cloudflare Pages — auto-deploy on push
- Vercel — auto-deploy on push
- Netlify — auto-deploy on push
Customers get web hosting without us building anything else.
Strengths
- Provable "your content isn't locked in" —
git clone-able artifact - Multiple free hosting options via auto-deploying watchers
- Git owns the version history; we don't have to
- One commit per deploy = clean audit log in Git's native UI
Trade-offs
- Requires
giton the host (EC2 ✓; Lambda ✗ — publisher must run on EC2) - High-velocity sites get long linear commit history (many deploys/day = many commits)
- Push-only — if the customer edits the repo manually, our next push overwrites
- Must protect the PAT — the publisher redacts it from logs and errors, but rotate on suspicion
- No atomic blue/green at the GitHub-side hosting layer (that's
manifest-pointer)
Auth + secrets safety
Token is supplied via STATICOWL_GITHUB_TOKEN. The publisher embeds it in the remote URL as https://x-access-token:TOKEN@github.com/.... If git fails, the stderr would naturally include this URL with the token visible — we redact it before logging or throwing. Verified in tests.
both
Runs static-paths and manifest-pointer simultaneously. Useful for:
- Migration from one to the other
- Customers who want the static export available alongside the managed CDN
- Disaster-recovery: if the L@E resolver has issues, the static export is still serving
The github target is independent of both — to ship to GitHub plus one of the other targets, you'd need to extend the predicate logic. For now, github is its own mode.
Deployment-model kill switch
Separate from STATICOWL_DEPLOY_TARGET is STATICOWL_DEPLOYMENT_MODEL — which controls whether the build pipeline reads from the new Deployment fact model or the legacy envPublishedAt_<envId> scalars:
| Mode | Behavior |
|---|---|
new (default) |
Read from the active Release; write only Deployment facts |
dual |
Read from legacy scalars; write BOTH legacy scalars AND Deployment facts; log divergences |
legacy |
Read + write only legacy scalars; no new model touchpoints |
dual is the transition mode. New customers should use new. legacy is the safe rollback target if the new model has bugs. See ADR-0019 for the full migration rationale.