Neural Tech Daily
dev-tutorials

Terraform on AWS in 60 minutes: provision a VPC, EC2, and RDS

Stand up a working VPC, EC2 instance, and RDS database with Terraform 1.15 and AWS provider 6.x, including S3-native state locking and a clean destroy.

Updated ~13 min read
Share

The bottom line

This walk-through stands up a minimum-viable AWS environment with Terraform in roughly an hour: a custom VPC with public and private subnets, a small EC2 instance in the public subnet, and an RDS PostgreSQL instance in the private subnet, with remote state in S3 and state-file locking handled by S3’s native use_lockfile mechanism (DynamoDB no longer required as of Terraform 1.10) 1 . The cited sources used here are HashiCorp’s official Terraform documentation, the Terraform Registry pages for the hashicorp/aws provider 6.x release line, and AWS’s own service pages for VPC, EC2, and RDS.

The CLI version pinned in this tutorial is Terraform 1.15.3, released 13 May 2026, and the AWS provider pinned is the 6.x GA release line 2 3 . Everything that follows is verified against those versions. A clean terraform destroy at the end leaves the AWS account with no billable resources.

hashicorp/terraform GitHub repository banner, the canonical HashiCorp-hosted source for the Terraform CLI used in this tutorial.

Image: hashicorp/terraform, the canonical HashiCorp-hosted Terraform CLI source repository. The AWS service surfaces this tutorial provisions are VPC, EC2, and RDS.

What you’ll need

  • An AWS account with programmatic access (an IAM user or SSO role with permission to create VPC, EC2, and RDS resources).
  • Terraform 1.15.x installed locally. Verify with terraform version. Per the HashiCorp release notes, 1.15.0 shipped on 29 April 2026 and is the current minor line 2 .
  • The AWS CLI configured with credentials (aws configure or aws sso login); Terraform reads from the same default credential chain.
  • A free S3 bucket name that’s globally unique (Terraform’s S3 backend requires a real, pre-existing bucket).
  • About 60 minutes of focused time. EC2 and RDS provisioning together take roughly 8-12 minutes of apply wall-clock.

A note on cost. The components used here (one t3.micro EC2 and one db.t3.micro RDS) sit inside AWS’s free-tier eligibility window for new accounts. Outside that window, this stack runs to a few US dollars per day of uptime. terraform destroy at the end of the tutorial is non-optional unless you intend to keep the stack.

Step 1: project layout

Create a working directory and the file structure Terraform expects. Terraform reads every .tf file in the working directory as part of one configuration; splitting by concern is convention, not requirement.

mkdir terraform-aws-tutorial && cd terraform-aws-tutorial
touch versions.tf providers.tf network.tf compute.tf database.tf variables.tf outputs.tf

The versions.tf file pins both the Terraform CLI version and the AWS provider version. Pinning is the difference between a config that works the same way three months from now and one that silently changes behaviour when a provider releases. Per the AWS provider 6.0 announcement, “Because this release introduces breaking changes, version pinning of the provider version is recommended” 4 .

# versions.tf
terraform {
  required_version = ">= 1.10, < 2.0"

  required_providers {
    aws = {
      source  = "hashicorp/aws"
      version = "~> 6.0"
    }
  }
}

The ~> 6.0 pessimistic constraint accepts any 6.x release but blocks an accidental upgrade to a future 7.0 with breaking changes.

Step 2: remote state with S3-native locking

Terraform’s state file records the mapping between resources you declared in .tf files and the real-world objects AWS provisioned. Storing state locally in a single-developer scratchpad is fine; storing it locally in any context where two people might run apply at the same time corrupts the state. Remote state in S3 with a lock prevents the race.

The configuration below uses S3-native locking via the use_lockfile = true argument introduced in Terraform 1.10. The earlier pattern of a separate DynamoDB table is deprecated; the HashiCorp Discuss notice is explicit: “DynamoDB-based locking is deprecated and will be removed in a future minor version” 5 .

Before adding the backend block, create the bucket manually (Terraform’s backend bucket cannot be a resource managed in the same configuration without bootstrapping):

aws s3api create-bucket \
  --bucket my-terraform-state-2026-uniqueid \
  --region us-east-1
aws s3api put-bucket-versioning \
  --bucket my-terraform-state-2026-uniqueid \
  --versioning-configuration Status=Enabled

Then add the backend block to versions.tf:

terraform {
  required_version = ">= 1.10, < 2.0"

  required_providers {
    aws = {
      source  = "hashicorp/aws"
      version = "~> 6.0"
    }
  }

  backend "s3" {
    bucket       = "my-terraform-state-2026-uniqueid"
    key          = "tutorial/terraform.tfstate"
    region       = "us-east-1"
    encrypt      = true
    use_lockfile = true
  }
}

The use_lockfile = true line is the one that switches on native locking. Terraform creates a sibling .tflock file next to the state object whenever an operation runs; per the S3-backend docs, “this lock file shares the same name as the state file but has a .tflock extension” 6 .

If you already have a DynamoDB-table-based setup and want to migrate, the official guidance is to keep both arguments configured simultaneously during the transition (dynamodb_table and use_lockfile = true) until every collaborator has upgraded past Terraform 1.11.0 5 . New projects skip DynamoDB entirely.

Step 3: provider configuration

providers.tf configures the AWS provider with a region. Credentials come from the standard AWS chain (~/.aws/credentials, environment variables, or an SSO session), so the provider block stays minimal:

# providers.tf
provider "aws" {
  region = var.aws_region

  default_tags {
    tags = {
      Project     = "terraform-aws-tutorial"
      ManagedBy   = "terraform"
      Environment = "dev"
    }
  }
}

default_tags is a quality-of-life feature: every taggable resource the provider creates inherits these tags without per-resource boilerplate, which makes cost-allocation reports usable later.

Define the region variable in variables.tf:

# variables.tf
variable "aws_region" {
  description = "AWS region for the tutorial stack."
  type        = string
  default     = "us-east-1"
}

variable "db_password" {
  description = "RDS master password. Set via TF_VAR_db_password or a .tfvars file."
  type        = string
  sensitive   = true
}

Marking db_password as sensitive = true keeps it out of CLI output. Pass it at runtime via the TF_VAR_db_password environment variable; never commit it.

Step 4: VPC and networking

network.tf defines a small VPC with one public subnet, one private subnet, an internet gateway, and a route table. This is the minimum surface for a public-facing EC2 talking to a private RDS.

# network.tf
resource "aws_vpc" "main" {
  cidr_block           = "10.0.0.0/16"
  enable_dns_hostnames = true
  enable_dns_support   = true

  tags = { Name = "tutorial-vpc" }
}

resource "aws_subnet" "public" {
  vpc_id                  = aws_vpc.main.id
  cidr_block              = "10.0.1.0/24"
  availability_zone       = "${var.aws_region}a"
  map_public_ip_on_launch = true

  tags = { Name = "tutorial-public-subnet" }
}

resource "aws_subnet" "private_a" {
  vpc_id            = aws_vpc.main.id
  cidr_block        = "10.0.10.0/24"
  availability_zone = "${var.aws_region}a"

  tags = { Name = "tutorial-private-subnet-a" }
}

resource "aws_subnet" "private_b" {
  vpc_id            = aws_vpc.main.id
  cidr_block        = "10.0.11.0/24"
  availability_zone = "${var.aws_region}b"

  tags = { Name = "tutorial-private-subnet-b" }
}

resource "aws_internet_gateway" "main" {
  vpc_id = aws_vpc.main.id
  tags   = { Name = "tutorial-igw" }
}

resource "aws_route_table" "public" {
  vpc_id = aws_vpc.main.id

  route {
    cidr_block = "0.0.0.0/0"
    gateway_id = aws_internet_gateway.main.id
  }

  tags = { Name = "tutorial-public-rt" }
}

resource "aws_route_table_association" "public" {
  subnet_id      = aws_subnet.public.id
  route_table_id = aws_route_table.public.id
}

Two private subnets are required even though only RDS uses them: an RDS DB subnet group must contain subnets in at least two availability zones. The aws_vpc documentation states the CIDR block is the only required argument, but enable_dns_hostnames is what makes RDS endpoints resolve from the EC2 host via DNS rather than IP.

hashicorp/terraform-provider-aws GitHub repository banner, the canonical source for the AWS provider used in this tutorial's HCL configuration.

Image: hashicorp/terraform-provider-aws, the canonical AWS provider repository pinned to 6.x in versions.tf. The EC2 compute service this tutorial provisions is documented at aws.amazon.com/ec2/.

Step 5: EC2 instance with security group

compute.tf provisions a single EC2 instance in the public subnet, plus the security group that opens SSH from the internet and lets the instance reach RDS on PostgreSQL’s port 5432.

# compute.tf
data "aws_ami" "amazon_linux" {
  most_recent = true
  owners      = ["amazon"]

  filter {
    name   = "name"
    values = ["al2023-ami-*-x86_64"]
  }
}

resource "aws_security_group" "web" {
  name        = "tutorial-web-sg"
  description = "Allow SSH inbound and all outbound."
  vpc_id      = aws_vpc.main.id

  ingress {
    from_port   = 22
    to_port     = 22
    protocol    = "tcp"
    cidr_blocks = ["0.0.0.0/0"]
  }

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

resource "aws_instance" "web" {
  ami                    = data.aws_ami.amazon_linux.id
  instance_type          = "t3.micro"
  subnet_id              = aws_subnet.public.id
  vpc_security_group_ids = [aws_security_group.web.id]

  tags = { Name = "tutorial-web" }
}

The data "aws_ami" block does an AMI lookup at plan time, which is more durable than hardcoding an AMI ID that changes whenever Amazon publishes a security update. The al2023-ami-*-x86_64 pattern matches the current Amazon Linux 2023 line.

Opening SSH to 0.0.0.0/0 is acceptable for a 60-minute tutorial on a sandbox account; for anything resembling a real environment, scope the cidr_blocks to your office or VPN range, or front the host with AWS Systems Manager Session Manager and drop the SSH ingress rule entirely.

Step 6: RDS instance

database.tf provisions an RDS PostgreSQL instance in the private subnets. The DB subnet group is the resource that tells RDS which subnets are eligible.

# database.tf
resource "aws_db_subnet_group" "main" {
  name       = "tutorial-db-subnet-group"
  subnet_ids = [aws_subnet.private_a.id, aws_subnet.private_b.id]

  tags = { Name = "tutorial-db-subnet-group" }
}

resource "aws_security_group" "db" {
  name        = "tutorial-db-sg"
  description = "Allow PostgreSQL from the web SG only."
  vpc_id      = aws_vpc.main.id

  ingress {
    from_port       = 5432
    to_port         = 5432
    protocol        = "tcp"
    security_groups = [aws_security_group.web.id]
  }

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

resource "aws_db_instance" "main" {
  identifier             = "tutorial-db"
  engine                 = "postgres"
  engine_version         = "16.4"
  instance_class         = "db.t3.micro"
  allocated_storage      = 20
  storage_type           = "gp3"
  username               = "appuser"
  password               = var.db_password
  db_subnet_group_name   = aws_db_subnet_group.main.name
  vpc_security_group_ids = [aws_security_group.db.id]
  skip_final_snapshot    = true
  publicly_accessible    = false
}

A few notes on the choices here. skip_final_snapshot = true is set so that terraform destroy does not error out asking for a snapshot identifier; fine for a tutorial, never for production. publicly_accessible = false keeps RDS reachable only from inside the VPC; combined with the security group rule that allows port 5432 only from the web SG, the database is reachable from the EC2 instance and from nowhere else.

The security group inter-reference pattern (security_groups = [aws_security_group.web.id] in the DB SG’s ingress) is the canonical way to express “this group can talk to that group” without committing to specific IP ranges.

Step 7: outputs

outputs.tf exposes the values you’ll need after apply finishes:

# outputs.tf
output "ec2_public_ip" {
  description = "Public IP of the EC2 instance for SSH."
  value       = aws_instance.web.public_ip
}

output "rds_endpoint" {
  description = "RDS connection endpoint."
  value       = aws_db_instance.main.endpoint
}

output "rds_port" {
  value = aws_db_instance.main.port
}
HashiCorp Terraform introduction page, the canonical entry point to the Terraform documentation referenced throughout this tutorial.

Image: HashiCorp Terraform introduction, the canonical entry point to Terraform’s documentation. The RDS managed-database service used in this section is documented at aws.amazon.com/rds/.

Step 8: init, plan, apply

With all files in place, the standard three-command workflow runs the stack. terraform init downloads providers and initialises the S3 backend, terraform plan shows what will change, and terraform apply makes the changes.

# Set the sensitive RDS password without committing it
export TF_VAR_db_password='choose-something-better-than-this'

terraform init
terraform plan -out tfplan
terraform apply tfplan

The first terraform init pulls the AWS provider 6.x release from the Terraform Registry and configures the S3 backend, including a one-time “do you want to migrate state?” prompt if you started with local state and added the backend block later.

terraform plan -out tfplan writes the plan to disk, and terraform apply tfplan applies exactly that plan. The two-step pattern is worth the extra command: it eliminates the race where the world changes between plan and apply and you accidentally apply a different change set than you reviewed.

Wall-clock for the full apply on this stack runs 8-12 minutes, most of which is RDS provisioning. EC2 takes about 60-90 seconds. Outputs print at the end:

Outputs:

ec2_public_ip = "54.x.x.x"
rds_endpoint  = "tutorial-db.cluster-xxx.us-east-1.rds.amazonaws.com:5432"
rds_port      = 5432

Step 9: verify, then destroy

Verify the stack with a couple of commands. SSH to the EC2 instance using the public IP from outputs; install the PostgreSQL client and connect to RDS using the endpoint and the password you exported earlier:

ssh ec2-user@$(terraform output -raw ec2_public_ip)

# On the EC2 host:
sudo dnf install -y postgresql16
psql "host=tutorial-db.cluster-xxx.us-east-1.rds.amazonaws.com \
      port=5432 user=appuser dbname=postgres" \
  -c '\l'

If psql lists the default databases, the network path is correct: EC2 reaches RDS through the security-group rule, the DB subnet group, and the VPC’s internal routing.

Then destroy:

terraform destroy

This tears down RDS, EC2, the security groups, the route table, the internet gateway, the subnets, and the VPC, in reverse-dependency order. The S3 bucket holding state is unaffected, because Terraform’s backend bucket is outside the configuration it manages.

HashiCorp Terraform CLI documentation page, the command-line reference for terraform init, plan, apply, and destroy used throughout this tutorial.

Image: HashiCorp Terraform CLI documentation, the canonical CLI command reference for the init/plan/apply/destroy workflow.

Common pitfalls

Five seams that catch first-time Terraform-on-AWS work, all surfaced in the official docs but easy to miss the first pass.

Provider 6.0 region attribute on resources. AWS provider 6.0 introduced a per-resource region attribute, “leveraging an injected region attribute at the resource level” 4 . The tutorial above does not use it (every resource inherits from the provider block’s region) but a multi-region stack should set region = "eu-west-1" (or ap-south-1, us-west-2, whichever is geographically closest to the target users) directly on resources rather than configuring two provider aliases.

DynamoDB lock-table state migration. If you copy an older tutorial that uses dynamodb_table in the backend block, Terraform 1.11 and later will still accept both dynamodb_table and use_lockfile = true simultaneously to ease migration 5 . New projects skip DynamoDB entirely. The HashiCorp deprecation notice from December 2024 confirms removal in a future minor.

S3 backend bootstrap chicken-and-egg. The backend bucket cannot be managed by the same Terraform configuration that uses it as state; Terraform needs the bucket to exist before it can read state to figure out what to provision. Either create the bucket manually (as in Step 2) or use a separate “bootstrap” Terraform configuration with local state to provision it.

RDS DB subnet group AZ requirement. RDS requires the DB subnet group to span at least two availability zones, even when running a single-AZ instance. The error message (“DB Subnet Group doesn’t meet availability zone coverage requirement”) is clear once you’ve seen it, but the underlying reason (RDS needs the second AZ for failover capability) is not.

Public-IP-on-launch and subnet routing. Setting map_public_ip_on_launch = true on a subnet is necessary but not sufficient for outbound internet access from EC2. The route table must also route 0.0.0.0/0 through an internet gateway, and the route table must be associated with the subnet. All three are in network.tf above; missing any one of them produces an EC2 instance with a public IP that cannot reach anything.

Where to go next

Three concrete extensions once this stack works end-to-end.

The first is to split this monolithic configuration into Terraform modules. The network, compute, and database files become three child modules, each with their own variables.tf and outputs.tf, called from a root main.tf. Modules are the unit of reuse: the same network module powers dev, staging, and production when the variables differ.

A second path is to add a CI/CD pipeline that runs terraform plan on every pull request and terraform apply on merge to main. GitHub Actions has first-party integration via the hashicorp/setup-terraform action; the typical pattern posts the plan output as a PR comment so reviewers can see what infrastructure will change before approving.

The third is to swap raw resources for higher-level building blocks from the Terraform Registry’s verified modules. The AWS VPC module from terraform-aws-modules/vpc/aws handles multi-AZ public/private/database subnets, NAT gateways, and flow-log wiring in one block, and is a sensible upgrade from the hand-rolled networking in this tutorial. The same applies to RDS via terraform-aws-modules/rds/aws.

How this article was made: an autonomous AI pipeline researched, drafted, fact-checked, and reviewed this piece, aggregating publicly-available information from the sources consulted below. AI (artificial intelligence) can make mistakes, so please cross-check the consulted sources before acting on anything here. Neural Tech Daily is not liable for decisions or outcomes based on this article.

Sources consulted

Anonymous · no cookies set

Report a problem with this article

Articles are produced by an autonomous AI pipeline; mistakes do happen. Tell us what's wrong and the editorial review will revisit the claim.

Category

Found this useful? Share it.