Skip to content

CloudFront

CloudFront is the only public path to S3. Browsers, mobile clients, and any backend reading assets all hit a CloudFront distribution; S3 is never exposed directly. There is one distribution per environment, both defined in cloudfront.tf.

Distributions

Environment Public alias Origin bucket
dev assets-dev.tomoda.life tomoda-assets-dev
prod assets.tomoda.life tomoda-assets-prod

The alias is set on the distribution itself via the aliases argument and matched against the ACM certificate's domain_name. If the alias and cert ever diverge (e.g. someone renames the asset subdomain in s3.tf's local.assets_subdomain), CloudFront will refuse to serve TLS for the requested host.

Custom domain currently disabled (dev)

The enable_custom_domain variable gates the alias, ACM cert, and Cloudflare CNAME. It is false today: the dev distribution serves only on its default *.cloudfront.net name because assets-dev.tomoda.life is still held by a distribution in the decommissioned old AWS account. Flip it to true once that CNAME frees up — full steps are tracked in _temp-docs/aws-migration-todo.md in the repo root.

Origin Access Control

CloudFront talks to S3 with an OAC, not the legacy OAI. Defined in cloudfront.tf:

resource "aws_cloudfront_origin_access_control" "default" {
  name                              = "${local.bucket_name}-oac"
  origin_access_control_origin_type = "s3"
  signing_behavior                  = "always"
  signing_protocol                  = "sigv4"
}

signing_behavior = "always" means every origin request CloudFront issues is signed with SigV4 using the distribution's identity. S3's bucket policy (see S3) checks the AWS:SourceArn of the requesting distribution against an exact match, so an attacker who knew the bucket name but not the distribution ARN still cannot read objects.

Cache behaviour

The single default cache behaviour applies to all paths:

default_cache_behavior {
  allowed_methods        = ["GET", "HEAD", "OPTIONS"]
  cached_methods         = ["GET", "HEAD"]
  viewer_protocol_policy = "redirect-to-https"
  min_ttl                = 0
  default_ttl            = 3600   # 1 hour
  max_ttl                = 86400  # 24 hours

  forwarded_values {
    query_string = false
    cookies { forward = "none" }
  }

  trusted_key_groups = var.signed_urls_enforced ? [aws_cloudfront_key_group.signing.id] : null
}

Implications:

  • HTTP is redirected to HTTPS at the edge — there is no plaintext path to any asset.
  • Query strings and cookies are stripped before the origin lookup, so two requests to …/avatar.jpg?v=1 and …/avatar.jpg?v=2 share a cache key. Signed URLs also share the same cache entry as their unsigned counterparts — the signature lives in query params and is dropped from the cache key, so signed URL rotation costs zero cache hits.
  • OPTIONS is allowed for CORS preflight but not cached.
  • TTLs: objects without an explicit Cache-Control header live at the edge for 1 hour; ones that ship Cache-Control: max-age=… may live up to 24 hours.

Signed URLs

Every asset URL the backend hands out is a CloudFront signed URL with a short TTL (default 30 minutes). Chat photos and other private content get a narrow leak window — a copied URL stops working once the signature expires.

{key, ttl} → CloudFront signer (RSA private key)
           → https://assets.tomoda.life/<key>?Expires=…&Signature=…&Key-Pair-Id=…

cloudfront_signing.tf provisions the material:

Resource Role
tls_private_key.cloudfront_signing RSA 2048 keypair generated in TF state
aws_cloudfront_public_key.signing Public half registered with CloudFront
aws_cloudfront_key_group.signing Group containing the public key (CloudFront only accepts groups, not bare keys)
google_secret_manager_secret.cloudfront_private_key Private half pushed to GCP Secret Manager as tomoda-cloudfront-signing-private-key-{env}
google_secret_manager_secret.cloudfront_key_pair_id Key ID (the Key-Pair-Id query param) as tomoda-cloudfront-key-pair-id-{env}

The backend pulls both via External Secrets (backend-secrets-{env}CLOUDFRONT_PRIVATE_KEY_PEM, CLOUDFRONT_KEY_PAIR_ID) and signs every asset URL at response-build time.

Enforcement rollout

Adding trusted_key_groups to a cache behaviour makes signed URLs mandatory — any unsigned request gets a 403 at the edge. The signed_urls_enforced variable gates this so the cutover is staged:

  1. Phase 1 — signed_urls_enforced = false: keypair and key group created, secrets pushed to GSM, cache behaviour does not require signatures. Backend can still serve unsigned URLs. Apply this first so the secrets exist before the backend rolls.
  2. Backend deploy: pick up CLOUDFRONT_PRIVATE_KEY_PEM + CLOUDFRONT_KEY_PAIR_ID from the environment, sign every URL via storage.PublicURL. Test against both dev and prod — old clients with cached unsigned URLs continue to work.
  3. Phase 2 — signed_urls_enforced = true: flip the variable, terraform apply. CloudFront starts rejecting unsigned requests with 403. The backend was already signing, so this is invisible to users; only stale unsigned URLs cached in third-party hands break.

To rotate the keypair, taint tls_private_key.cloudfront_signing and re-apply. CloudFront accepts the new key immediately; old signatures stop validating once the public key is replaced.

Edge distribution

price_class = "PriceClass_200"

PriceClass_200 covers North America, Europe, Asia, the Middle East, and Africa, and excludes the most expensive edge locations in South America and Oceania. Chosen because tomoda's user base is Asia-primary plus North America and Europe. If usage consolidates and non-Asia egress becomes wasteful, drop to PriceClass_100 (US, Canada, Europe only).

TLS

viewer_certificate {
  acm_certificate_arn      = aws_acm_certificate.cert.arn
  ssl_support_method       = "sni-only"
  minimum_protocol_version = "TLSv1.2_2021"
}
  • The cert is the one provisioned by aws_acm_certificate.cert — see ACM. It must be in us-east-1 regardless of where the rest of the stack runs, because CloudFront is a global service whose control plane lives there.
  • TLSv1.2_2021 is the floor: TLS 1.2 with the modern cipher suite list, TLS 1.3 supported.
  • sni-only — clients that do not send SNI cannot connect (any modern browser and HTTP client does).

Defaults worth knowing

  • default_root_object = "index.html" — a request to https://assets.tomoda.life/ returns index.html from the bucket root, not a directory listing. There is currently no index.html uploaded for either environment; this is a no-op until something writes one.
  • is_ipv6_enabled = true — the distribution accepts AAAA queries.
  • geo_restriction.restriction_type = "none" — no country-level blocking. If that ever changes (regulatory, abuse), it goes here.

DNS

assets[-dev].tomoda.life resolves to <distribution-id>.cloudfront.net via a Cloudflare CNAME, configured in cloudflare.tf. Cloudflare does not proxy this record (proxied = false), so the edge cache is CloudFront alone — there is no double-CDN. See Cloudflare for the full DNS layering.

Operational notes

  • Propagation: any change to a CloudFront distribution takes 3–5 minutes to roll out to all edges. terraform apply blocks until the state is Deployed.
  • Cache invalidation: not automated. If a bad object needs immediate removal, issue a manual aws cloudfront create-invalidation --paths '/path/to/object'.