Posted on :: 2259 Words :: Tags: , , ,

Your First Cloud Resource

Time to stop reading and start breaking things.

You've made it through the theory (Part 1) and survived the setup (Part 2). Now we're going to create something real: an actual EC2 instance on AWS using Terraform.

This is where it clicks. Or where you realize you hate declarative infrastructure. Either way, you'll know.

📦 Code Examples

Repository: terraform-hcl-tutorial-series This Part: Part 3 - First Resource Example

Get the working example:

git clone https://github.com/khuongdo/terraform-hcl-tutorial-series.git
cd terraform-hcl-tutorial-series
git checkout part-03
cd examples/part-03-first-resource/

# Initialize and deploy
terraform init
terraform plan
terraform apply

# Clean up when done
terraform destroy

What You're Building

By the end of this tutorial, you'll have deployed an EC2 instance to AWS, understood how Terraform tracks what it created, and (hopefully) destroyed it so you don't get billed. Along the way, you'll run into at least one error, which is good—debugging teaches you more than success ever does.

Cost warning: We're using t2.micro instances, which are free-tier eligible. If you actually destroy the resources when we're done, this costs $0. If you forget and leave it running? About $10/month. Set a calendar reminder.

Before You Start

Make sure you finished Part 2. Seriously. If these commands fail, go back and fix your setup first:

# Terraform installed?
terraform --version
# Should show v1.6+

# AWS credentials configured?
aws sts get-caller-identity
# Should show your account info

# Working directory ready?
mkdir -p ~/terraform-tutorial/part-03
cd ~/terraform-tutorial/part-03

If anything errors out, don't proceed. You'll just waste time debugging the wrong thing.

Your First Configuration File

Create a file called main.tf:

# Provider configuration
provider "aws" {
  region = "us-east-1"
}

# EC2 instance resource
resource "aws_instance" "web" {
  ami           = "ami-0c55b159cbfafe1f0"
  instance_type = "t2.micro"

  tags = {
    Name        = "MyFirstTerraformInstance"
    Environment = "tutorial"
    ManagedBy   = "terraform"
  }
}

Let's break this down:

provider "aws" tells Terraform we're using AWS in the us-east-1 region. Simple.

resource "aws_instance" "web" declares an EC2 instance. The format is resource "<type>" "<local_name>". The type (aws_instance) comes from the AWS provider. The local name (web) is whatever you want—it's just how you reference this resource in your code.

ami is the Amazon Machine Image—basically, which operating system you want. This one is Ubuntu 20.04. AMI IDs are region-specific, so if you're not in us-east-1, you'll need to change this. We'll hit that error in a minute.

instance_type is the size. t2.micro = 1 vCPU, 1 GB RAM, free-tier eligible. Good enough for a tutorial.

tags are metadata. Always tag your resources. The ManagedBy = "terraform" tag saves you later when you're trying to figure out what's safe to delete.

Initialize Terraform

Before Terraform can do anything, it needs to download the AWS provider plugin:

terraform init

You'll see output like this:

Initializing the backend...

Initializing provider plugins...
- Finding latest version of hashicorp/aws...
- Installing hashicorp/aws v5.31.0...
- Installed hashicorp/aws v5.31.0 (signed by HashiCorp)

Terraform has been successfully initialized!

What just happened? Terraform read your main.tf file, saw you're using AWS, and downloaded the AWS provider plugin (it's about 500 MB). It also created a .terraform/ directory and a .terraform.lock.hcl file to lock the provider version.

You only run terraform init once per project, or when you add new providers.

Preview Changes with terraform plan

This is the most important habit you'll develop: always run terraform plan before terraform apply.

terraform plan

Output:

Terraform will perform the following actions:

  # aws_instance.web will be created
  + resource "aws_instance" "web" {
      + ami                          = "ami-0c55b159cbfafe1f0"
      + instance_type                = "t2.micro"
      + id                           = (known after apply)
      + public_ip                    = (known after apply)
      + private_ip                   = (known after apply)
      ...
      + tags                         = {
          + "Environment" = "tutorial"
          + "ManagedBy"   = "terraform"
          + "Name"        = "MyFirstTerraformInstance"
        }
    }

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

This is Terraform saying "here's what I'm going to do." The + means it'll create something. (known after apply) means values that won't exist until AWS actually creates the instance.

Reading plan output is a skill. In production, one missed line can delete your database. Get used to reading carefully now.

Common Errors (You'll Probably Hit One)

Error: "No valid credential sources found"

Error: No valid credential sources found for AWS Provider

Fix: Run aws configure and enter your access keys.

Error: "AMI not found"

Error: creating EC2 Instance: InvalidAMIID.NotFound

Fix: AMI IDs are region-specific. The AMI in the example is for us-east-1. If you're using a different region, find your AMI at Ubuntu Cloud Images and update the ami value in main.tf.

Error: "Insufficient IAM permissions"

Error: creating EC2 Instance: UnauthorizedOperation

Fix: Your AWS user needs EC2 permissions. If you're using a personal account, just give yourself admin access for now. If you're in a company account, ask your admin for the AmazonEC2FullAccess policy.

Apply Changes (Actually Create the Instance)

Once the plan looks good, apply it:

terraform apply

Terraform shows you the plan again and asks for confirmation:

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

  Enter a value:

Type yes (not y, full word):

aws_instance.web: Creating...
aws_instance.web: Still creating... [10s elapsed]
aws_instance.web: Still creating... [20s elapsed]
aws_instance.web: Creation complete after 32s [id=i-0abc123def456789]

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

Congratulations. You just created cloud infrastructure with code.

Go check the AWS Console. EC2 → Instances. There's your instance, tagged "MyFirstTerraformInstance," actually running.

What Just Happened?

When you ran terraform apply, here's what Terraform did:

  1. Built a dependency graph (in this case, just one resource, so not very exciting)
  2. Made AWS API calls (ec2:RunInstances) with your parameters
  3. Waited for AWS to provision the instance
  4. Recorded everything in a file called terraform.tfstate
  5. Gave you the instance ID

That state file is critical. Let's talk about it.

The State File (terraform.tfstate)

After apply, you'll see a new file:

ls -la
# .terraform/
# .terraform.lock.hcl
# main.tf
# terraform.tfstate    <-- This is new

Open terraform.tfstate. It's JSON. It looks something like this:

{
  "version": 4,
  "terraform_version": "1.6.0",
  "resources": [
    {
      "type": "aws_instance",
      "name": "web",
      "instances": [
        {
          "attributes": {
            "id": "i-0abc123def456789",
            "ami": "ami-0c55b159cbfafe1f0",
            "instance_type": "t2.micro",
            "public_ip": "54.123.45.67",
            ...
          }
        }
      ]
    }
  ]
}

This is Terraform's memory. It maps aws_instance.web in your code to i-0abc123def456789 in AWS. Without this file, Terraform wouldn't know what it created, what needs updating, or what it can safely destroy.

Never edit this file manually. Treat it like a database. Use Terraform commands to interact with it, not a text editor.

In production, you'll store state remotely (S3, Terraform Cloud, etc.), but for now, the local file is fine.

Understanding Plan vs Apply

Terraform's workflow is two steps: plan then apply. This is deliberate.

terraform plan is read-only. It queries AWS to see what exists, compares it to your configuration, and shows what would change. It's safe to run anytime. Nothing gets modified.

Think of it like git diff before committing.

terraform apply actually makes the changes. It calls AWS APIs. It creates, updates, and deletes resources. It costs money.

Think of it like git push to production.

The golden rule: always run plan before apply. Always read the output. Never type yes reflexively.

One accidentally destroyed resource can cost hours of recovery work. Or worse, lost data.

Making Changes to Resources

Let's modify our instance. Update main.tf to add a tag:

resource "aws_instance" "web" {
  ami           = "ami-0c55b159cbfafe1f0"
  instance_type = "t2.micro"

  tags = {
    Name        = "MyFirstTerraformInstance"
    Environment = "tutorial"
    ManagedBy   = "terraform"
    Owner       = "YourName"  # <-- Added this
  }
}

Run plan:

terraform plan

Output:

Terraform will perform the following actions:

  # aws_instance.web will be updated in-place
  ~ resource "aws_instance" "web" {
        id            = "i-0abc123def456789"
      ~ tags          = {
          + "Owner"       = "YourName"
            # (3 unchanged elements hidden)
        }
        # (20 unchanged attributes hidden)
    }

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

Notice the ~ symbol. That means "update in-place." Terraform can add tags without recreating the instance. No downtime.

Apply it:

terraform apply
# Type: yes

# Output:
# aws_instance.web: Modifying... [id=i-0abc123def456789]
# aws_instance.web: Modifications complete after 2s

Done. Tags updated, instance still running. This is the power of declarative infrastructure—you say what you want, Terraform figures out the minimal changes needed.

When Resources Get Replaced

Some changes can't be made in-place. They require destroying the old resource and creating a new one.

Try changing the AMI (don't actually apply this, just look at the plan):

resource "aws_instance" "web" {
  ami           = "ami-0xyz987new654321"  # Changed
  instance_type = "t2.micro"
  ...
}

Run plan:

terraform plan
Terraform will perform the following actions:

  # aws_instance.web must be replaced
-/+ resource "aws_instance" "web" {
      ~ ami           = "ami-0c55b159cbfafe1f0" -> "ami-0xyz987new654321" # forces replacement
      ~ id            = "i-0abc123def456789" -> (known after apply)
        ...
    }

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

See forces replacement? AWS can't change an instance's AMI after it's launched. Terraform has to destroy the old instance and create a new one.

Don't apply this change. Revert the AMI back to the original value. We'll cover resource replacement in detail in Part 6.

Cleaning Up: Destroy Resources

When you're done experimenting, destroy the instance to avoid charges:

terraform destroy

Output:

Terraform will perform the following actions:

  # aws_instance.web will be destroyed
  - resource "aws_instance" "web" {
      - ami           = "ami-0c55b159cbfafe1f0" -> null
      - id            = "i-0abc123def456789" -> null
      ...
    }

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

Do you really want to destroy all resources?
  Terraform will destroy all your managed infrastructure.
  There is no undo. Only 'yes' will be accepted to confirm.

  Enter a value:

Type yes:

aws_instance.web: Destroying... [id=i-0abc123def456789]
aws_instance.web: Still destroying... [10s elapsed]
aws_instance.web: Destruction complete after 32s

Destroy complete! Resources: 1 destroyed.

Check the AWS Console. Instance state: "Terminated."

Your state file still exists (it tracks the history), but the resources are gone.

A Few More Errors You Might See

Error: "Resource already exists"

This happens if you manually created an instance, then tried to run terraform apply. Terraform doesn't know about manually created resources. Either delete the manual instance or import it into Terraform state (we'll cover imports in Part 9).

Error: "State file locked"

Error: Error acquiring the state lock

This is Terraform's safety mechanism. If you hit Ctrl+C during an apply, Terraform locks the state to prevent corruption. Wait 2 minutes or force unlock:

terraform force-unlock <lock-id-from-error>

Only force unlock if you're certain no other Terraform process is running.

Error: "Provider version mismatch"

Error: Provider version constraint not met

Someone else used a newer provider version. Fix it:

terraform init -upgrade

Test Yourself

Before moving to Part 4, make sure you can answer these:

  1. What does terraform plan do?
  2. What is terraform.tfstate and why does it matter?
  3. What's the difference between an in-place update and a resource replacement?
  4. What command destroys all Terraform-managed resources?
  5. What does the + symbol mean in plan output?

If you can't answer these, reread the relevant sections. These concepts are foundational.

What You Just Learned

You deployed cloud infrastructure with code. That's the fundamental value proposition of Terraform.

You also learned the workflow: write code, run plan to preview, run apply to execute, run destroy to clean up. This pattern scales from one EC2 instance to thousands of resources across multiple clouds.

And you probably hit an error. Good. Debugging teaches you how things actually work. Documentation teaches you the happy path.

Next: HCL Fundamentals

In Part 4, we'll dive deep into the HashiCorp Configuration Language. Variables, outputs, data types, functions, conditionals, loops—all the tools you need to write flexible, reusable Terraform configurations.

Right now, your main.tf is hardcoded. Part 4 teaches you how to make it dynamic.

Ready? Continue to Part 4 → (coming soon)


Troubleshooting: SSH Access

If you want to SSH into your instance (we didn't set that up), you'll need a security group:

resource "aws_security_group" "allow_ssh" {
  name        = "allow_ssh"
  description = "Allow SSH inbound traffic"

  ingress {
    from_port   = 22
    to_port     = 22
    protocol    = "tcp"
    cidr_blocks = ["0.0.0.0/0"]  # Warning: Allows SSH from anywhere
  }

  egress {
    from_port   = 0
    to_port     = 0
    protocol    = "-1"
    cidr_blocks = ["0.0.0.0/0"]
  }
}

resource "aws_instance" "web" {
  ami           = "ami-0c55b159cbfafe1f0"
  instance_type = "t2.micro"
  vpc_security_group_ids = [aws_security_group.allow_ssh.id]

  tags = {
    Name        = "MyFirstTerraformInstance"
    Environment = "tutorial"
    ManagedBy   = "terraform"
  }
}

We'll cover security groups properly in Part 5. For now, just know that 0.0.0.0/0 allows SSH from anywhere, which is insecure for production. Fine for a tutorial.


Additional Resources


This post is part of the "Terraform from Fundamentals to Production" series. Follow along to master Infrastructure as Code with Terraform.

Series navigation: