Terraform for EC2 and S3

Write Terraform configs for EC2 and S3 — hands-on AWS Cloud & DevOps with Python tutorial.

Focus: write terraform configs for ec2 and s3

Sponsored

Staring at the AWS console, clicking through a dozen screens just to launch a single EC2 instance, and then manually creating an S3 bucket—does that sound familiar? It's painful, error-prone, and absolutely not repeatable. This lesson ends that misery: you'll write Terraform configuration files that declare your EC2 instances and S3 buckets as code, so you can spin up, tear down, and version your entire infrastructure with a single command. By the time you finish, you'll have the foundational skill every DevOps engineer needs: treating infrastructure as code.

The problem this lesson solves

Manually provisioning AWS resources through the web console might work for a one-off test, but it collapses under real-world demands. You can't easily reproduce the same environment, you can't track who changed what, and you're one misclick away from a very expensive mistake. Scripting with the AWS CLI is better, but it's still procedural—you write step-by-step commands, and if something fails halfway through, you're left with a half-built mess.

Terraform solves this by taking a completely different approach. Instead of how to create resources, you declare what you want the end state to be. Terraform figures out the steps, applies them, and keeps a record of everything it created. This shift from imperative to declarative is the heart of Infrastructure as Code (IaC), and it's the reason Terraform has become the industry standard for cloud provisioning.

Core concept / mental model

Think of Terraform as a high-level blueprint for your cloud infrastructure. You write a configuration file that describes the desired state—an EC2 instance with this AMI, an S3 bucket with that name—and Terraform becomes the general contractor. It reads the blueprint, compares it to what already exists (the current state), and takes only the actions needed to make reality match your blueprint.

This is a declarative model, as opposed to the imperative model of shell scripts. With a script, you command: "Run this command, then this one, then this one." With Terraform, you declare: "I want an EC2 instance with these properties and an S3 bucket with these settings."

Key concepts you'll encounter:

  • Provider: The plugin that lets Terraform talk to a specific platform (e.g., aws).
  • Resource: A discrete piece of infrastructure, like aws_instance or aws_s3_bucket.
  • State: Terraform's record of what it manages, stored in terraform.tfstate.
  • Plan: A preview of what Terraform will do before it does it.
  • Apply: The command that executes the plan and creates/modifies resources.

How it works step by step

Terraform's workflow is a loop you'll run dozens of times:

  1. Write or edit your configuration – create .tf files that describe the desired infrastructure.
  2. Initialize – run terraform init to download the required providers and set up the working directory.
  3. Plan – run terraform plan to see a dry run of what will be created, changed, or destroyed.
  4. Apply – run terraform apply to actually make the changes. Terraform records everything in the state file.
  5. Destroy (when needed) – run terraform destroy to remove everything you created.

For EC2 and S3, your configuration will define two resource blocks: one for the EC2 instance and one for the S3 bucket. Each block specifies arguments like AMI, instance type, bucket name, and tags. Terraform uses these to create the resources in the correct order—it figures out dependencies automatically.

Hands-on walkthrough

Let's build a real Terraform configuration that creates an EC2 instance and an S3 bucket. You'll see how the pieces fit together and run the commands yourself.

Prerequisites

  • AWS account with credentials configured (aws configure or environment variables)
  • Terraform installed (v1.x recommended)
  • Python installed (for parts of the track)

Step 1: Create the configuration file

Create a directory and a file named main.tf with the following content:

# main.tf
terraform {
  required_version = ">= 1.0"
  required_providers {
    aws = {
      source  = "hashicorp/aws"
      version = "~> 5.0"
    }
  }
}

provider "aws" {
  region = "us-east-1"
}

resource "aws_instance" "web" {
  ami           = "ami-0c55b159cbfafe1f0"  # Amazon Linux 2 (example)
  instance_type = "t2.micro"
  tags = {
    Name = "PythonSkillset-EC2"
  }
}

resource "aws_s3_bucket" "data" {
  bucket = "python-skillset-data-bucket"  # must be globally unique
  tags = {
    Name = "PythonSkillset-S3"
  }
}

Pro tip: The S3 bucket name must be globally unique across all AWS accounts. Add your initials or a random suffix to avoid conflicts.

Step 2: Initialize, plan, and apply

# In the same directory as main.tf
terraform init

expect output like:
Initializing the backend...
Initializing provider plugins...
Terraform has been successfully initialized!

terraform plan

expect output like:
Terraform used the selected providers to generate the following execution plan.
Resource actions are indicated with the following symbols:
  + create

Terraform will perform the following actions:

  # aws_instance.web will be created
  + resource "aws_instance" "web" {
      + ami                                  = "ami-0c55b159cbfafe1f0"
      + instance_type                        = "t2.micro"
      ...
    }

  # aws_s3_bucket.data will be created
  + resource "aws_s3_bucket" "data" {
      + bucket                               = "python-skillset-data-bucket"
      ...
    }

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

terraform apply -auto-approve

expect output like:
aws_s3_bucket.data: Creating...
aws_instance.web: Creating...
aws_instance.web: Still creating... [10s elapsed]
aws_s3_bucket.data: Creation complete
aws_instance.web: Creation complete

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

Step 3: Verify and clean up

Run terraform state list to see the resources you've created. When you're done, run terraform destroy to avoid ongoing charges.

terraform state list
# output:
# aws_instance.web
# aws_s3_bucket.data

exit
terraform destroy -auto-approve
# output:
# ...
# Destroy complete! Resources: 2 destroyed.

Compare options / when to choose what

You might wonder why you'd use Terraform over the AWS CLI or CloudFormation. Here's a quick comparison:

Tool Paradigm Best for Limitations
AWS CLI Imperative Quick one-off tasks, scripting simple actions Not idempotent, no state tracking, hard to reuse
CloudFormation Declarative Deep AWS integration, built-in rollback AWS-only, YAML/JSON verbose, slower feedback loop
Terraform Declarative Multi-cloud, community providers, easy state Requires state management, learning curve

When to choose Terraform: You're working in a multi-cloud environment, you want to manage non-AWS resources too (like GitHub or Cloudflare), or you prefer a simpler, more readable configuration language (HCL) over JSON/YAML.

When to choose AWS CLI: You're doing a quick one-off task like listing buckets or copying files, and you don't need reproducibility.

When to choose CloudFormation: You're fully invested in AWS, want tight integration with AWS-specific features like StackSets, and don't mind the verbosity.

Troubleshooting & edge cases

Common errors

  • Bucket name already exists: S3 bucket names are globally unique. If you get BucketAlreadyExists, change the name to something unique.
  • Permission denied: Your AWS credentials don't have ec2:RunInstances or s3:CreateBucket permissions. Ensure your IAM user has the necessary policies (e.g., AmazonEC2FullAccess, AmazonS3FullAccess).
  • AMI not found: The AMI ID might be region-specific or expired. Use the AWS console or CLI to find a valid AMI for your region.
  • State file conflicts: If you're working in a team, you'll hit state locking issues. Use a remote backend like S3 to store state and enable locking.

Edge cases

  • AMI IDs change across regions: Always specify the correct AMI for your provider region. You can use SSM parameters to fetch the latest AMI dynamically.
  • S3 bucket naming rules: Only lowercase letters, numbers, periods, and hyphens; must start with a number or letter; 3-63 characters.
  • Resource ordering: Terraform automatically handles dependencies, but if you ever need to force a dependency (e.g., an instance that needs a bucket), use depends_on.

What you learned & what's next

You now understand the core problem Terraform solves—bringing reproducibility and clarity to cloud infrastructure—and you know the mental model of declarative configuration. You can write Terraform configs for EC2 and S3, run the full initplanapplydestroy workflow, and compare Terraform to other provisioning tools. You've also touched on state management and dependency handling.

In the next lesson, you'll build on this foundation, likely by integrating Terraform with Python—perhaps using boto3 to interact with the resources you've created, or embedding Terraform in a Python automation script. That's where the true DevOps magic happens: Terraform provisions, Python orchestrates.

Before you move on, make sure you can answer: Why is Terraform declarative? What is the state file for? How do you preview changes before applying? If you can, you're ready to scale your infrastructure as code.

Practice recap

As a quick exercise, create a new directory, write a main.tf that defines an EC2 instance using a t2.micro and an S3 bucket with a name that includes your initials (e.g., js-data-bucket). Run terraform init, terraform plan, then terraform apply. Verify with terraform state list, and finally terraform destroy to clean up. Try adding a tag to both resources and re-running plan to see how Terraform tracks changes.

Common mistakes

  • Using a global S3 bucket name that isn't unique—you'll hit 'BucketAlreadyExists'. Always add a random suffix or your name.
  • Forgetting to run terraform init before plan or apply, which results in 'Terraform initialized in an empty directory' or provider errors.
  • Hardcoding a region-specific AMI ID that doesn't exist in your chosen region, causing launch failures.
  • Running apply without reviewing plan first, leading to unexpected creation of resources or costs.
  • Not destroying resources after testing, leaving EC2 instances running and racking up charges.

Variations

  1. Instead of inline AMI IDs, use the aws_ami data source to dynamically fetch the latest AMI.
  2. Use Terraform modules (e.g., from the Terraform Registry) to wrap EC2 and S3 configurations into reusable components.
  3. Store your Terraform state in a remote S3 backend with DynamoDB locking for team collaboration.

Real-world use cases

  • Automating creation of a web server (EC2) and its static asset store (S3) for a new environment in a CI/CD pipeline.
  • Provisioning an EC2 instance and S3 bucket for a Python data processing job that needs a shared storage location.
  • Setting up development and production environments with identical infrastructure using the same Terraform configuration.

Key takeaways

  • Terraform uses a declarative model: you describe the desired state, not the steps to get there.
  • The core workflow is initplanapplydestroy, and each command has a distinct purpose.
  • Define EC2 and S3 resources with aws_instance and aws_s3_bucket blocks, specifying required arguments.
  • Always preview changes with terraform plan before applying to avoid surprises.
  • Manage state carefully—use a remote backend for team collaboration.
  • Clean up with terraform destroy to prevent unexpected cloud costs.

Sponsored

Sponsored

Discussion

Questions, corrections, and tips help everyone reading this page.

0 comments

Add a comment

Shown publicly with your comment.

Be constructive · max 4,000 characters

No comments yet — start the thread.

Related tutorials, quizzes, and articles for this topic.