Hosting
The default
Section titled “The default”Which of these a site is follows from its tier, which says what it’s built with.
| What | Where |
|---|---|
| A static site (tier 1) | AWS: S3 with CloudFront in front |
| A static site with an API (tier 2) | AWS: S3 for the pages, a Lambda function for /api/*, CloudFront in front |
| A server-rendered site (tier 3) | AWS: Lambda for the pages, S3 for the static files, CloudFront in front |
| A site for developers (this handbook, reports, previews), above all one only SVRBC’s members may see | GitLab Pages |
| A site’s large files (PDFs, audio, video) | its assets domain, assets.<domain> |
doreancon.org was the first site to move, on 2026-10-04: a multi-tenant Nuxt site,
one tenant per subdomain. Its setup is recorded in
infra/doreancon-org-site/,
and its repository has the build and deploy steps to copy. Older sites on
Vercel or Netlify move when someone next works on them.
GitLab Pages, for developer-facing sites
Section titled “GitLab Pages, for developer-facing sites”A site goes on GitLab Pages only when its readers are SVRBC’s developers,
not the church, its members or visitors. Pages is the right tool there for one
reason AWS can’t match cheaply: access by GitLab membership. With the
project’s Pages visibility set to Only project members (Settings →
General → Visibility), GitLab makes a visitor sign in and admits only
members of the project, which includes the svrbc group’s. Nothing to build
or maintain, no password to share.
- Members only, for anything internal: test reports, coverage, previews of unreleased work, notes about infrastructure. This is the main use.
- Public, for developer documentation meant for anyone, such as this
handbook. Say in the project’s
AGENTS.mdthat the site is developer-facing. - Never for a site the church’s members or the public use. Those go on AWS, whatever their tier: one way of deploying, one bill, and the assets domain beside them.
A Pages site still deploys from a release tag, like everything else
(Releases). svrbc-audit warns about a
project that publishes a public Pages site without saying in AGENTS.md
that it’s developer-facing.
Why AWS, and not Vercel or Amplify
Section titled “Why AWS, and not Vercel or Amplify”- The release deploys exactly what CI built. The site is a Bazel step, built once and found in the shared cache. Releasing then only uploads files. A host that insists on building the site itself would rebuild everything, outside the cache.
- No per-site secret. The release job assumes an AWS role with GitLab’s OIDC token, and the role trusts only that project’s release tags. Vercel and Netlify need a long-lived token in CI.
- One account, one bill, one naming scheme (AWS) for the site, its assets domain and the build cache.
- AWS Amplify was the first choice and doesn’t fit. Amplify Hosting accepts a prebuilt upload (a zip, or an S3 prefix) only for a static site. A server-rendered app has to be built by Amplify’s own pipeline from a connected Git repository, which is the rebuild this setup exists to avoid. Multi-tenancy wasn’t the problem: Amplify supports wildcard subdomains.
How a request is served
Section titled “How a request is served”doreancon.org’s Nuxt site is the example below. An Astro site at
tier 3 is served the same way,
with its static files from dist/client and its function from the Node
adapter, so the CloudFront and function-URL parts apply to it and the Nitro
ones don’t.
flowchart LR V[Visitor] --> CF[CloudFront<br/>viewer-request function] CF -- "static paths (cached)" --> S3[(S3 bucket<br/>.output/public)] CF -- "everything else (not cached)" --> L[Lambda function URL<br/>.output/server]Nitro’s aws-lambda preset writes both halves: .output/server is the
function’s code, and .output/public the static files. The resources follow
AWS naming: bucket and function svrbc-<domain>-site, roles
svrbc-<domain>-site-lambda and svrbc-<domain>-site-deployer.
A static site with an API
Section titled “A static site with an API”A tier 2 site is one distribution
with two origins: the bucket for every page, through the site’s own router
function, and the API’s function URL for /api/*, uncached
(CachingDisabled), with every viewer header but Host
(AllViewerExceptHostHeader) and the origin secret below. One origin for the
browser means no CORS. svrbc.org’s staging site, next.svrbc.org, is the
first:
infra/next-svrbc-org-site/.
- A distribution’s error responses are the whole distribution’s. The
pages need 403 and 404 mapped to
/404.html(S3 answers 403 for a key it lacks), and that rewrites the API’s own 403 and 404 answers too. So the API answers a client with other codes (400, 401, 422, 429, 5xx) wherever the client reads the body; only the refused origin secret stays 403. - The certificate merges first. CloudFront won’t take an alias whose certificate isn’t issued, and its validation CNAMEs live in the DNS stack: the certificate, then its CNAMEs, then the distribution, in three merge requests.
The traps, and what to do instead
Section titled “The traps, and what to do instead”- The function URL answers only to its own hostname. CloudFront can’t
pass the visitor’s
Hostto it. A CloudFront function copiesHosttoX-Forwarded-Host, and a server middleware puts it back. Anything that reads the host (tenant selection, absolute URLs) then works unchanged. - Keep the function URL public, behind a secret header. Signing
CloudFront’s requests (origin access control,
AWS_IAM) breaks any request with a body, such as Nuxt Content’s browser-sidePOSTqueries. So the URL is authNONE, and CloudFront adds a header whose value only the function knows (an environment variable). The middleware refuses anything without it, and only then trustsX-Forwarded-Host. The value goes in neither repository. - Route static paths to S3 one directory at a time. List the directories
of
.output/publicas cache behaviors, and check none of them is also a server route: Nuxt Content servessql_dump.txtstatically but answers/__nuxt_content/<collection>/queryfrom the server. - Only
/tmpis writable on Lambda. Nuxt Content builds its SQLite database at first use, so setcontent.database.filenameto/tmp/contents.sqlitefor theaws-lambdapreset. Its Vercel and Amplify presets do this on their own, so a site moved from Vercel renders with no content and logs “unable to open database file”. - Nitro links duplicate package versions (
node_modules/entities -> .nitro/entities@7.0.1). Zip the server with those links followed, or the function fails withCannot find module. - Make the zip deterministic (sorted entries, one fixed date), so an unchanged server is an unchanged zip and the release can skip updating the function.
- Don’t
aws s3 synca build whose timestamps are zeroed. Bazel’s tarballs set every mtime to 0, andsynccompares size and time, so a change that keeps a file’s size never uploads. Keep a manifest of hashes in the bucket and upload what differs. Leave removed files in place, since a page loaded before the release may still ask for its old/_nuxt/chunks. - Apex redirects are a domain setting on Vercel. On AWS, put them in the CloudFront function.
doreancon.org’s
lambda-zip.py,
deploy-aws.sh
and
00.forwarded-host.ts
implement all of the above.
Moving a site
Section titled “Moving a site”- Create the resources following
infra/doreancon-org-site/, and record them ininfra/<domain>-site/the same way. - Add the Bazel steps: the site with the
aws-lambdapreset, its zip, and a deploy that skips what’s unchanged. Deploy from the release job (Releases) alongside the old host. - Test before DNS moves. Pin each hostname to the distribution with
curl --resolve <host>:443:<cloudfront ip>, and compare every tenant and route against production (prove it equal). - Move DNS in the Porkbun
svrbcprofile: anALIASat the apex and aCNAMEfor each name (wildcards included) to the distribution. Save the zone first (porkbun --profile svrbc --json dns list <domain>). - Retire the old host: its build step and deploy job, then its project.
svrbc-audit warns about a project that still deploys
to Vercel or Netlify, and about the Lambda traps it can see in the files.