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.
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 Primitives | OpenTofu & IaC Engine | Engineering 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
| Resource | Configuration | Free Tier Cost | Standard Account (Past Free Tier) |
|---|---|---|---|
| Amazon S3 Bucket Storage | Empty bucket (<1 hour) | $0.00 | $0.00 ($0.023/GB-mo prorated) |
| AWS API Requests | 3 API calls (HeadBucket, CreateBucket, DeleteBucket) | $0.00 | <$0.00002 ($0.005 per 1,000 PUTs) |
| OpenTofu CLI | Local binary execution | $0.00 | $0.00 (Free & Open Source) |
| Total Estimated Cost | Complete 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:
- AWS CLI v2 configured: Verified with
aws sts get-caller-identity. - 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 opentofuorwinget install opentofu
- On macOS:
- 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:
**/.terraform/: Downloaded provider binaries (several hundred megabytes).**/*.tfstateand**/*.tfstate.backup: State files store live resource metadata, private IPs, IDs, and potentially plain-text secrets or passwords.**/*.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.tfstateto 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.gitignoreensures 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
-
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).
-
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 withBucketAlreadyExists.
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:
- Reads
providers.tf. - Connects to the registry and downloads the
hashicorp/awsbinary plugin for your operating system and CPU architecture into.terraform/providers/. - 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 inproviders.tf!Plan: 1 to add, 0 to change, 0 to destroy: A concise summary of the proposed changes.
[!IMPORTANT]
tofu planis strictly a read-only dry run. No resources have been created yet. You can runtofu planas 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 = "..."inmain.tfto 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:
If you use a named profile:aws sts get-caller-identityexport 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 -upgradeto refresh the AWS provider plugin to the latest patch release.
🏋️ Hands-On Challenge
Before moving to Lab 103, test your skills:
- 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 applyand inspect the result withaws s3api get-bucket-tagging.
- Recreate the bucket with
- Clean Up:
- Run
tofu destroy -auto-approvewhen complete.
- Run
📖 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-labsrepository with a root.gitignorerecursively guarding all subdirectories against committed state files or provider caches. - Provider & Resource Declarations: Configured centralized
default_tagsand declared anaws_s3_bucketinlab-102-first-project/. - Declarative Resource Creation: Declared an
aws_s3_bucketusing 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.tfstateto 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.