S3: Bucket Design and Best Practices for Managing tfstate
The previous chapter created the VPC and subnets, laying the groundwork for where the ALB, ECS, and RDS will live. This chapter isn't about a workload itself — it's about S3. S3 plays three roles in Tasuku: storing the attachments users upload, storing the ALB's access logs, and storing Terraform's state file.1 Of these, the bucket for ALB access logs will be set up in Chapter 6, where we build the ALB itself. This chapter covers the remaining two: the attachment bucket and S3 as the store for tfstate.
Chapter 2 started by treating Terraform's state as a local file.2 At the time, we noted that once collaboration among multiple people or safe storage of the state itself became necessary, we'd eventually move it to S3.3 This chapter also makes good on that promise.
5.1. S3 basics and bucket design principles
S3 (Simple Storage Service) is a storage service that saves files as units called objects. Objects live inside containers called buckets, and within a bucket, a string called a key uniquely identifies each object. Unlike a VPC, S3 has no structure divided into subnets — it's complete with just two layers: buckets and objects.
Bucket names carry a constraint that no other AWS resource has. Unlike a subnet name, which only needs to be unique within a single AWS account, a bucket name must be unique across every AWS account within the same partition.4 A partition is an AWS division such as the commercial regions (aws) or the China regions (aws-cn), and the ap-northeast-1 region Tasuku uses belongs to the commercial partition. Since you can't choose a name another account has already taken, the two buckets we build in this chapter ensure uniqueness by appending the AWS account ID to the end of the name.
S3 buckets have four design principles.
- Block Public Access: Four independent settings that prevent a bucket or its objects from being unintentionally made public.
- Encryption: Encrypts the objects you store.
- Versioning: Preserves older versions of objects that would otherwise be lost to an overwrite or deletion.
- Lifecycle: Automatically deletes objects after a set period, or moves them to a different storage class.
Both buckets we build in this chapter are configured according to these four principles, but how broadly each principle applies depends on the bucket's purpose. The next section lays out the role of each of the two buckets.
5.2. The two buckets this chapter builds
| Bucket | Purpose | Where it's created |
|---|---|---|
| tfstate bucket | Storage for Terraform's state file | bootstrap/ (newly created in this chapter) |
| Attachment bucket | Storage for files users upload | main/ (same location as the existing vpc.tf, etc.) |
The tfstate bucket is created in a location called bootstrap/, separate from main/. We'll explain why in section 5.4. The attachment bucket is added to main/ as s3.tf, the same as in previous chapters.
The bucket for ALB access logs isn't included in this table. Creating just the bucket at a stage when the ALB that will send it logs doesn't yet exist wouldn't let us verify it all the way through to the bucket policy design. It's the same approach as in Chapter 3, where we left the substance of the IAM roles in a preview table and deferred the implementation to the chapter that uses them. The ALB access log bucket and its bucket policy are covered in Chapter 6, where we build the ALB.
5.3. The chicken-and-egg problem of tfstate
Chapter 2 treated Terraform's state as a local file.2 That's enough for a personal test environment, but in real-world work where multiple people run apply, two problems arise. If one person runs apply while someone else runs it at the same time, the state's contents can conflict and become corrupted. On top of that, as long as the state stays a local file, there's no way to recover it if it's accidentally deleted.
There are broadly three standard options for sharing state.
The first is to keep operating with a local file and avoid sharing it through Git or similar tools. This isn't a problem for a personal development test environment, but it isn't suited to a situation where multiple people run apply at the same time.
The second is to let a managed service such as HCP Terraform handle storing and locking the state. You don't need to build the locking mechanism yourself, and you get a UI for browsing state and managing execution history as well, but it means depending on an additional service.
The third is backend "s3", which is self-contained within AWS. It places the state file in an S3 bucket, and S3 itself provides the locking mechanism as well. Since this series follows a policy of assembling Tasuku's configuration entirely within AWS, we choose this approach, which avoids adding any extra service.
Previously, locking the S3 backend required a separate DynamoDB table, but that approach is now deprecated.5 Terraform v1.10.0 introduced use_lockfile, which places a lock object in the S3 bucket itself,6 so locking now works without setting up a separate DynamoDB table. This chapter doesn't use DynamoDB — it achieves locking with use_lockfile alone.
5.4. The bootstrap configuration: building the state bucket as a separate module
To use backend "s3", the bucket that will hold the state must already exist. This creates a problem: if you try to create that bucket itself inside main/, which uses backend "s3", you'd end up creating the bucket that the backend references inside the very state that backend manages. Because the backend configuration demands the bucket before the bucket exists, the ordering doesn't line up.
This chapter avoids that problem by splitting the state bucket out into a separate root module called bootstrap/, apart from main/. bootstrap/ itself keeps operating with a local state, and the bucket created there is referenced from main/'s backend "s3". Since the only resources bootstrap/ holds are a single bucket and its attribute settings, the operational overhead of keeping it on local state stays small — that's the judgment call here.
bootstrap/'s structure consists of the same versions.tf, providers.tf, variables.tf, and locals.tf as main/, plus state.tf, where the bucket itself is written, and outputs.tf, which exposes the values that main/ references.
data "aws_caller_identity" "current" {}
resource "aws_s3_bucket" "state" {
bucket = "${local.name_prefix}-tfstate-${data.aws_caller_identity.current.account_id}"
tags = {
Name = "${local.name_prefix}-tfstate"
}
}
resource "aws_s3_bucket_versioning" "state" {
bucket = aws_s3_bucket.state.id
versioning_configuration {
status = "Enabled"
}
}
resource "aws_s3_bucket_public_access_block" "state" {
bucket = aws_s3_bucket.state.id
block_public_acls = true
block_public_policy = true
ignore_public_acls = true
restrict_public_buckets = true
}
resource "aws_s3_bucket_server_side_encryption_configuration" "state" {
bucket = aws_s3_bucket.state.id
rule {
apply_server_side_encryption_by_default {
sse_algorithm = "AES256"
}
}
}
Appending data.aws_caller_identity.current.account_id to the end of the bucket name is for the name uniqueness discussed in section 5.1. Versioning is enabled so that state can be recovered if a mistaken operation corrupts it.7
5.5. Migrating to backend "s3"
Once the bucket exists in bootstrap/, add a backend "s3" block to main/'s versions.tf.
terraform {
required_version = ">= 1.10"
required_providers {
aws = {
source = "hashicorp/aws"
version = "~> 6.53"
}
}
backend "s3" {
bucket = "tasuku-dev-tfstate-123456789012" # value obtained from bootstrap/'s terraform output
key = "main/terraform.tfstate"
region = "ap-northeast-1"
use_lockfile = true
encrypt = true
}
}
The backend "s3" block requires three arguments: bucket, key, and region.8 bucket is the name of the bucket created in section 5.4, and key is the path where the state file is placed within that bucket. Because the backend block's arguments can't reference other variables in main/, you write the value obtained from bootstrap/'s terraform output directly as a literal.
Once you've added this configuration and run terraform init, the -migrate-state option becomes available. It copies the existing local state to the new backend, and depending on the change, a prompt confirming the workspace migration may appear.9 If you want to skip the prompt, add -force-copy.
This is as far as we'll go in showing the migration steps as a sample for this article. The main/ in the terraform-verify project that verifies this series continues to operate on local state, to keep it consistent with the existing chapters.10 For that reason, this backend "s3" block isn't included in the code under verification.
5.6. Designing the attachment bucket
The attachment bucket applies all four principles listed in section 5.1.
The four Block Public Access settings each play a different role. BlockPublicAcls rejects the operation of granting public access via an ACL in the first place, and IgnorePublicAcls ignores any existing ACL even if one grants public access. BlockPublicPolicy rejects setting a bucket policy that allows public access, and RestrictPublicBuckets blocks access from anyone other than authorized principals within the account, even if the bucket policy is set to public.11 Since Tasuku's attachments are only ever delivered through the application, we enable all four.
As for encryption, S3 has automatically applied it to every new object since January 5, 2023.12 Even so, we explicitly declare aws_s3_bucket_server_side_encryption_configuration so that the setting is recorded in code as intentional, rather than relying on an implicit default.
Enabling versioning keeps older versions that would otherwise be lost to an overwrite or deletion. But keeping them around indefinitely lets storage costs pile up, so a lifecycle setting deletes old versions that are no longer needed after a set period. Likewise, fragments from multipart uploads that failed to complete stay billable if left alone, so they're automatically aborted after a set number of days.13 14
Attachments are designed to be uploaded directly from the browser to S3. Because the user's browser sends a PUT request to an S3 domain that differs from the application's origin, the bucket side needs to permit CORS in advance. The upload URL actually issued to the browser uses a mechanism called a presigned URL, which carries an expiration and is issued by the application's backend using the SDK.15 Issuing the presigned URL itself is outside Terraform's jurisdiction, so this chapter codifies only the CORS setting and treats issuing the presigned URL as something implemented on the application side.
5.7. Terraform implementation for the attachment bucket
Write the attachment bucket in main/s3.tf.
resource "aws_s3_bucket" "attachments" {
bucket = "${local.name_prefix}-attachments-${data.aws_caller_identity.current.account_id}"
tags = {
Name = "${local.name_prefix}-attachments"
}
}
resource "aws_s3_bucket_public_access_block" "attachments" {
bucket = aws_s3_bucket.attachments.id
block_public_acls = true
block_public_policy = true
ignore_public_acls = true
restrict_public_buckets = true
}
resource "aws_s3_bucket_versioning" "attachments" {
bucket = aws_s3_bucket.attachments.id
versioning_configuration {
status = "Enabled"
}
}
resource "aws_s3_bucket_server_side_encryption_configuration" "attachments" {
bucket = aws_s3_bucket.attachments.id
rule {
apply_server_side_encryption_by_default {
sse_algorithm = "AES256"
}
}
}
resource "aws_s3_bucket_lifecycle_configuration" "attachments" {
bucket = aws_s3_bucket.attachments.id
rule {
id = "abort-incomplete-multipart-upload"
status = "Enabled"
filter {}
abort_incomplete_multipart_upload {
days_after_initiation = 7
}
}
rule {
id = "expire-noncurrent-versions"
status = "Enabled"
filter {}
noncurrent_version_expiration {
noncurrent_days = 90
}
}
}
resource "aws_s3_bucket_cors_configuration" "attachments" {
bucket = aws_s3_bucket.attachments.id
cors_rule {
allowed_methods = ["GET", "PUT"]
allowed_origins = ["https://app.tasuku.example"]
allowed_headers = ["*"]
expose_headers = ["ETag"]
max_age_seconds = 3000
}
}
data.aws_caller_identity.current simply references the one already declared in Chapter 3's iam.tf. There's no need to declare the same data source twice within a single module.
Notice that the bucket itself, the access block, versioning, encryption, lifecycle, and CORS are each split out as independent resources. In earlier provider versions, you could write all of these inside a single aws_s3_bucket block, but that style is deprecated in the current provider, which recommends separating them into distinct resources instead.16 The allowed_origins value in cors_rule is a sample value for the application's origin. When you actually use this, replace it with your own application's origin.
5.8. A VPC endpoint for S3
The ECS Fargate tasks in the private-app subnet built in Chapter 4 route their outbound traffic through the NAT Gateway. Left as is, access to S3 would go through that NAT Gateway too. S3 offers a separate path called a Gateway-type VPC endpoint; using it lets you reach S3 directly through the VPC's route table, bypassing the NAT Gateway. There's no additional charge for using this path.17
resource "aws_vpc_endpoint" "s3" {
vpc_id = aws_vpc.main.id
service_name = "com.amazonaws.${var.region}.s3"
vpc_endpoint_type = "Gateway"
route_table_ids = [for k, v in aws_route_table.private_app : v.id]
tags = {
Name = "${local.name_prefix}-s3-gateway-endpoint"
}
}
The route tables to associate are only the two for private-app. In Chapter 4, private-data was intentionally left without a route to the internet.18 There's no reason to make an exception for S3 access either, so we don't associate the private-data route table.
At this point in the chapter, the ECS Fargate tasks — the workload that accesses S3 — don't exist yet. It's only once the ECS tasks exist in Chapter 8 that this endpoint's benefit of avoiding NAT Gateway data processing charges actually starts to take effect. We set up the endpoint here ahead of time so that this chapter, which creates the S3 buckets, also wraps up the network path at the same time.
5.9. Verification
In both bootstrap/ and main/, run terraform fmt -check -recursive -diff and terraform validate to confirm the syntax and provider schema line up.10 Because bootstrap/ is a root module independent from main/, run terraform init separately in each location as well.
terraform plan and apply require AWS credentials, so they're outside this series' verification scope. The migration to backend "s3" shown in section 5.5, the actual behavior of CORS, and how the VPC endpoint actually redirects traffic are all things that syntax verification alone can't confirm. If you want to try them, check starting with plan in your own AWS environment.
Summary
- An S3 bucket name must be globally unique within its partition; this chapter secured that uniqueness by appending the AWS account ID to the end of the name
- Moving tfstate to S3 requires first creating the bucket the backend references, and we resolved this chicken-and-egg problem by splitting it out into a separate
bootstrap/module - We achieved S3 backend locking with
use_lockfile, and don't use DynamoDB, the previous standard approach - For the attachment bucket, we configured five settings: Block Public Access, encryption, versioning, lifecycle, and CORS
- We set up a Gateway-type VPC endpoint for S3, though its benefit only starts to take effect in Chapter 8, once the ECS Fargate tasks exist
Next steps
- Chapter 6: ALB: Implementing the load balancer and the access log bucket
- Chapter 4: VPC: CIDR design and the 2-AZ subnet structure
- Series table of contents: The structure of all 12 chapters and how to read through them