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
cdnmodule 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
cdnmodule 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
cdnmodule deploys a Lambda@Edge function on every Lambda-backed distribution that hashes the request body and setsx-amz-content-sha256before 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
cdnmodule'sstreaming = truemode disables CloudFront compression and configures the distribution to forwardtext/event-streambodies through unbuffered, enabling the fixes service to stream long-running AI responses end-to-end from Bedrock through Lambda (RESPONSE_STREAMinvoke 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 samecdn+ 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.
API Gateway-based alternatives were last reviewed on May 27, 2026.
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.
ALB with containerized compute was last reviewed on Apr 22, 2026.
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:
bucketalone with a catch-all*route — a pure-static site (e.g.,design.fluidattacks.com,public.fluidattacks.com)lambdaalone with a catch-all*route — a Lambdalith (e.g.,infers.fluidattacks.com,tracks.fluidattacks.com)bucketandlambdatogether 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)lambdawithstreaming = true— a Lambda Function URL invoked inRESPONSE_STREAMmode for Server-Sent Events responses (e.g.,fixes-canary.fluidattacks.comand 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.