Cloud 25 min read

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.

Mohammad Rizky Prawira portrait
Mohammad Rizky Prawira
terminal Series Guide β€’ Part 3 of 15

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:

  1. Accidental Deletion & Ransomware Risk: Without versioning, if an engineer runs aws s3 rm or a compromised API key executes a mass deletion, your data is gone instantly and irreversibly.
  2. 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.
  3. 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.
  4. 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 ControlsOpenTofu Modular ArchitectureEngineering 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

ResourceConfigurationFree Tier CostStandard Account (Past Free Tier)
Amazon S3 Hardened BucketStandard Tier (<1 hour)$0.00$0.00 (Negligible bytes)
S3 Versioning Storage2 small text files (~1 KB)$0.00<$0.00001
AWS API CallsPutEncryption, PutPublicAccessBlock (~10 calls)$0.00<$0.00005
Server-Side Encryption (SSE-S3)Default AES-256$0.00$0.00 (Included Free)
Total Estimated CostComplete 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):

  1. OpenTofu creates the parent aws_s3_bucket.
  2. It discovers that the other four resources reference aws_s3_bucket.app_vault.id.
  3. 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:

SettingWhat It DoesWhy It Matters
block_public_acls = trueRejects 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 = trueCauses 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 = trueRejects bucket policy updates that allow public/anonymous access (Principal: "*").Prevents anyone from attaching an overly permissive bucket policy.
restrict_public_buckets = trueRestricts 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 true for 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 = true in main.tf, run tofu apply, and then run tofu 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_on blocks (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:
    # ❌ AVOID: Deprecated monolithic syntax
    resource "aws_s3_bucket" "example" {
      bucket = "my-bucket"
      versioning {
        enabled = true
      }
      server_side_encryption_configuration { ... }
    }
    Do not use this pattern. In AWS Provider v4+, inline blocks trigger continuous drift and deprecation warnings. Always use standalone child resources (aws_s3_bucket_versioning, etc.).

πŸ‹οΈ Hands-On Challenge

Before proceeding to Lab 104:

  1. Configure S3 Lifecycle Expiration: Research the aws_s3_bucket_lifecycle_configuration resource in the AWS Provider documentation.
  2. Add a rule to main.tf that automatically transitions non-current versions to Standard-IA (Infrequent Access) after 30 days and permanently expires non-current versions after 90 days.
  3. Run tofu plan to 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 s3api commands 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.

Related & Recommended Guides

Continue exploring related systems architectures and engineering field notes.