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 ap-northeast-1, 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 https://assets-dev.tomoda.life
prod tomoda-assets-prod https://assets.tomoda.life

Bucket naming is derived in s3.tf:

locals {
  bucket_name = "${var.project_name}-assets-${var.environment}"
}

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.ap-northeast-1.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.

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. The IAM uploader policy is PutObject-only (no DeleteObject), so previous versions accumulate as distinct keys until an out-of-band cleanup runs.

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

The backend writes 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.