Public hosting

Last updated: Apr 28, 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 OAC and SigV4, instead of via home-grown mechanisms we would have to operate and rotate. Unsigned requests to the Lambda URL or S3 bucket return 403 at the AWS service boundary.
  • 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.

The architecture has one documented exception. The fixes service exposes a WebSocket API. Lambda URLs are HTTP-only, and API Gateway WebSocket API v2 supports neither OAC nor mutual TLS, so it cannot join the chain as-is. Fixes stays on its current posture (proxied Cloudflare + API Gateway with $connect AWS IAM authorization as the cryptographic gate) as a documented legacy path until its WebSocket usage is either rearchitected to HTTP streaming or AWS adds the missing origin-restriction primitive.

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) rather than a different product category.

Shared-secret authorizer on API Gateway

A CloudFront custom header injects a shared secret on every origin request; an API Gateway Lambda authorizer validates it on every incoming request.

  • It covers every consumer uniformly, including the WebSocket case (fixes) via a $connect authorizer.
  • It operates entirely at the application layer, which means we would own the secret lifecycle across three AWS surfaces (Terraform state, CloudFront distribution config, Lambda env vars).
  • It requires a per-request authorizer Lambda invocation in the hot path, with caching TTL configured at the authorizer level.
  • It would force us to design a rotation procedure across CloudFront propagation and Lambda updates, with a dual-accept window to avoid downtime during secret rotation.
  • It trades AWS-platform-enforced origin control for self-operated origin control — a step backwards on the "bet with the platform" axis.
  • It has no documented AWS SLA on the shared-secret mechanism; reliability is inherited from the services (CloudFront, Lambda).
  • It costs one extra Lambda invocation per request at the authorizer layer, amortizable via caching but non-zero.

Origin mutual TLS (mTLS) on API Gateway

Cloudflare presents a client certificate on every origin connection (via Authenticated Origin Pulls); API Gateway's custom domain validates the cert against a truststore in S3.

  • It enforces cryptographic origin authentication at the TLS handshake, which is architecturally symmetric with OAC's SigV4 approach.
  • It is only supported for API Gateway HTTP API v2 and REST API v1. API Gateway WebSocket API v2 does not support mTLS, so fixes would remain uncovered.
  • It requires ongoing truststore management: PEM upload, Cloudflare-side cert rotation coordination, truststore_version bumps on every rotation.
  • It introduces a new operational dependency on Cloudflare Authenticated Origin Pulls being correctly configured per-zone or per-hostname.
  • It adds no additional monthly AWS cost, but Cloudflare plan-level requirements apply for zone-level origin pulls.
  • It is compatible with CloudFront via origin mTLS (GA January 2026), but that still leaves the WebSocket gap unresolved.

API Gateway REST API v1 with resource policies

Downgrade each API consumer from the newer 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.

  • It has the origin-restriction features that HTTP API v2 lacks (resource policies, WAF, per-stage configuration).
  • Its request pricing is roughly 3.5× higher than HTTP API v2 ($3.50 vs $1.00 per million requests).
  • It has higher cold-start latency than HTTP API v2 and is generally considered a more-featured-but-older service that AWS is not actively modernizing.
  • It does not cover fixes, because REST API is HTTP-only — WebSocket APIs are a separate v2 service which itself lacks resource policies and WAF.
  • It keeps our compute model on Lambda and requires no product-level changes.
  • It would require a migration from v2 to v1 resource types in Terraform (different resource schemas, different integration payload formats).

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, and views.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 and passes whichever origin shape applies:

  • bucket alone — a pure-static site (e.g., design.fluidattacks.com, public.fluidattacks.com)
  • lambda alone — a Lambdalith (e.g., infers.fluidattacks.com, tracks.fluidattacks.com)
  • bucket and lambda together with lambda.static_paths — a server-side-rendered site with static assets (e.g., docs.fluidattacks.com, db.fluidattacks.com)

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-request CloudFront Function that rejects non-Cloudflare source IPs, 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.

Fixes (WebSocket) remains on API Gateway WebSocket API with $connect IAM authorization, proxied through Cloudflare. This is a documented legacy path tracked for rearchitecture when the product team revisits the WebSocket usage pattern.

Other dependencies

On this page