Public hosting

Last updated: May 27, 2026


Rationale

Every public HTTPS endpoint at *.fluidattacks.com is served behind a four-hop chain: Cloudflare at the edge, Amazon CloudFront as the origin-side CDN, and either an Amazon S3 bucket for static content or an AWS Lambda Function URL for dynamic content as the origin. Every hop enforces a specific security property, and the origin is locked down cryptographically via Origin Access Control (OAC) — CloudFront signs each origin request with AWS Signature Version 4, and the origin rejects anything that isn't a valid CloudFront-signed request. The request flow is shown below.

The infrastructure is managed as code via reusable Terraform modules, consumed as a single configuration shape per service — documentation sites, static asset hosts, HTTP APIs, and server-side-rendered applications all sit behind this chain.

Request flow

The main reasons why we chose it over other alternatives are:

  • It enforces origin protection cryptographically at the AWS platform layer via two independent mechanisms: viewer mTLS (Authenticated Origin Pulls) gates who can talk to the CloudFront distribution, and OAC with SigV4 gates who can talk to the Lambda URL or S3 origin. Both replace home-grown IP-allowlist mechanisms we would otherwise have to operate and rotate. Unsigned origin requests return 403 at the AWS service boundary; non-AOP TLS handshakes are rejected before any HTTP layer is reached.
  • It is the direction AWS itself is betting. AWS shipped OAC for Lambda Function URL origins in April 2024 specifically to solve "only CloudFront reaches the origin", and did not ship the equivalent for API Gateway HTTP API v2 — a clear signal of where modern HTTPS services should live.
  • It collapses to one mental model for developers. Every new public service writes a single Terraform module block and picks whichever origin shape applies (bucket, Lambda URL, or both). No per-service decision about which edge pattern to use.
  • It leaves no home-grown authentication surface to maintain. No shared secrets, no authorizer Lambdas, no rotation ceremonies.
  • Edge protections (Cloudflare WAF, bot management, rate limiting) are layered in front of CloudFront automatically, because the same cdn module points the Cloudflare DNS record at the CloudFront distribution with proxying enabled.
  • Traceability is handled via CloudWatch Logs Delivery V2 to a central S3 logging bucket, configured by the cdn module itself.
  • CloudFront automatically terminates TLS with modern ciphers (TLS 1.3 enforced at the viewer), and ACM certificates are auto-issued and DNS-validated via Cloudflare.
  • It supports the full verb set for Lambda-backed services (POST, PUT, PATCH, DELETE) and narrows to read-only verbs for pure-static distributions. Body-bearing requests work transparently for any client: the cdn module deploys a Lambda@Edge function on every Lambda-backed distribution that hashes the request body and sets x-amz-content-sha256 before CloudFront's OAC SigV4 to the origin, closing the one piece OAC for Lambda Function URLs does not handle on its own.
  • It supports streaming responses. The cdn module's streaming = true mode disables CloudFront compression and configures the distribution to forward text/event-stream bodies through unbuffered, enabling the fixes service to stream long-running AI responses end-to-end from Bedrock through Lambda (RESPONSE_STREAM invoke mode via the AWS Lambda Web Adapter) out to the viewer over Server-Sent Events. This collapses what would otherwise have been a WebSocket-on-API-Gateway exception into the same cdn + Lambda URL + OAC chain every other service uses.

Alternatives

Below are the alternatives we evaluated before choosing this architecture, ordered from most to least interesting based on our specific needs. Each is a replacement for the legacy pattern (Cloudflare non-proxied + direct-to-AWS API Gateway).

API Gateway-based alternatives (retired)

Three variants of "keep API Gateway, layer something on top" were evaluated before this architecture was selected:

  • Shared-secret authorizer — a CloudFront custom header injects a shared secret on every origin request; an API Gateway Lambda authorizer validates it. Pushes the secret lifecycle onto us across three AWS surfaces and costs one extra Lambda invocation per request.
  • Origin mTLS — Cloudflare presents a client certificate via Authenticated Origin Pulls; API Gateway's custom domain validates against a truststore in S3. Symmetric with OAC SigV4 architecturally, but only supported on HTTP API v2 / REST API v1 (not WebSocket v2), and requires ongoing truststore rotation coordination.
  • REST API v1 with resource policies — downgrade each consumer from HTTP API v2 to REST API v1, which supports resource policies and WAF attachment, and restrict origin access to CloudFront IP ranges via aws:SourceIp. Roughly 3.5× the per-request cost of HTTP API v2 and AWS is not actively modernizing the product family.

All three trade away the AWS-platform-enforced origin guarantee that OAC delivers for free, in exchange for self-operated origin control or ongoing AWS-surface rotation work. None of them covered the WebSocket case (fixes) — REST v1 is HTTP-only, mTLS is not available on WebSocket v2, and a $connect authorizer is a Lambda-authored gate (not a platform gate). Once fixes was migrated to Server-Sent Events on a Lambda Function URL, the entire API Gateway tier was retired; the full historical evaluation lives in the Amazon CloudFront ADR's "Amazon API Gateway" alternative.

Application Load Balancer with containerized compute

Place each consumer behind an Application Load Balancer (ALB) whose security group restricts ingress to a customer-managed prefix list of Cloudflare IP ranges, with compute running on containers instead of Lambda.

  • It enforces origin protection at the network layer via security group rules, which is cryptographic-equivalent (packets from disallowed IPs are dropped at the ENI).
  • It natively supports WebSocket to EC2 or ECS/Fargate targets, including the fixes case.
  • ALB to Lambda target does not support WebSocket; using ALB for the WebSocket case requires containers, not serverless.
  • It introduces container-platform operational surface (cluster management, task definitions, service discovery, container images, auto-scaling, blue-green deploys) across every migrated consumer.
  • It changes the compute billing model from per-invocation (Lambda) to always-on (minimum of one task per service, ~$20–40 per month per task on Fargate).
  • It requires a full serverless-to-containers migration per consumer — a product-level change, not a pattern swap.
  • It cannot reuse our existing Lambda module ecosystem; a new containerized deployment workflow would have to be built out per service.

Usage

This architecture serves docs.fluidattacks.com, design.fluidattacks.com, public.fluidattacks.com, db.fluidattacks.com, infers.fluidattacks.com, tracks.fluidattacks.com, views.fluidattacks.com, and the fixes canaries (fixes-canary.fluidattacks.com, fixes-sca-npm-canary.fluidattacks.com, fixes-sca-pypi-canary.fluidattacks.com) today.

We use the cdn Terraform module as the single entry point for exposing any service at *.fluidattacks.com. Every consumer writes one module "X_cdn" block, configures whichever origins apply (bucket, lambda, or both), and declares path-based routing and per-route edge Cache-Control via a routes list. Common consumer shapes:

  • bucket alone with a catch-all * route — a pure-static site (e.g., design.fluidattacks.com, public.fluidattacks.com)
  • lambda alone with a catch-all * route — a Lambdalith (e.g., infers.fluidattacks.com, tracks.fluidattacks.com)
  • bucket and lambda together with explicit static-prefix routes for content-hashed assets (cache = "immutable") plus a catch-all * route hitting the Lambda — a server-side-rendered site with static assets (e.g., docs.fluidattacks.com, db.fluidattacks.com)
  • lambda with streaming = true — a Lambda Function URL invoked in RESPONSE_STREAM mode for Server-Sent Events responses (e.g., fixes-canary.fluidattacks.com and its SCA siblings)

Routes are evaluated top-to-bottom; the catch-all * must be last. Lambda routes are precondition-enforced to cache = "none" because Cloudflare's default cache key excludes auth headers and request bodies — caching a Lambda response could serve it across requesters and defeat per-request authorization. Bucket routes pick whichever cache tier matches the bytes ("immutable" for content-hashed assets, "short" for stable URLs with mutable bytes).

Caller identity for IAM-gated routes

CloudFront OAC re-signs every origin request with its own SigV4 identity, which means a viewer's Authorization header does not survive the edge. Services that need to know who is calling them (e.g., tracks gating audit-write endpoints to specific internal roles) use a pre-signed STS GetCallerIdentity URL in an X-Fluid-Identity header. The caller signs the URL with their own AWS credentials; the back end fetches it, parses the returned ARN, and checks it against a configured allowlist. STS is the validator, AWS IAM is the trust anchor, no shared secrets are introduced. This is the same pattern Vault's AWS auth method and Kubernetes' aws-iam-authenticator use to translate AWS-IAM identity across an opaque transport.

The cdn module owns the CloudFront distribution, both Origin Access Controls, the Lambda Function URL (with AUTH_TYPE=AWS_IAM and a scoped CloudFront invoke permission), the viewer mTLS trust store (Authenticated Origin Pulls) that rejects any handshake whose client certificate is not signed by our private CA — i.e. anyone other than our own Cloudflare zone, the origin-request Lambda@Edge function that injects the body hash on body-bearing requests, the ACM certificate and its Cloudflare-DNS-validated issuance, the Cloudflare DNS record pointing at the distribution, and the CloudWatch Logs Delivery V2 wiring that sends access logs to the central common.logging S3 bucket.

The s3 module owns the private bucket (BucketOwnerEnforced, all public-access blocks on, AES256 at rest, TLS-deny baseline policy) and the merged bucket policy (OAC allow from cdn + any extra statements the consumer passes). The lambda module owns the function itself; public exposure is exclusively cdn's responsibility — the module does not create a Function URL on its own.

Other dependencies

On this page