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=1and…/avatar.jpg?v=2share 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-Controlheader live at the edge for 1 hour; ones that shipCache-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:
- 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. - Backend deploy: pick up
CLOUDFRONT_PRIVATE_KEY_PEM+CLOUDFRONT_KEY_PAIR_IDfrom the environment, sign every URL viastorage.PublicURL. Test against both dev and prod — old clients with cached unsigned URLs continue to work. - 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 inus-east-1regardless of where the rest of the stack runs, because CloudFront is a global service whose control plane lives there. TLSv1.2_2021is 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 tohttps://assets.tomoda.life/returnsindex.htmlfrom the bucket root, not a directory listing. There is currently noindex.htmluploaded 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 applyblocks until the state isDeployed. - Cache invalidation: not automated. If a bad object needs immediate removal, issue a manual
aws cloudfront create-invalidation --paths '/path/to/object'.