Skip to content

S3

tomoda uses S3 as the durable origin for every static asset served from assets[-dev].tomoda.life. There is one bucket per environment, both in us-east-2, both completely private. The only entity allowed to read from them is the matching CloudFront distribution, signing requests via Origin Access Control (OAC).

Buckets

Environment Bucket name Public domain
dev tomoda-assets-dev-373544523193 https://assets-dev.tomoda.life
prod tomoda-assets-prod-<account-id> https://assets.tomoda.life

Bucket naming is derived in s3.tf, with an account-ID suffix so the globally-unique name can't collide with a bucket in another account:

locals {
  bucket_name = "${var.project_name}-assets-${var.environment}-${data.aws_caller_identity.current.account_id}"
}

Account migration in progress

The account-ID suffix and a disabled custom domain are temporary states from the AWS account migration (old account 209695252502 → new 373544523193). The public domains in the table above are the target; dev currently serves on the default *.cloudfront.net name. Migration open items are tracked in _temp-docs/aws-migration-todo.md in the repo root.

force_destroy is gated by environment: var.environment != "prod". Dev gets true (convenient for tear-down/rebuild); prod gets false (Terraform will refuse to destroy the bucket while it has objects). To intentionally destroy the prod bucket, you must first empty it (aws s3 rm s3://tomoda-assets-prod --recursive) — there is no single-step accident path.

Public access — fully blocked

Every bucket-level public-access knob is on (s3.tf):

resource "aws_s3_bucket_public_access_block" "static_assets" {
  block_public_acls       = true
  ignore_public_acls      = true
  block_public_policy     = true
  restrict_public_buckets = true
}

Direct S3 URLs return 403:

curl -I https://tomoda-assets-prod.s3.us-east-2.amazonaws.com/test.jpg
# HTTP/1.1 403 Forbidden

The only path that resolves an object is via CloudFront on the custom domain.

CloudFront origin policy

The bucket policy grants s3:GetObject to the CloudFront service principal, scoped by AWS:SourceArn to the exact distribution for that environment. This is the OAC pattern — CloudFront signs origin requests with SigV4 and AWS evaluates them against this statement:

data "aws_iam_policy_document" "s3_policy" {
  statement {
    actions   = ["s3:GetObject"]
    resources = ["${aws_s3_bucket.static_assets.arn}/*"]

    principals {
      type        = "Service"
      identifiers = ["cloudfront.amazonaws.com"]
    }

    condition {
      test     = "StringEquals"
      variable = "AWS:SourceArn"
      values   = [aws_cloudfront_distribution.s3_distribution.arn]
    }
  }
}

No IAM user, no AWS account, and no other CloudFront distribution can read from the bucket. The matching OAC resource lives in cloudfront.tf — see CloudFront.

Encryption

Encryption-at-rest uses AWS-managed keys (the default applied to every new S3 bucket since January 2023). There is no explicit aws_s3_bucket_server_side_encryption_configuration block in s3.tf because the default SSE-S3 behaviour is sufficient for these assets — they are not regulated data and the recovery model relies on the originating tomoda backend keeping its own copies, not on bucket-side cryptography.

Key prefixes

The bucket layout is governed by the tomoda backend's AssetService — see tomoda's storage doc for the authoritative (kind, prefix, MIME allowlist) table. From the bucket's perspective, the 10 prefixes split into two ownership tiers:

Prefix Ownership App-managed cascade
avatars/ User profile Replace on rotation; delete on user purge
group-avatars/ Chat room Replace on rotation; delete on room purge
chat-images/ Chat message Sender delete (everywhere) + disappearing-message TTL
moments/ Moment Hard-delete on moment purge — ref-counted via moment_tags (post spec 15)
event-covers/ Event Hard-delete on event purge
user-stamps/ UserStamp (StampMint asset) Hard-delete when last stamp_mint_holders row drops
user-stamps-raw/ UserStamp (StampMint raw photo) Same as user-stamps/
stamps/ TomodaStamp (curated) Permanent — no user-facing delete
stamps/bespoke/ TomodaStamp (curated bespoke variant) Permanent
curios/ Curio (curated) Permanent

The app owns every cascade — S3 lifecycle rules only handle multipart hygiene and the moments/ storage-class transition.

Object lifecycle

Lifecycle config lives in s3.tf (aws_s3_bucket_lifecycle_configuration.static_assets). Two rules today:

Rule Scope Action
abort-incomplete-multipart All prefixes Abort incomplete multipart uploads after 24h. Abandoned multiparts otherwise live forever and silently inflate the bill.
moments-to-ia moments/ only Transition to STANDARD_IA after 30d. Chronological discovery falls off fast; older moments are read rarely (passport / explicit profile scroll) so IA's per-GB retrieval cost is amortised against the cheaper storage.

Anything else stays STANDARD indefinitely. For curated assets (stamps/, stamps/bespoke/, curios/) that's the right answer — they're permanent. For user assets without a transition (avatars, chat-images, event-covers, user-stamps), the app's cascade-delete is the cost control; an IA transition would just add retrieval cost without saving much given the volumes.

Versioning

S3 object versioning is disabled (no aws_s3_bucket_versioning resource in s3.tf). The asset model is append-only with filename-based versioning: when the backend uploads a new avatar or chat image, it writes to a new key derived from the content hash or timestamp. Previous versions are explicitly deleted by the app's cascade-delete path — see the Writes and deletes section.

CORS

Verify the current CORS configuration directly in s3.tf before relying on cross-origin reads. At the time of writing, no aws_s3_bucket_cors_configuration resource is present — assets are served exclusively through the CloudFront domain on the same TLD (tomoda.life), so cross-origin reads from app.tomoda.life to assets.tomoda.life are treated as cross-subdomain rather than cross-origin in most asset use cases. If a future feature requires explicit CORS headers (e.g. fetching JSON via XHR with credentials), they should be added at the CloudFront response-headers-policy layer or as an S3 CORS rule, but neither exists today.

Writes and deletes

The backend writes and deletes to S3 using the tomoda-uploader-{env} IAM user — see IAM for the credential surface and how External Secrets Operator delivers the access key into the cluster.

Policy actions granted in iam_uploader.tf:

  • s3:PutObject / s3:PutObjectAcl — uploads (multipart-aware via s3/manager.Uploader).
  • s3:DeleteObject — cascade-delete from the app. Hard-deleted moments / chat-messages / event-covers / un-tagged stamp mints enqueue an asynq cleanup:s3_delete task that calls this.
  • s3:ListBucket — used only by the weekly orphan-sweep cron (cleanup:s3_orphan_sweep) to reconcile S3 against the DB and re-queue deletes for objects with no DB row.

The matching AssetService + cleanup handlers live in the tomoda repo. If you see objects in S3 with no matching DB row, check the asynq archived queue in the admin DLQ inspector — terminal failures land there for manual replay.