ADR-0011: Frontends as static exports on OCI Object Storage, fronted by Cloudflare Workers¶
- Status: Accepted — implemented 2026-06-01 (apex
marnissi-holdings.comlive via Workermgh-website;cloudflare_workers_custom_domainid8139df08…; Squarespace apex A records removed, MX/SPF retained) - Date: 2026-05-31
- Deciders: cloud-architect, cloudflare-expert, oci-expert, finops-agent, secops-agent, marnissi.investments
Context¶
MGH runs six hostnames across two access tiers. ADR-0007 established Cloudflare Tunnel as the sole ingress path for all hostnames, routing every request through cloudflared on the ARM A1 VPS to a local container. That model is correct and remains authoritative for backends and server-rendered workloads.
Three of the six hostnames serve frontends that are, or can be, fully static: the public website at marnissi-holdings.com (website/, Next.js), the internal admin UI at admin.marnissi-holdings.com (admin-frontend/, React), and experimental surfaces at labs.marnissi-holdings.com. Running a container on the VPS purely to serve pre-built HTML, CSS, and JS assets is wasteful: the container consumes ARM A1 CPU/memory headroom, couples the VPS deployment pipeline to frontend CI, and adds container lifecycle overhead for workloads that carry no server-side compute.
The immediate operational need is to serve a "coming soon" splash page at the apex marnissi-holdings.com, replacing the existing Squarespace A records, before the full website/ build is production-ready. The apex is currently live on Squarespace; the CF zone has a placeholder tunnel CNAME for it (ADR-0006) that has not yet displaced the Squarespace records.
OCI Object Storage (Always Free, unlimited storage and egress within OCI) can serve anonymous static assets. Cloudflare Workers (Free tier: 100,000 requests/day) can act as a thin origin-fetching edge layer in front of the bucket, handling index/error routing that OCI Object Storage cannot provide natively (OCI has no S3-style website hosting with index/error document support). Workers Custom Domain binds the Worker to the apex hostname, managing the DNS record automatically and eliminating the need for a tunnel CNAME at that hostname.
The admin-frontend/ deployment is internal and dev-only at this stage; no prod deployment decision is taken by this ADR. labs/ follows the same pattern when static content is appropriate.
Decision¶
Deploy all MGH frontend applications as static exports to an OCI Object Storage bucket, served via a Cloudflare Worker bound to the hostname via Workers Custom Domain. This carves out a defined exception to ADR-0007: ADR-0007 (Tunnel as sole ingress) remains authoritative for backends (api.*), docs (docs.*), and any server-rendered or API workload. Frontends that compile to static assets (output: 'export' in Next.js; equivalent build output in other frameworks) use the bucket-plus-Worker path instead of the tunnel-plus-container path.
Build and export. Each frontend repo is configured for full static export (Next.js: output: 'export' in next.config.js; produces an out/ directory). No server-side rendering, no route handlers, and no Next.js API routes are permitted at these hostnames — the Worker serves pre-built files only. Any feature that requires server-side compute must live on api.marnissi-holdings.com (the FastAPI backend, behind the tunnel).
OCI Object Storage bucket. One bucket per environment (e.g., mgh-website-dev, mgh-website-prod) in the eu-milan-1 region, configured with access_type = "ObjectReadWithoutList". This setting allows any client to fetch a named object by its full path (anonymous GET on <bucket>/o/<key> succeeds) but prevents enumeration of the bucket contents (no LIST capability). The canonical anonymous URL pattern is https://objectstorage.eu-milan-1.oraclecloud.com/n/<namespace>/b/<bucket>/o/<key>. OCI Object Storage does not support index document or custom error document routing — this gap is filled by the Worker.
CI deployment to the bucket. Frontend CI (GitHub Actions) uploads the out/ directory to the bucket using the OCI Object Storage S3-compatible endpoint (https://<namespace>.compat.objectstorage.eu-milan-1.oraclecloud.com) with standard aws s3 sync tooling and an OCI Customer Secret Key. A least-privilege IAM group ci-website-deployers-<env> is scoped to write access on the specific bucket only. The Customer Secret Key is stored as a GitHub Actions secret; it is not a Tofu-managed credential and is never committed to any file.
Cloudflare Worker. A thin Worker script fetches the requested path from the OCI bucket, maps / and directory paths to index.html, and serves the bucket's 404.html (or a hardcoded fallback) on missing keys. The Worker is deployed by Wrangler from CI on every frontend release. The Tofu cloudflare-workers module manages the Workers Custom Domain binding (cloudflare_workers_custom_domain resource in the Cloudflare provider v5), which auto-creates and manages the apex DNS record, replacing the Squarespace A records (198.49.23.144, 198.49.23.145, 198.185.159.144, 198.185.159.145) from the moment the binding is applied.
WAF and CF token. The hostname bound via Workers Custom Domain must be added to the WAF hostname_allowlist (ADR-0010 module) so managed ruleset evaluation applies. The Cloudflare API token used by CI for Wrangler must carry the Account Workers Scripts: Edit permission in addition to existing scopes. infra-agent adds this permission to the token scope declaration; secops-agent reviews the scope change.
www redirect, SSL, and HSTS. The existing www → apex Single Redirect rule (ADR-0006) and zone-wide SSL strict + HSTS settings (ADR-0006) are unaffected. The Worker is the edge origin; CF still terminates TLS and applies zone settings before the request reaches the Worker.
admin-frontend/ and labs/. These follow the same bucket-plus-Worker pattern when ready. admin.marnissi-holdings.com remains behind CF Access (ADR-0008); the Worker for that hostname must not remove or bypass the Access policy. CF Access evaluation happens at the edge before the Worker is invoked, so no Worker-level auth logic is required.
Consequences¶
- Cost (delta vs free tier):
- OCI Object Storage: Always Free; no storage or egress charges within OCI. The S3-compat endpoint is included. Zero cost delta.
- Cloudflare Workers Free: 100,000 requests/day. For a low-traffic public website and an internal admin UI this is ample headroom. If the public site scales past 100k req/day, Workers Paid ($5/month flat, 10M req/day) is the upgrade path.
finops-agentshould alert when monthly request count exceeds 2M (roughly 65k/day, the 65% threshold). Workers Paid is the only paid component introduced by this ADR and is entirely optional at current traffic levels. - ARM A1 VPS: freed from running
website/andadmin-frontend/containers. This headroom is available for other Always Free workloads (DB, backend). -
CI minutes: minimal;
aws s3 syncof a static export is fast. No new paid CI surface. -
Operational surface:
- One OCI bucket per frontend per environment. Lifecycle policy (already established for
mgh-assets-devin ADR-0002's pattern) should be applied to avoid stale object accumulation. - One Worker per frontend hostname, deployed by Wrangler from CI. Worker logs are visible in the CF dashboard. No new monitoring infrastructure required at launch; add a CF Health Check or external uptime check if SLA is required.
- OCI Customer Secret Key per CI environment, stored as GitHub Actions secret. Rotation cadence: 6 months or on suspected compromise. Runbook entry required in
infra/scripts/rotate-secrets.md. - Workers Custom Domain binding is Tofu-managed. DNS record for the apex is owned by the CF provider after first apply — operators must not manually edit the apex record in the CF dashboard.
-
Account Workers Scripts: Editadded to the Cloudflare API token scope.secops-agentmust verify this addition is scoped to account level (Workers are account-level resources, not zone-level). -
Security posture:
access_type = "ObjectReadWithoutList"on the bucket: anonymous GET on a known key succeeds; directory listing is disabled. Assets served to a public frontend are world-readable by design (they ship in the browser anyway). Foradmin-frontend/assets, CF Access blocks unauthenticated requests at the edge before the Worker fetches from the bucket, so the bucket URL is never exposed to unauthenticated clients. However, if an attacker knows a specific object path, they could fetch it directly from the OCI endpoint without going through CF Access. Mitigating controls: (a) never put sensitive data in the static bundle; (b)secops-agentreviews each frontend deployment for secrets or PII in the built assets; (c) a future enhancement could add an OCI pre-authenticated request with expiry and have the Worker proxy with it, at the cost of added complexity.- Zero public ingress on OCI compute is preserved. The OCI ARM A1 NSG rules are unchanged; no new inbound rules are added. The bucket is object storage (not compute), accessed by CF Workers over the public OCI Object Storage endpoint — this is not an inbound rule on the VPS.
- The
Account Workers Scripts: Edittoken permission is broader than a zone-scoped permission.secops-agentshould verify that no other accounts are accessible with this token and that the token expiry (2028-01-01) is unchanged. - Static export forbids SSR, route handlers, and server-side data fetching at these hostnames. Any feature that touches a database, calls an internal API with a service credential, or processes user-submitted data must live on
api.marnissi-holdings.com(behind the tunnel and CF Access). This is a hard architectural constraint, not a preference. -
secops-agentchecklist for every frontend deploy PR: (a) no secrets or tokens present in theout/directory or CI artifact; (b) Worker script does not introduce new egress targets beyond the OCI bucket endpoint; (c) CF Access policy onadmin.*is unchanged; (d) WAFhostname_allowlistincludes the new hostname. -
Static export constraint (load-bearing): Next.js
output: 'export'disablesgetServerSideProps, API routes (/pages/api/), Image Optimization (requires a server), and incremental static regeneration. These features are not available at the bucket-plus-Worker tier. Teams must designwebsite/andadmin-frontend/features with this in mind from the start.backend-agentowns all server-side compute onapi.*;frontend-agentandwebsite-agentmust not introduce server-side-only Next.js features without an ADR revision. -
Migration path if we revisit:
- Revert a frontend to tunnel-plus-container: remove the Workers Custom Domain binding in Tofu, re-add the tunnel CNAME for the hostname (as originally specified in ADR-0006 and ADR-0007), deploy a container to the VPS, and add an ingress rule to
config.yml. Estimated effort: one sprint. No application code changes required if SSR was never used. - Restore Squarespace apex: remove the Workers Custom Domain binding in Tofu, restore the Squarespace A records (
198.49.23.144,198.49.23.145,198.185.159.144,198.185.159.145) as CF DNS A records (proxied). This is reversible in under an hour and requires no OCI changes.
Alternatives considered¶
| Option | Why rejected |
|---|---|
| Tunnel → container per ADR-0007 (status quo for frontends) | Correct for backends; wasteful for pure static assets. A container serving pre-built HTML/CSS/JS consumes Always Free ARM A1 compute headroom (shared with the backend and DB) and adds container lifecycle (build, push to OCIR, pull, run, health check, restart) to every frontend release. No server-side compute is used once the files are built; the tunnel-plus-container path is over-engineered for this use case. |
| Cloudflare Pages | Pages is the natural CF-native static hosting product. Rejected because the operator's explicit decision is to use OCI Object Storage as the origin (keeping state on OCI, using CF only as edge/CDN/security layer). Pages also stores built assets on CF infrastructure, which is a vendor lock-in for asset storage. The bucket-plus-Worker approach keeps assets on OCI (where MGH already has Always Free Object Storage) and uses CF Workers only for routing logic. Pages would be the right choice if OCI were not already in the stack. |
| Workers Static Assets (edge-bundled) | The Cloudflare Workers platform supports bundling static assets directly into the Worker (via Wrangler's assets configuration), eliminating the OCI bucket round-trip and delivering lower latency (assets served from CF edge PoPs, not fetched from OCI on each request). This is the highest-performance option. Rejected because the operator's confirmed direction is bucket-as-origin; Workers Static Assets store files on CF's network, which duplicates the asset storage concern that OCI Object Storage already satisfies for free. If latency becomes a concern at scale, this is the upgrade path without any application code change — only a Wrangler config change. |
| CNAME to OCI Object Storage endpoint + CF Origin Rules / Transform Rules | OCI Object Storage object URLs are path-heavy (/n/<ns>/b/<bucket>/o/<key>); mapping a clean hostname like marnissi-holdings.com to that path structure requires CF Transform Rules to rewrite every request path. CF Transform Rules are a Pro plan feature. Additionally, this approach provides no mechanism to inject custom response headers, handle / → index.html rewrites, or serve custom 404 pages without Pro-tier features. Workers (Free tier) solve all of these at no additional cost. Rejected on plan constraint and capability grounds. |