Rendered from the repository — the file stays the source of truth.
Infrastructure — project history
Status: Snapshot as of 2026-08-18, written by session C4. Covers
infrastructure/and.github/workflows/through PR #19. Later sessions append below rather than rewriting. Sources: PR descriptions,handovers/,product/feasibility.md,product/security.md.Update (2026-08-18, session C8): coverage extended through the full merged board — sessions B3, B4, and B5 in the catch-up section below.
What was built
- 2026-08-17 — Terraform skeleton (PR #1).
Scaleway provider ~> 2.81: website buckets for docs and marketing,
registry + container namespaces, the
scaleway_containerresource commented out until a first image existed. Local state; the S3 backend block prepared but commented.terraform validatepassing from day one. - 2026-08-17 — First real deployment, spike S-1 / session B1
(PR #11). The
scaleway_containerwent live (marginalia-webapp, fr-par, min-scale 0, 1 vCPU / 2 GB), resource prefix renamed tomarginalia(owner-confirmed product name) before the first apply, plus a manual image-push script. - 2026-08-18 — CI and deploy pipelines, session B2
(PR #13). Three
GitHub Actions workflows:
ci.ymlon every PR (lint, typecheck,bun test, build all three apps,terraform fmt -check+validatewithout credentials),deploy-webapp.yml(docker build → push to the Scaleway registry → update the container via the Containers API → poll → smoke test), anddeploy-static-sites.yml(Astro builds →aws s3 syncto the website buckets). Terraform state moved into a versionedmarginalia-tfstatebucket. All three targets were deployed with owner approval on 2026-08-18: the webapp answers with the auth proxy’s 307, the docs and marketing buckets serve HTTP 200. - 2026-08-18 — Deploy fix (PR #19).
First real Actions dispatch of
deploy-webappexposed a redeploy-call bug (see problems below); the workflow now skips the explicit/redeploywhen the image PATCH already started a rollout.
Decisions and why
-
Scaleway serverless confirmed — spike S-1 decided D-7 (PR #11,
product/feasibility.mdD-7). The single highest risk in the feasibility study was whether SSE streaming survives the Serverless Containers gateway — the docs cover HTTP/2, WebSockets and gRPC but never SSE. B1 ran the spike first, on purpose, against a real deployment. The measured evidence:- a server-paced 500 ms SSE ticker arrived with 499–508 ms client-side deltas — no gateway buffering;
- an 87-event AI-SDK LLM stream arrived token-by-token over ~0.77 s with a
clean
finish/[DONE]— no truncation; - warm TTFB 87–96 ms; cold start from min-scale 0 after 25 min idle: 3.89 s TTFB (407 MB image);
- request-body probes at 512 KB / 1 / 5 / 10 / 20 MB all passed — the user-reported ~1 MB gateway limit did not materialize.
Verdict: serverless stays; the VM fallback text in D-7 is retained for reference only. (D-5’s storage-direct uploads also stay — for RLS and resumability, no longer because of a body limit.)
-
State in a versioned bucket, no locking (session B2, PR #13). Scaleway’s S3-compatible backend has no DynamoDB-equivalent lock table; the accepted trade-off for a single-operator prototype is bucket versioning as the safety net, documented in
infrastructure/AGENTS.md. -
Min-scale 0 as the cost posture. The container idles at €0 (max-scale 2 caps the worst case); demo windows switch to min-scale 1 later (B3). Every infrastructure PR carries an explicit cost-delta section — a convention worth keeping.
-
CI never sees real Supabase values (session B2). The webapp build in CI uses obviously-fake placeholder
NEXT_PUBLIC_SUPABASE_*values; the corresponding repo secrets are placeholders too, because no hosted Supabase project exists yet (SEC-6 governs the real secrets flow —gh secret set, names only in PRs).
Problems and how they were dealt with
-
Deprecated provider arguments on
scaleway_container. When B1 uncommented the container resource, the scaffold-era arguments had been deprecated in provider 2.x; they were replaced with the current ones (image,memory_limit_bytes,https_connections_only,public_endpoint) before the first apply (handovers/2026-08-17-session-b1-spike-streaming.md). -
Generative APIs 403s on the bare
/v1URL. The IAM key’s default project wasn’t the target project, and Scaleway’s Generative APIs then require the project-scoped base URL (https://api.scaleway.ai/<project-id>/v1). Found in B1 when the LLM stream failed; fixed by passing the scoped URL as a container env var via Terraform (commitbcbc258, PR #11). -
A narrowly-scoped IAM key can’t read the account API. Terraform data sources like
scaleway_account_project403 under a key scoped to storage/registry/containers/GenerativeApis; the project ID is passed in asTF_VAR_scw_project_idinstead (B1 handover). -
Terraform state stranded in a worktree. B1’s first apply left state local to the session worktree — flagged in the PR as a hand-off hazard (worktrees get deleted), parked in the main checkout, then properly resolved by B2’s migration. The bootstrap had a chicken-and-egg step: the tfstate bucket can’t live in the state it stores at creation time, so it was created by a targeted apply against the still-local state, then
terraform init -migrate-statemoved the state into it. The obsolete local state file in the main checkout was flagged for the foreman to delete (PR #13). -
Containers API drift — in two rounds. B2 found the current API is
containers/v1(not v1beta1), the field isimage(registry_imageis gone, matching the provider deprecations B1 hit), and observed that a PATCH does not roll out on its own, so the workflow added an explicit POST/redeploy(commitsdd7fa6a,6a9eb96). The first real Actions dispatch then failed at exactly that call: an image-changing PATCH does start a rollout, and/redeployduring it returns 4xx — the deploy had succeeded, only the job died before its smoke test. PR #19 made the explicit redeploy conditional on the PATCH having been a no-op. -
bun testexits 99 under PGlite despite 0 failures. Bun 1.3.14 + pglite 0.5.5 exit with code 99 whenever a test instantiates PGlite, even though all tests pass (Node exits 0). Local runs never noticed — nobody checks$?by hand; CI’s exit-code check surfaced it immediately. CI now accepts exactly theexit 99 && "0 fail"signature and fails everything else (commite425a1e); flagged as worth an upstream report (handovers/2026-08-18-session-b2-ci-deploy.md). -
PGlite cold init can blow Bun’s 5 s hook timeout on CI runners. The first
new PGlite()(WASM compile) intermittently took >5 s on a GitHub runner, failing abeforeAllas(fail) (unnamed). CI runsbun test --timeout 30000(commit89b4fad).Resolution (2026-08-18, session A7, D-9). Both PGlite issues above are gone at the root: DB-backed tests moved to a real Postgres + pgvector (local Supabase stack / CI service container), PGlite was removed, and CI’s exit-99 allowlist and raised timeout were reverted to a plain
bun test. -
workflow_dispatchworkflows aren’t dispatchable until merged. GitHub only indexes them from the default branch, so B2’s one-time deploy ran the identical steps locally; the first post-merge dispatch (which then found the PR #19 bug) was a foreman follow-up. -
memory_limit_bytesdrift. Config says 2147483648, the API stored 2147000000, so unrestricted plans show a benign in-place update. Consciously left unfixed in B2 (the apply was kept strictly targeted); aligning the config value is a noted option. -
The spike route was publicly reachable (SEC-4). B1’s deployed image exposed the unauthenticated
/api/spike-streamon a public URL — token spend if the URL leaked. Accepted for days, not weeks, by the B1 PR; B2’s deploy of current main put it behind A2’s auth proxy (curl-verified 307), and full closure comes when A4 deletes the route (PRs #13, #17).
Where infrastructure stands
Update (2026-08-18, session C8): superseded — B3, B4, and B5 all landed the same day. See the catch-up section that follows.
Everything deployable is deployed from pipelines: webapp container plus both static-site buckets, with CI gating every PR and state versioned in a bucket. Open per the roadmap: B3 (hosted Supabase, demo-mode scaling, Edge Services / custom domains), per-user rate limiting before public exposure (SEC-7), and security headers (SEC-9).
Catch-up: sessions B3, B4, B5 (appended 2026-08-18, session C8)
Written by session C8 from the session handovers and PR descriptions
(#36, #45, #48). The snapshot above is unchanged; with this section the
page covers the full merged board. The hosted-database side of B3 and B5
is told in product/history/supabase.md; this page carries the
Terraform/platform side.
What was built
- 2026-08-18 — Demo environment, session B3
(PR #36). The
deployed product became functional end to end: a hosted Supabase project
(
marginalia, eu-west-3) behind the existing container, with the env wiring split by binding time — build-timeNEXT_PUBLIC_*values flow as Docker build args from GitHub secrets (B2’s placeholders replaced viagh secret set, piped, never echoed), runtime values flow through Terraform as plain andsecret_environment_variablessourced from.env.localasTF_VAR_*at apply time.DATABASE_URLuses the transaction pooler (port 6543; the app already setsprepare: false). Awebapp_min_scaletfvar (default 0) makes demo warm-up an explicit-var webapp_min_scale=1(~€35/mo while on). B2’smemory_limit_bytesdrift gotcha was closed by aligning the config to the API’s stored value. Verified by a full headless E2E on the public URL: signup → PDF + URL ingestion → grounded chat with citations → citation click-through → save-to-note → a 4:10 audio overview generated in 15 s → auth guards (foreign notebook 404). - 2026-08-18 — Custom domain
mrgnl.eu, session B4 (PR #45). The product on its own domain with HTTPS everywhere, DNS in code (infrastructure/domain.tf):app.via the nativescaleway_container_domain(platform-issued Let’s Encrypt cert, no pipeline needed),docs./www.via Edge Services pipelines (bucket-website backends, managed certs), and the apex as a true 301 tohttps://www.mrgnl.euthrough a ~10-line serverless function bound via ALIAS — because Edge Services is subdomain-only. All previous default endpoints keep serving. Supabase authsite_urlmoved tohttps://app.mrgnl.euwith the old endpoint kept on the allow-list. - 2026-08-18 — Hosted Supabase under Terraform, session B5
(PR #48). The
live hosted project was adopted by the official
supabase/supabaseprovider by import only — the first plan needed no settings alignment (1 to import, 0 to add, 0 to change, 0 to destroy), and the post-adoption gate wasterraform plan -detailed-exitcode→ exit 0. The resource carriesprevent_destroy = trueand the import block stays insupabase.tfas a permanent record;infrastructure/AGENTS.mdnow states that a plan proposing replace/destroy on the project is a config bug, never something to apply. The container’s Supabase env is fed from thesupabase_apikeysdata source instead of hand-copiedTF_VARs, deleting a manual copy step and three vault fields.
Decisions and why
- Supabase Free tier — owner decision (session B3, PR
#36): $0 against
the recorded trade-off that idle projects pause after ~1 week; the
operational rule (check/restore, or upgrade to Pro for the demo window)
went into
product/feasibility.md. Region eu-west-3 (Paris) for colocation with the fr-par container. - Registrar Scaleway; Edge Services Starter plan (session B4, owner decisions, PR #45): the root zone is native (no delegation step), and Starter + one extra pipeline (€4.99/mo) beats Professional (€12.99/10 pipelines) at our count of exactly two. The webapp deliberately uses the free native container-domain path so it consumes no pipeline.
- Apex via a redirect function, not a pipeline (session B4). Edge Services is subdomain-only — feasibility F-8’s claim was re-verified against current docs rather than assumed (no correction needed). Functions accept apex hostnames via ALIAS and auto-issue a certificate, so the apex gets a real HTTPS 301 with the path preserved.
- The docs/www CNAMEs are the one exception to DNS-in-Terraform
(session B4): Edge Services creates and owns them for Scaleway-managed
zones, and a Terraform-managed duplicate fails on CNAME uniqueness —
documented as the exception in
domain.tf. supabase_settingsdeliberately not instantiated (session B5, PR #48). Everything configured on the hosted project is auth config, owned as code bysupabase/config.toml+supabase config pushsince B3; every category the settings resource exposes has aconfig.tomlnamespace, so importing it would put everyconfig pushand everyterraform applyin a standing double-ownership fight. The split is drawn at the resource boundary (Terraform: project lifecycle + API-key reads; CLI: everythingconfig.tomlmodels) — a flagged deviation from the brief’s letter in service of its no-double-ownership intent, accepted in review. The rule on record: move a category wholesale or not at all (supabase/AGENTS.md).- The hosted DB password stays out of Terraform state (session B5).
Import cannot read it and
ignore_changes = [database_password]keeps it that way; rotation is out-of-band. Thesupabase_apikeysread does put the anon + service-role keys in state — the same accepted class as B3’ssecret_environment_variables(SEC-6, the only register row B5 touched). The account token (SUPABASE_ACCESS_TOKEN) is keyring-only with ephemeral per-command injection, and the provider requires it at every plan/apply.
Problems and how they were dealt with
supabase config pushauto-confirms when it detects an agent (session B3): a piped “n” did not abort the first push. Everyconfig pushis an apply, not a preview — recorded in the handover for whoever runs it next.- First-time Edge Services applies race (session B4): creating two
DNS stages in parallel failed one of them (
404 edge_api_key not Found), and the race left a TLS stage in Terraform state that the platform had deleted — breaking the docs pipeline until astate rm+ rebuild. Compounding it: stage DELETEs return a bare 500 while another stage still references them, head-stage drift is invisible toterraform plan(the platform’spipeline_missing_head_stagenever surfaces there — check the pipeline’serrors[]via the API), and destroying a dns_stage deletes its auto-managed CNAME without recreating it on rebuild (manual re-add + a no-op PATCH forced revalidation). Thewwwpipeline, created in one clean serial pass, hit none of this — hence the recorded recommendation:-parallelism=1for first-time Edge Services applies. Six numbered gotchas in the B4 handover. - Scaleway node functions are ESM-only (session B4): the runtime wraps
sources as modules, so
module.exportsimports fail with an HTTP 500 — the apex redirect usesexport const handle. - A sensitivity mark alone planned a container update (session B5):
wiring the provider-sensitive anon key into plain
environment_variablesplanned an update with byte-identical before/after values (onlyafter_sensitivechanged, verified via plan JSON). Fixed withnonsensitive()— the anon key is publishable by design (SEC-6), already inlined in the client bundle. legacy_api_keys_enabledmust be explicit (session B5): the live app uses the anon/service-role JWT keys; the attribute is deprecated upstream and a null config could have planned disabling them.
Where infrastructure stands (2026-08-18, after B5)
Two providers (scaleway/scaleway, supabase/supabase) manage everything
from the container and buckets to the hosted database project and the
mrgnl.eu domain; deploys remain workflow-dispatched pipelines, and the
plan gate on the imported project is exit-0 clean. Running cost:
€4.99/mo Edge Services, everything else ~€0 idle. Open: request-rate
limiting (SEC-7), security headers (SEC-9 — Edge Services now concretely
exposes header policies for docs/www), and the undecided D2 analog of B5
(Terraform-managing the Azure Speech resources; B5’s provider-limits list
is the recorded decision input).