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.