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
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_instanceoraws_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:
- Write or edit your configuration – create
.tffiles that describe the desired infrastructure. - Initialize – run
terraform initto download the required providers and set up the working directory. - Plan – run
terraform planto see a dry run of what will be created, changed, or destroyed. - Apply – run
terraform applyto actually make the changes. Terraform records everything in the state file. - Destroy (when needed) – run
terraform destroyto 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 configureor 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:RunInstancesors3:CreateBucketpermissions. 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
providerregion. 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 init → plan → apply → destroy 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 initbeforeplanorapply, 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
applywithout reviewingplanfirst, leading to unexpected creation of resources or costs. - Not destroying resources after testing, leaving EC2 instances running and racking up charges.
Variations
- Instead of inline AMI IDs, use the
aws_amidata source to dynamically fetch the latest AMI. - Use Terraform modules (e.g., from the Terraform Registry) to wrap EC2 and S3 configurations into reusable components.
- 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
init→plan→apply→destroy, and each command has a distinct purpose. - Define EC2 and S3 resources with
aws_instanceandaws_s3_bucketblocks, specifying required arguments. - Always preview changes with
terraform planbefore applying to avoid surprises. - Manage state carefully—use a remote backend for team collaboration.
- Clean up with
terraform destroyto prevent unexpected cloud costs.
Keep learning
Related tutorials, quizzes, and articles for this topic.
Discussion
Questions, corrections, and tips help everyone reading this page.
0 comments
Add a comment
No comments yet — start the thread.