Skip to content

Hosting

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.

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.md that 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.

  • 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.

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 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 function URL answers only to its own hostname. CloudFront can’t pass the visitor’s Host to it. A CloudFront function copies Host to X-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-side POST queries. So the URL is auth NONE, and CloudFront adds a header whose value only the function knows (an environment variable). The middleware refuses anything without it, and only then trusts X-Forwarded-Host. The value goes in neither repository.
  • Route static paths to S3 one directory at a time. List the directories of .output/public as cache behaviors, and check none of them is also a server route: Nuxt Content serves sql_dump.txt statically but answers /__nuxt_content/<collection>/query from the server.
  • Only /tmp is writable on Lambda. Nuxt Content builds its SQLite database at first use, so set content.database.filename to /tmp/contents.sqlite for the aws-lambda preset. 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 with Cannot 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 sync a build whose timestamps are zeroed. Bazel’s tarballs set every mtime to 0, and sync compares 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.

  1. Create the resources following infra/doreancon-org-site/, and record them in infra/<domain>-site/ the same way.
  2. Add the Bazel steps: the site with the aws-lambda preset, its zip, and a deploy that skips what’s unchanged. Deploy from the release job (Releases) alongside the old host.
  3. 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).
  4. Move DNS in the Porkbun svrbc profile: an ALIAS at the apex and a CNAME for each name (wildcards included) to the distribution. Save the zone first (porkbun --profile svrbc --json dns list <domain>).
  5. 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.