AWS OpenTofu #103: Build a Proper S3 Bucket with Security Defaults
Fortify Amazon S3 using modern OpenTofu resource decomposition. Configure versioning, default AES-256 encryption, bucket ownership controls, and the four golden public access block rules.
AWS + OpenTofu Level 100: Infrastructure as Code from Zero
Part of the CLI-first comprehensive learning path from foundational setup to production cloud architectures.
Search security news headlines for βAWS data leakβ, and you will find an endless list of breaches: voter registration records exposed, medical records dumped, banking credentials left public to the internet.
In almost every single case, the culprit was not an elite zero-day exploit or an Advanced Persistent Threat (APT). It was a simple, bare Amazon S3 bucket deployed with default settings, missing an access block, or granted a loose Access Control List (ACL).
In Lab 102, we provisioned our first raw S3 bucket:
resource "aws_s3_bucket" "primary_storage" {
bucket = "tofu-lab-prawira-102-dev"
}
While that code successfully creates cloud storage, in a real-world enterprise environment, that bare bucket is a security and compliance liability:
- Accidental Deletion & Ransomware Risk: Without versioning, if an engineer runs
aws s3 rmor a compromised API key executes a mass deletion, your data is gone instantly and irreversibly. - Access Control List (ACL) Pitfalls: Legacy S3 allows object-level ACLs, meaning an external AWS account uploading a file can retain ownership of that file, preventing your own bucket owner account from reading it.
- Public Exposure: Without an explicit Block Public Access guardrail, an errant bucket policy or mistaken CLI command can open your bucket to anonymous internet traffic.
- Data-at-Rest Governance: While AWS now defaults to basic encryption, compliance standards (SOC 2, HIPAA, ISO 27001) require codified, audited encryption rules with explicit algorithm declarations.
In this lab, we will harden Amazon S3 using modern, enterprise-grade OpenTofu patterns. You will learn how the AWS Provider decomposes complex resources into modular child blocks, implement Defense-in-Depth, and verify your security posture using the live AWS CLI.
π― What You Will Learn
| AWS Security Controls | OpenTofu Modular Architecture | Engineering Best Practices |
|---|---|---|
| β’ The 4 Golden S3 Public Access Block rules | β’ Modern AWS Provider v4/v5 resource decomposition | β’ Security-by-Default as code |
| β’ Server-Side Encryption (SSE-S3 vs SSE-KMS) | β’ Resource referencing & implicit dependency DAGs | β’ Disabling legacy ACLs (BucketOwnerEnforced) |
| β’ S3 Versioning mechanics & Delete Markers | β’ Separating base storage from security policy layers | β’ Testing accidental deletion recovery |
| β’ S3 Object Ownership controls | β’ Safe teardown of versioned buckets (force_destroy) | β’ Auditing security flags via aws s3api |
π° Estimated Lab Cost
| Resource | Configuration | Free Tier Cost | Standard Account (Past Free Tier) |
|---|---|---|---|
| Amazon S3 Hardened Bucket | Standard Tier (<1 hour) | $0.00 | $0.00 (Negligible bytes) |
| S3 Versioning Storage | 2 small text files (~1 KB) | $0.00 | <$0.00001 |
| AWS API Calls | PutEncryption, PutPublicAccessBlock (~10 calls) | $0.00 | <$0.00005 |
| Server-Side Encryption (SSE-S3) | Default AES-256 | $0.00 | $0.00 (Included Free) |
| Total Estimated Cost | Complete Hands-On Session | $0.00 | < $0.01 (essentially $0.00) |
[!NOTE] No Free Tier? No Problem: Testing versioning with a few kilobytes of text files costs a fraction of a cent. SSE-S3 encryption is included at no extra charge by AWS. When torn down promptly with
tofu destroy, total charges will be $0.00.
π‘οΈ Architecture: The S3 Security Perimeter
Before writing code, letβs visualize the multi-layered security perimeter we are constructing:
+-------------------------------------------------------------------------------+
| AMAZON S3 SECURITY PERIMETER |
| |
| +-----------------------------------------------------------------------+ |
| | LAYER 1: S3 Public Access Block | |
| | - BlockPublicAcls = true | |
| | - BlockPublicPolicy = true | |
| | - IgnorePublicAcls = true | |
| | - RestrictPublicBuckets = true | |
| +-----------------------------------------------------------------------+ |
| β |
| βΌ |
| +-----------------------------------------------------------------------+ |
| | LAYER 2: Object Ownership Controls | |
| | - Rule: "BucketOwnerEnforced" (Legacy ACLs 100% Disabled) | |
| +-----------------------------------------------------------------------+ |
| β |
| βΌ |
| +-----------------------------------------------------------------------+ |
| | LAYER 3: Server-Side Encryption at Rest (SSE-S3) | |
| | - Algorithm: AES-256 | |
| +-----------------------------------------------------------------------+ |
| β |
| βΌ |
| +-----------------------------------------------------------------------+ |
| | LAYER 4: Object Versioning & Resilience | |
| | - Status: Enabled (Preserves deleted/overwritten objects) | |
| +-----------------------------------------------------------------------+ |
| β |
| βΌ |
| [ Protected Object Data Vault ] |
+-------------------------------------------------------------------------------+
π§© The OpenTofu Dependency Graph (DAG)
In older Terraform / OpenTofu tutorials (prior to AWS Provider v4.0), S3 bucket configurations were written as monolithic nested inline blocks inside aws_s3_bucket.
Today, the AWS Provider uses Resource Decomposition. Security configurations are declared as separate, focused child resources that attach to the parent bucket via its unique identifier (bucket = aws_s3_bucket.app_vault.id).
aws_s3_bucket.app_vault (Base Storage Primitive)
β
βββββββββββββββββββββββββββΌββββββββββββββββββββββββββ
βΌ βΌ βΌ
aws_s3_bucket_ownership_controls aws_s3_bucket_versioning aws_s3_bucket_server_side_encryption
β
βΌ
aws_s3_bucket_public_access_block
When OpenTofu evaluates this code, it builds a Directed Acyclic Graph (DAG):
- OpenTofu creates the parent
aws_s3_bucket. - It discovers that the other four resources reference
aws_s3_bucket.app_vault.id. - OpenTofu establishes an implicit dependency: it provisions the parent bucket first, and then configures the encryption, versioning, ownership, and public access blocks in parallel or correct sequence.
π Step 1: Set Up Lab Directory
Following our root repository pattern established in Lab 102, navigate to your ~/opentofu-labs repository and create the directory for Lab 103:
cd ~/opentofu-labs
mkdir -p lab-103-secure-s3
cd lab-103-secure-s3
Because our root .gitignore is already in place at ~/opentofu-labs/.gitignore, any state files or caches generated inside lab-103-secure-s3/ are automatically protected.
βοΈ Step 2: Configure Providers (providers.tf)
Create providers.tf:
terraform {
required_version = ">= 1.6.0"
required_providers {
aws = {
source = "hashicorp/aws"
version = "~> 5.40"
}
}
}
provider "aws" {
region = "ap-southeast-1"
default_tags {
tags = {
Project = "OpenTofu-Level-100"
Environment = "Production"
ManagedBy = "OpenTofu"
LabPart = "103"
}
}
}
π‘οΈ Step 3: Declare the Base Bucket & Security Child Blocks (main.tf)
Create main.tf and add each security layer progressively:
# 1. Base S3 Bucket Resource
resource "aws_s3_bucket" "app_vault" {
# Remember: S3 bucket names must be globally unique!
bucket = "tofu-secure-vault-prawira-103"
# force_destroy allows OpenTofu to delete the bucket even if it contains
# versioned objects during 'tofu destroy'. In strict production, leave this false.
force_destroy = true
tags = {
DataClassification = "Confidential"
ComplianceScope = "SOC2"
}
}
# 2. S3 Object Ownership Controls (Disabling Legacy ACLs)
resource "aws_s3_bucket_ownership_controls" "app_vault" {
bucket = aws_s3_bucket.app_vault.id
rule {
object_ownership = "BucketOwnerEnforced"
}
}
# 3. S3 Public Access Block (The 4 Golden Guardrails)
resource "aws_s3_bucket_public_access_block" "app_vault" {
bucket = aws_s3_bucket.app_vault.id
# Block new public ACLs and uploading public objects
block_public_acls = true
# Retroactively ignore all existing public ACLs
ignore_public_acls = true
# Block new public bucket policies
block_public_policy = true
# Restrict access to only bucket owners and AWS services
restrict_public_buckets = true
# Ensure ownership controls are applied before or alongside access block
depends_on = [aws_s3_bucket_ownership_controls.app_vault]
}
# 4. S3 Bucket Versioning Configuration
resource "aws_s3_bucket_versioning" "app_vault" {
bucket = aws_s3_bucket.app_vault.id
versioning_configuration {
status = "Enabled"
}
}
# 5. S3 Server-Side Encryption Configuration (SSE-S3 AES-256)
resource "aws_s3_bucket_server_side_encryption_configuration" "app_vault" {
bucket = aws_s3_bucket.app_vault.id
rule {
apply_server_side_encryption_by_default {
sse_algorithm = "AES256"
}
# Bucket Key reduces AWS KMS request costs by up to 99% if KMS is used
bucket_key_enabled = true
}
}
π Deep Dive: Understanding the 4 Golden Guardrails
Why does aws_s3_bucket_public_access_block have four separate flags? Each protects against a distinct vulnerability vector:
| Setting | What It Does | Why It Matters |
|---|---|---|
block_public_acls = true | Rejects any incoming PUT request if it contains a public ACL (e.g. x-amz-acl: public-read). | Prevents engineers or scripts from accidentally uploading public objects. |
ignore_public_acls = true | Causes S3 to ignore all existing ACLs on every object in the bucket. | Neutralizes any legacy public ACLs without having to re-upload files. |
block_public_policy = true | Rejects bucket policy updates that allow public/anonymous access (Principal: "*"). | Prevents anyone from attaching an overly permissive bucket policy. |
restrict_public_buckets = true | Restricts access to a bucket with a public policy to only AWS service principals and authorized users. | Second line of defense if an external cross-account policy exists. |
[!IMPORTANT] In production environments, all four flags should be set to
truefor 99.9% of internal buckets. The only rare exception is a public static website or public asset CDN origin.
π Step 4: Initialize & Validate
Initialize your OpenTofu working directory:
tofu init
Format and validate your HCL configuration:
tofu fmt
tofu validate
Output:
Success! The configuration is valid.
π Step 5: Preview the Execution Plan (tofu plan)
Run:
tofu plan
Examine the plan output carefully. Notice how OpenTofu recognizes five separate resources to create:
OpenTofu will perform the following actions:
# aws_s3_bucket.app_vault will be created
+ resource "aws_s3_bucket" "app_vault" { ... }
# aws_s3_bucket_ownership_controls.app_vault will be created
+ resource "aws_s3_bucket_ownership_controls" "app_vault" {
+ bucket = (known after apply)
+ rule {
+ object_ownership = "BucketOwnerEnforced"
}
}
# aws_s3_bucket_public_access_block.app_vault will be created
+ resource "aws_s3_bucket_public_access_block" "app_vault" {
+ block_public_acls = true
+ block_public_policy = true
+ bucket = (known after apply)
+ ignore_public_acls = true
+ restrict_public_buckets = true
}
# aws_s3_bucket_server_side_encryption_configuration.app_vault will be created
+ resource "aws_s3_bucket_server_side_encryption_configuration" "app_vault" { ... }
# aws_s3_bucket_versioning.app_vault will be created
+ resource "aws_s3_bucket_versioning" "app_vault" {
+ versioning_configuration {
+ status = "Enabled"
}
}
Plan: 5 to add, 0 to change, 0 to destroy.
Notice bucket = (known after apply). OpenTofu knows that the child resources require the bucketβs ID, which will only exist after the bucket is provisioned in AWS.
β‘ Step 6: Provision the Hardened Storage (tofu apply)
Apply the changes:
tofu apply
Type yes when prompted.
aws_s3_bucket.app_vault: Creating...
aws_s3_bucket.app_vault: Creation complete after 2s [id=tofu-secure-vault-prawira-103]
aws_s3_bucket_versioning.app_vault: Creating...
aws_s3_bucket_ownership_controls.app_vault: Creating...
aws_s3_bucket_server_side_encryption_configuration.app_vault: Creating...
aws_s3_bucket_ownership_controls.app_vault: Creation complete after 1s
aws_s3_bucket_versioning.app_vault: Creation complete after 1s
aws_s3_bucket_server_side_encryption_configuration.app_vault: Creation complete after 1s
aws_s3_bucket_public_access_block.app_vault: Creating...
aws_s3_bucket_public_access_block.app_vault: Creation complete after 1s
Apply complete! Resources: 5 added, 0 changed, 0 destroyed.
In approximately 4 seconds, OpenTofu orchestrated five sequenced API calls to construct your secured storage vault.
π§ͺ Step 7: Live Security Audit with AWS CLI
Never trust configuration blindlyβalways verify live infrastructure at the API layer.
Set a temporary shell variable for your bucket name:
BUCKET_NAME="tofu-secure-vault-prawira-103"
1. Audit Public Access Block
aws s3api get-public-access-block --bucket $BUCKET_NAME --output json
Output:
{
"PublicAccessBlockConfiguration": {
"BlockPublicAcls": true,
"IgnorePublicAcls": true,
"BlockPublicPolicy": true,
"RestrictPublicBuckets": true
}
}
All four security flags are confirmed active.
2. Audit Server-Side Encryption
aws s3api get-bucket-encryption --bucket $BUCKET_NAME --output json
Output:
{
"ServerSideEncryptionConfiguration": {
"Rules": [
{
"ApplyServerSideEncryptionByDefault": {
"SSEAlgorithm": "AES256"
},
"BucketKeyEnabled": true
}
]
}
}
Every byte written to this bucket is automatically encrypted at rest using AES-256 before being written to disk.
3. Audit Bucket Versioning
aws s3api get-bucket-versioning --bucket $BUCKET_NAME --output json
Output:
{
"Status": "Enabled"
}
4. Audit Object Ownership Controls
aws s3api get-bucket-ownership-controls --bucket $BUCKET_NAME --output json
Output:
{
"OwnershipControls": {
"Rules": [
{
"ObjectOwnership": "BucketOwnerEnforced"
}
]
}
}
BucketOwnerEnforced confirms that legacy S3 ACLs are completely disabled. All objects uploaded by any identity are owned exclusively by your account.
π Step 8: Hands-On Test β How S3 Versioning Protects Your Data
Letβs witness S3 Versioning in action by simulating an accidental file overwrite and deletion.
1. Upload Version 1 of a Document
echo "Company Secret Recipe - Version 1.0 (Initial Draft)" > secret.txt
aws s3 cp secret.txt s3://$BUCKET_NAME/secret.txt
2. Overwrite with Version 2
echo "Company Secret Recipe - Version 2.0 (Accidentally Overwritten)" > secret.txt
aws s3 cp secret.txt s3://$BUCKET_NAME/secret.txt
3. List All Versions in the Bucket
aws s3api list-object-versions \
--bucket $BUCKET_NAME \
--prefix secret.txt \
--query "Versions[].{VersionId:VersionId, IsLatest:IsLatest, LastModified:LastModified}" \
--output table
Output:
-----------------------------------------------------------------------------------------
| ListObjectVersions |
+----------+----------------------------------+-----------------------------------------+
| IsLatest | LastModified | VersionId |
+----------+----------------------------------+-----------------------------------------+
| True | 2026-09-15T09:40:12.000Z | 3/Lal4Bq81cKx.q8gNEXAMPLEVERSION2... |
| False | 2026-09-15T09:39:55.000Z | N9eKl01zA7bPx.v3aBEXAMPLEVERSION1... |
+----------+----------------------------------+-----------------------------------------+
Even though the file was overwritten, S3 preserved Version 1.0 as a non-current version!
4. Simulate Accidental Deletion
Now delete the file:
aws s3 rm s3://$BUCKET_NAME/secret.txt
If you try to fetch the object:
aws s3 cp s3://$BUCKET_NAME/secret.txt downloaded.txt
# fatal error: An error occurred (404) when calling the GetObject operation: Key "secret.txt" does not exist
Is your data lost? No. Check the version list again:
aws s3api list-object-versions \
--bucket $BUCKET_NAME \
--prefix secret.txt \
--output json
S3 did not delete your data. Instead, it placed a Delete Marker on top of the version stack.
+-----------------------------------------------------------------+
| S3 VERSION STACK FOR 'secret.txt' |
| |
| [ Top: Delete Marker ] <-- Current State (Returns 404 to apps) |
| [ Middle: Version 2.0 ] <-- Retained & recoverable |
| [ Bottom: Version 1.0 ] <-- Retained & recoverable |
+-----------------------------------------------------------------+
To recover your data, simply delete the Delete Marker version:
DELETE_MARKER_ID=$(aws s3api list-object-versions \
--bucket $BUCKET_NAME \
--prefix secret.txt \
--query "DeleteMarkers[0].VersionId" \
--output text)
aws s3api delete-object \
--bucket $BUCKET_NAME \
--key secret.txt \
--version-id $DELETE_MARKER_ID
Now download the file again:
aws s3 cp s3://$BUCKET_NAME/secret.txt downloaded.txt
cat downloaded.txt
# Output: Company Secret Recipe - Version 2.0 (Accidentally Overwritten)
The file is restored. That is the power of codified S3 versioning.
π§Ή Step 9: Clean Teardown (tofu destroy)
Before destroying, clean up your local test files:
rm -f secret.txt downloaded.txt
Now tear down the infrastructure:
tofu destroy
Notice the plan:
Plan: 0 to add, 0 to change, 5 to destroy.
Do you really want to destroy all resources?
Enter a value: yes
aws_s3_bucket_public_access_block.app_vault: Destroying...
aws_s3_bucket_ownership_controls.app_vault: Destroying...
aws_s3_bucket_versioning.app_vault: Destroying...
aws_s3_bucket_server_side_encryption_configuration.app_vault: Destroying...
...
aws_s3_bucket.app_vault: Destroying...
aws_s3_bucket.app_vault: Destruction complete after 2s
Destroy complete! Resources: 5 destroyed.
Why force_destroy = true Was Critical Here
By default, AWS API refuses to delete an S3 bucket if it contains objects or non-current versions, throwing a BucketNotEmpty error. Setting force_destroy = true on aws_s3_bucket instructs OpenTofu to automatically iterate through and empty all object versions and delete markers before issuing the DeleteBucket API call.
π οΈ Common Troubleshooting & Gotchas
1. Error: deleting S3 Bucket: BucketNotEmpty
- Cause: You uploaded files to the bucket and
force_destroy = false(or omitted). - Fix: Either empty all versions using the CLI or add
force_destroy = trueinmain.tf, runtofu apply, and then runtofu destroy.
2. Error: OperationAborted: A conflicting conditional operation is currently in progress
- Cause: In rare cases, running multiple child resource updates on S3 simultaneously can trigger transient eventual consistency conflicts inside AWSβs S3 metadata store.
- Fix: Adding explicit
depends_onblocks (such as having the public access block depend on ownership controls) guarantees strict sequential execution.
3. Legacy Monolithic Syntax in Older Guides
- Warning: If you see tutorials using inline blocks like:
Do not use this pattern. In AWS Provider v4+, inline blocks trigger continuous drift and deprecation warnings. Always use standalone child resources (# β AVOID: Deprecated monolithic syntax resource "aws_s3_bucket" "example" { bucket = "my-bucket" versioning { enabled = true } server_side_encryption_configuration { ... } }aws_s3_bucket_versioning, etc.).
ποΈ Hands-On Challenge
Before proceeding to Lab 104:
- Configure S3 Lifecycle Expiration:
Research the
aws_s3_bucket_lifecycle_configurationresource in the AWS Provider documentation. - Add a rule to
main.tfthat automatically transitions non-current versions to Standard-IA (Infrequent Access) after 30 days and permanently expires non-current versions after 90 days. - Run
tofu planto see how OpenTofu integrates lifecycle rules into the existing bucket architecture.
π Summary & What You Learned
In this lab, you upgraded from a naive S3 bucket to a production-grade secure storage vault:
- Defense-in-Depth: Implemented a four-tier security perimeter covering Public Access Blocks, Object Ownership, Server-Side Encryption, and Object Versioning.
- Resource Decomposition: Mastered modern OpenTofu architecture by decoupling the core storage resource from its modular security configurations.
- Dependency Graphs: Observed how OpenTofu computes implicit dependencies via attribute references (
aws_s3_bucket.app_vault.id). - Resilience in Practice: Proved how S3 Versioning and Delete Markers prevent accidental deletions and facilitate rapid incident recovery.
- API Verification: Used
aws s3apicommands to independently audit every security control against live AWS endpoints.
π Whatβs Next? (Lab 104)
Notice how we hardcoded the bucket name "tofu-secure-vault-prawira-103" and the region "ap-southeast-1" directly in our files? In production, hardcoded values make code unshareable and impossible to promote across environments.
In [AWS OpenTofu #104: Variables, Outputs & Locals], we will:
- Parameterize our infrastructure with input variables and validation rules.
- Compute dynamic naming using
locals. - Expose resource attributes cleanly to external systems using
outputs.