Cloud 25 min read

AWS OpenTofu #102: Your First OpenTofu Project

Set up a clean OpenTofu workspace, configure the AWS provider with default resource tagging, provision your first S3 bucket, and master the complete init, plan, apply, and destroy lifecycle.

Mohammad Rizky Prawira portrait
Mohammad Rizky Prawira
terminal Series Guide • Part 2 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.

In Lab 101, we explored the architectural mechanics of AWS: how every user action is an authenticated HTTPS request, how regions and availability zones provide resilience, and why imperative scripts fail to scale.

Now it is time to write code and watch cloud infrastructure materialize.

Consider a standard task in any development sprint: “Create an Amazon S3 storage bucket to hold customer profile uploads.”

In the AWS Management Console, this task presents a 5-step wizard. You choose a name, select a region, configure Object Ownership, enable bucket versioning, toggle default server-side encryption, configure bucket keys, and attach billing tags. If you need to reproduce this bucket across staging, UAT, and production environments, you must repeat that exact sequence of 23 clicks three times. If you miss one checkbox—such as omitting the Environment = Production tag or leaving default encryption unconfigured—your environments drift, compliance audits fail, and your FinOps team cannot allocate cloud costs.

In this lab, you will build your very first OpenTofu project from scratch. You will learn the anatomy of HashiCorp Configuration Language (HCL), configure the AWS provider with centralized resource tags, provision a real Amazon S3 bucket, inspect OpenTofu’s internal state tracking engine, and tear it down cleanly with zero leftover costs.


🎯 What You Will Learn

AWS PrimitivesOpenTofu & IaC EngineEngineering Best Practices
• Amazon S3 global naming rules• HCL block syntax, arguments & attributes• Clean project layout & directory separation
• S3 regional placement & data storage• Provider requirements & lockfiles (.terraform.lock.hcl)• Strict .gitignore rules (never commit state)
• Provider-level default resource tags• State tracking mechanics (terraform.tfstate)• Validation & styling with tofu fmt & tofu validate
• CLI verification via aws s3api• The 4-phase lifecycle (init, plan, apply, destroy)• Idempotency: how OpenTofu manages drift

💰 Estimated Lab Cost

ResourceConfigurationFree Tier CostStandard Account (Past Free Tier)
Amazon S3 Bucket StorageEmpty bucket (<1 hour)$0.00$0.00 ($0.023/GB-mo prorated)
AWS API Requests3 API calls (HeadBucket, CreateBucket, DeleteBucket)$0.00<$0.00002 ($0.005 per 1,000 PUTs)
OpenTofu CLILocal binary execution$0.00$0.00 (Free & Open Source)
Total Estimated CostComplete Hands-On Session$0.00< $0.01 (essentially $0.00)

[!NOTE] No Free Tier? No Problem: S3 pricing is based on stored data and API volume. Because this lab provisions an empty bucket that is deleted within minutes, standard on-demand accounts incur less than $0.0001 (effectively rounded to $0.00 on your AWS invoice).


📋 Prerequisites

Before starting, confirm your environment:

  1. AWS CLI v2 configured: Verified with aws sts get-caller-identity.
  2. OpenTofu installed: Verified with tofu --version.
    • On macOS: brew install opentofu
    • On Linux: Available via official deb/rpm repositories (curl -fsSL https://get.opentofu.org/install-opentofu.sh | sh)
    • On Windows: choco install opentofu or winget install opentofu
  3. A code editor (e.g., VS Code with the OpenTofu / Terraform extension installed).

🏗️ Architecture & Project Directory Layout

Rather than treating each lab as a disconnected folder, we structure our workspace as a single root repository for the entire 15-lab curriculum. The .gitignore lives at the root to protect all current and future lab directories:

opentofu-labs/                   # Root repository for the entire learning path
├── .gitignore                   # Root gitignore protecting all lab subfolders
└── lab-102-first-project/       # Lab 102 workspace
    ├── providers.tf             # OpenTofu engine & AWS provider configuration
    ├── main.tf                  # Target infrastructure declarations (S3 bucket)
    ├── .terraform/              # (Generated by tofu init) Cached AWS provider plugin
    ├── .terraform.lock.hcl      # (Generated by tofu init) Cryptographic checksums of plugins
    └── terraform.tfstate        # (Generated by tofu apply) OpenTofu's local database of truth
+-------------------------------------------------------------------------------+
| LOCAL WORKSTATION (opentofu-labs/lab-102-first-project)                       |
|                                                                               |
|   [ providers.tf ] + [ main.tf ]                                              |
|            |                                                                  |
|            v                                                                  |
|   [ OpenTofu Engine (tofu) ] <=======> [ terraform.tfstate ]                  |
+-------------------------------------------------------------------------------+
             |
             | Authenticated HTTPS API Calls (SigV4)
             v
+-------------------------------------------------------------------------------+
| AWS REGION (e.g. ap-southeast-1)                                              |
|                                                                               |
|   +-----------------------------------------------------------------------+   |
|   | Amazon S3 (Simple Storage Service)                                    |   |
|   |   Bucket: tofu-lab-first-bucket-<your-unique-suffix>                  |   |
|   |   Default Tags: Environment = "Development", ManagedBy = "OpenTofu"   |   |
|   +-----------------------------------------------------------------------+   |
+-------------------------------------------------------------------------------+

🛠️ Step 1: Initialize Root Workspace & Configure Root .gitignore

First, create the root repository directory and initialize Git:

mkdir -p ~/opentofu-labs
cd ~/opentofu-labs
git init

Why a Root .gitignore is Non-Negotiable

Before writing any infrastructure code, you must configure version control exclusions at the repository root. OpenTofu generates files across your lab directories that must never be committed to Git:

  1. **/.terraform/: Downloaded provider binaries (several hundred megabytes).
  2. **/*.tfstate and **/*.tfstate.backup: State files store live resource metadata, private IPs, IDs, and potentially plain-text secrets or passwords.
  3. **/*.tfvars: Files containing sensitive variables or credentials.

Create a root .gitignore file that recursively guards all subdirectories:

cat <<'EOF' > .gitignore
# OpenTofu local directories in any lab subfolder
**/.terraform/
**/.terraform.lock.hcl.tmp

# State files (NEVER commit state to Git)
**/*.tfstate
**/*.tfstate.*
**/*.tfstate.backup
**/crash.log
**/crash.*.log

# Variables and secrets
**/*.tfvars
**/*.tfvars.json
**/override.tf
**/override.tf.json
**/*_override.tf
**/*_override.tf.json

# CLI configuration credentials
.terraformrc
terraform.rc
EOF

Commit your root baseline:

git add .gitignore
git commit -m "chore: initialize opentofu-labs repository with root gitignore"

Now, create the dedicated directory for Lab 102 and navigate into it:

mkdir -p lab-102-first-project
cd lab-102-first-project

[!CAUTION] Committing terraform.tfstate to a repository—even a private one—is one of the most common vectors for cloud security breaches. Placing recursive ignore rules (**/*.tfstate) in your root .gitignore ensures every future lab in this series is automatically protected from accidental commits.


📦 Step 2: Configure Providers (providers.tf)

In OpenTofu, a Provider is a plugin that understands how to communicate with a specific cloud or service API (AWS, Google Cloud, Azure, GitHub, Kubernetes, Cloudflare, etc.).

OpenTofu itself does not know what an EC2 instance or S3 bucket is. The OpenTofu engine only knows how to build graphs and manage state; it delegates all cloud-specific API operations to the AWS Provider.

Create a file named 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 automatically apply to ALL resources created by this provider!
  default_tags {
    tags = {
      Project     = "OpenTofu-Level-100"
      Environment = "Development"
      ManagedBy   = "OpenTofu"
      LabPart     = "102"
    }
  }
}

Deep Dive: Breaking Down the Configuration

  1. terraform { ... } Block: Configures settings for OpenTofu itself.

    • required_version: Specifies which versions of OpenTofu can run this code.
    • required_providers: Specifies where to download the provider plugin and what version constraint to use.
    • ~> 5.40: The pessimistic constraint operator (“tilde-arrow”). It allows patch upgrades (e.g., 5.40.1, 5.41.0) but prevents breaking major upgrades (e.g., 6.0.0).
  2. provider "aws" { ... } Block: Configures the target cloud environment.

    • region: Tells OpenTofu which AWS regional endpoint to query. Change "ap-southeast-1" to your preferred region (such as "us-east-1" or "eu-central-1").
    • default_tags: A powerful feature of the AWS provider. Any resource created in this project automatically inherits these tags without needing to repeat them in every resource block.

🪣 Step 3: Declare the S3 Bucket (main.tf)

Now let’s declare our first cloud resource.

Amazon S3 Naming Rules

S3 bucket names share a single global namespace across all AWS customers worldwide:

  • Must be between 3 and 63 characters long.
  • Can only contain lowercase letters, numbers, and hyphens (-).
  • Cannot start or end with a hyphen.
  • Must be globally unique—if someone else on Earth owns my-app-bucket, your request will fail with BucketAlreadyExists.

To ensure uniqueness, append your initials and a random string to the bucket name.

Create main.tf:

resource "aws_s3_bucket" "primary_storage" {
  # NOTE: Replace 'prawira-lab-102' with your name/initials to ensure global uniqueness!
  bucket = "tofu-lab-prawira-102-dev"

  tags = {
    Purpose = "Lab102-Demonstration"
  }
}

Anatomy of an HCL Resource Block

Every resource declaration in HCL follows this exact structure:

resource "aws_s3_bucket" "primary_storage" {
# ───┬──  ──────┬──────  ───────┬───────
#    │          │               │
#    │          │               └── 3. Local Resource Identifier (Logical Name in Code)
#    │          └────────────────── 2. Resource Type (Defined by the AWS Provider)
#    └───────────────────────────── 1. Block Keyword (Tells OpenTofu this is a resource)

  bucket = "tofu-lab-prawira-102-dev" # Argument: Value passed to the AWS API
}
  • resource: The block type.
  • aws_s3_bucket: The type of resource defined by the AWS Provider.
  • primary_storage: The local name you choose. Other OpenTofu blocks use this identifier to reference this bucket (e.g., aws_s3_bucket.primary_storage.arn). This name never appears in the AWS console.
  • bucket: An argument specifying the physical name of the S3 bucket in AWS.

🚀 Step 4: Initialize the Working Directory (tofu init)

Open your terminal in ~/opentofu-labs/lab-102-first-project and run:

tofu init

What Happens Behind the Scenes?

Initializing the backend...

Initializing provider plugins...
- Finding hashicorp/aws versions matching "~> 5.40"...
- Installing hashicorp/aws v5.82.2...
- Installed hashicorp/aws v5.82.2 (signed by HashiCorp)

OpenTofu has created a lock file .terraform.lock.hcl to record the provider
selections it made above. Include this file in your version control repository
so that OpenTofu can guarantee to make the same selections by default when
you run "tofu init" in the future.

OpenTofu has been successfully initialized!

During initialization, OpenTofu:

  1. Reads providers.tf.
  2. Connects to the registry and downloads the hashicorp/aws binary plugin for your operating system and CPU architecture into .terraform/providers/.
  3. Creates .terraform.lock.hcl. This lockfile contains cryptographic sha256 checksums of the downloaded binary to guarantee that your colleagues and CI/CD pipelines run the exact same provider binary.

📐 Step 5: Format and Validate

Before planning changes, professional engineers always run two built-in sanity checks:

1. Format Code (tofu fmt)

HCL has canonical formatting rules (2-space indents, alignment of = signs). Run:

tofu fmt

If any files had improper indentation or spacing, OpenTofu will print their names and format them automatically.

2. Validate Syntax (tofu validate)

Run:

tofu validate

Output:

Success! The configuration is valid.

tofu validate performs internal consistency checks: it verifies that argument names exist on the declared resource types, checks variable types, and catches syntax errors before touching the network.


🔍 Step 6: Preview Changes with tofu plan

Run:

tofu plan

Output:

OpenTofu used the selected providers to generate the following execution plan. Resource actions are indicated with the following symbols:
  + create

OpenTofu will perform the following actions:

  # aws_s3_bucket.primary_storage will be created
  + resource "aws_s3_bucket" "primary_storage" {
      + acceleration_status         = (known after apply)
      + acl                         = (known after apply)
      + arn                         = (known after apply)
      + bucket                      = "tofu-lab-prawira-102-dev"
      + bucket_domain_name          = (known after apply)
      + bucket_regional_domain_name = (known after apply)
      + force_destroy               = false
      + hosted_zone_id              = (known after apply)
      + id                          = (known after apply)
      + object_lock_enabled         = (known after apply)
      + policy                      = (known after apply)
      + region                      = (known after apply)
      + request_payer               = (known after apply)
      + tags                        = {
          + "Purpose" = "Lab102-Demonstration"
        }
      + tags_all                    = {
          + "Environment" = "Development"
          + "LabPart"     = "102"
          + "ManagedBy"   = "OpenTofu"
          + "Project"     = "OpenTofu-Level-100"
          + "Purpose"     = "Lab102-Demonstration"
        }
      + website_domain              = (known after apply)
      + website_endpoint            = (known after apply)
    }

Plan: 1 to add, 0 to change, 0 to destroy.

Analyzing the Plan Output

  • + create: Indicates a new resource will be created.
  • (known after apply): Values that AWS generates dynamically when creating the resource (e.g., the bucket’s ARN, hosted zone ID, and regional domain name).
  • tags_all: Notice how OpenTofu automatically merged the resource-specific tag (Purpose = "Lab102-Demonstration") with the 4 default tags defined in providers.tf!
  • Plan: 1 to add, 0 to change, 0 to destroy: A concise summary of the proposed changes.

[!IMPORTANT] tofu plan is strictly a read-only dry run. No resources have been created yet. You can run tofu plan as many times as you like without altering your cloud environment.


⚡ Step 7: Apply the Changes (tofu apply)

Now instruct OpenTofu to provision the bucket:

tofu apply

OpenTofu will display the execution plan again and pause for explicit confirmation:

Do you want to perform these actions?
  OpenTofu will perform the actions described above.
  Only 'yes' will be accepted to approve.

  Enter a value: yes

Type yes and press Enter.

aws_s3_bucket.primary_storage: Creating...
aws_s3_bucket.primary_storage: Creation complete after 2s [id=tofu-lab-prawira-102-dev]

Apply complete! Resources: 1 added, 0 changed, 0 destroyed.

Within two seconds, OpenTofu made an authenticated HTTPS PUT request to the S3 regional API, verified the bucket was ready, and completed execution.


🧪 Step 8: Verify Against Live AWS

Let’s confirm that OpenTofu actually created the bucket by querying AWS with the AWS CLI:

1. List Buckets

aws s3 ls | grep tofu-lab

Output:

2026-09-15 08:30:15 tofu-lab-prawira-102-dev

2. Verify Tags

Check whether the default tags were correctly attached by AWS:

aws s3api get-bucket-tagging --bucket tofu-lab-prawira-102-dev --output table

Output:

--------------------------------------------------
|                GetBucketTagging                |
+----------------------+-------------------------+
|         Key          |          Value          |
+----------------------+-------------------------+
|  Environment         |  Development            |
|  LabPart             |  102                    |
|  ManagedBy           |  OpenTofu               |
|  Project             |  OpenTofu-Level-100     |
|  Purpose             |  Lab102-Demonstration   |
+----------------------+-------------------------+

Every single tag—including our centralized provider defaults—is attached to the live AWS resource.


🗄️ Step 9: Demystifying OpenTofu State (terraform.tfstate)

Look at your directory with ls -la. You will see a new file: terraform.tfstate.

What is the State File?

OpenTofu is not stateless. It needs a way to map your code declarations to the actual physical resources in AWS. That mapping is stored in terraform.tfstate.

Inspect the state file using the safe, read-only command:

tofu show

Output:

# aws_s3_bucket.primary_storage:
resource "aws_s3_bucket" "primary_storage" {
    arn                         = "arn:aws:s3:::tofu-lab-prawira-102-dev"
    bucket                      = "tofu-lab-prawira-102-dev"
    bucket_domain_name          = "tofu-lab-prawira-102-dev.s3.amazonaws.com"
    bucket_regional_domain_name = "tofu-lab-prawira-102-dev.s3.ap-southeast-1.amazonaws.com"
    id                          = "tofu-lab-prawira-102-dev"
    region                      = "ap-southeast-1"
    tags                        = {
        "Purpose" = "Lab102-Demonstration"
    }
    tags_all                    = {
        "Environment" = "Development"
        "LabPart"     = "102"
        "ManagedBy"   = "OpenTofu"
        "Project"     = "OpenTofu-Level-100"
        "Purpose"     = "Lab102-Demonstration"
    }
}

Now open terraform.tfstate in a text editor or view it with cat:

{
  "version": 4,
  "terraform_version": "1.9.0",
  "serial": 1,
  "lineage": "9b12d34a-89a1-4321-abcd-ef0123456789",
  "outputs": {},
  "resources": [
    {
      "mode": "managed",
      "type": "aws_s3_bucket",
      "name": "primary_storage",
      "provider": "provider[\"registry.opentofu.org/hashicorp/aws\"]",
      "instances": [
        {
          "schema_version": 0,
          "attributes": {
            "arn": "arn:aws:s3:::tofu-lab-prawira-102-dev",
            "bucket": "tofu-lab-prawira-102-dev",
            "id": "tofu-lab-prawira-102-dev"
          }
        }
      ]
    }
  ]
}

The Power of Idempotency

What happens if you run tofu apply again right now without changing any code?

tofu apply

Output:

aws_s3_bucket.primary_storage: Refreshing state... [id=tofu-lab-prawira-102-dev]

No changes. Your infrastructure matches the configuration.

OpenTofu has compared your real infrastructure against your configuration and found no differences, so no changes are needed.

This is idempotency. You can run tofu apply 100 times, and if nothing has changed in your code or in AWS, OpenTofu makes zero unnecessary changes.


🧹 Step 10: Clean Teardown (tofu destroy)

In cloud engineering, cleanup discipline is what separates professionals from engineers with massive surprise cloud bills.

When you finish a lab or deprecate an environment, you destroy the resources with a single command:

tofu destroy

Output:

OpenTofu will perform the following actions:

  # aws_s3_bucket.primary_storage will be destroyed
  - resource "aws_s3_bucket" "primary_storage" {
      - arn                         = "arn:aws:s3:::tofu-lab-prawira-102-dev" -> null
      - bucket                      = "tofu-lab-prawira-102-dev" -> null
      - id                          = "tofu-lab-prawira-102-dev" -> null
      - tags                        = {
          - "Purpose" = "Lab102-Demonstration"
        } -> null
      - tags_all                    = {
          - "Environment" = "Development"
          - "LabPart"     = "102"
          - "ManagedBy"   = "OpenTofu"
          - "Project"     = "OpenTofu-Level-100"
          - "Purpose"     = "Lab102-Demonstration"
        } -> null
    }

Plan: 0 to add, 0 to change, 1 to destroy.

Do you really want to destroy all resources?
  Only 'yes' will be accepted to approve.

  Enter a value: yes

Type yes and press Enter:

aws_s3_bucket.primary_storage: Destroying... [id=tofu-lab-prawira-102-dev]
aws_s3_bucket.primary_storage: Destruction complete after 1s

Destroy complete! Resources: 1 destroyed.

Verify Deletion:

aws s3 ls | grep tofu-lab

(Command returns empty—the bucket is completely gone!)


🛠️ Common Troubleshooting & Gotchas

1. BucketAlreadyExists: The requested bucket name is not available

  • Cause: Someone else in the world already owns that bucket name. Remember, S3 bucket names are globally shared across all AWS accounts.
  • Fix: Edit bucket = "..." in main.tf to include your name, company, or a random string (e.g., tofu-lab-alex-98234-storage).

2. Error: No valid credential sources found

  • Cause: OpenTofu cannot find your AWS credentials.
  • Fix: OpenTofu uses the standard AWS SDK credential resolution chain. Verify your AWS CLI is authenticated:
    aws sts get-caller-identity
    If you use a named profile:
    export AWS_PROFILE="your-profile-name"
    tofu plan

3. Error: Provider produced inconsistent final plan

  • Cause: Usually caused by a mismatch between arguments that AWS overrides automatically or a provider bug.
  • Fix: Re-run tofu init -upgrade to refresh the AWS provider plugin to the latest patch release.

🏋️ Hands-On Challenge

Before moving to Lab 103, test your skills:

  1. In-Place Modification:
    • Recreate the bucket with tofu apply -auto-approve.
    • In main.tf, add an extra tag to the bucket:
      tags = {
        Purpose    = "Lab102-Demonstration"
        CostCenter = "CC-8421"
      }
    • Run tofu plan. Notice how OpenTofu shows an in-place update (~) rather than destroying and recreating the bucket.
    • Run tofu apply and inspect the result with aws s3api get-bucket-tagging.
  2. Clean Up:
    • Run tofu destroy -auto-approve when complete.

📖 Summary & What You Learned

In this lab, you successfully built your first end-to-end Infrastructure as Code project:

  • Repository Architecture: Initialized a centralized opentofu-labs repository with a root .gitignore recursively guarding all subdirectories against committed state files or provider caches.
  • Provider & Resource Declarations: Configured centralized default_tags and declared an aws_s3_bucket in lab-102-first-project/.
  • Declarative Resource Creation: Declared an aws_s3_bucket using HCL block syntax.
  • The Core OpenTofu Lifecycle:
    • tofu init: Prepared plugins and initialized .terraform.lock.hcl.
    • tofu fmt & tofu validate: Ensured code quality and syntax validity.
    • tofu plan: Visualized proposed changes without touching live infrastructure.
    • tofu apply: Provisioned resources idempotently.
    • tofu destroy: Safely tore down resources with zero lingering costs.
  • State Management: Examined terraform.tfstate to understand how OpenTofu maps local code declarations to live AWS resource IDs.

🚀 What’s Next? (Lab 103)

Our current bucket is empty, unencrypted, and lacks security controls. In [AWS OpenTofu #103: Build a Proper S3 Bucket with Security Defaults], we will:

  • Enable S3 bucket versioning to protect against accidental deletions.
  • Configure server-side encryption with AWS-managed KMS keys.
  • Enforce public access blocks to guarantee zero data leaks.
  • Learn how to decompose complex resources into modern, modular OpenTofu resource blocks.

Related & Recommended Guides

Continue exploring related systems architectures and engineering field notes.