Terraform AWS Tutorial: Production-Ready IaC Foundations

Learn Terraform AWS for production. This tutorial covers setting up robust, scalable infrastructure-as-code foundations on AWS for experienced engineers.

Ahmet Çelik

11 min read
0

/

Terraform AWS Tutorial: Production-Ready IaC Foundations

Terraform AWS Tutorial: Production-Ready IaC Foundations


Most teams begin provisioning AWS resources manually or with basic scripts. But this approach commonly leads to configuration drift, inconsistent environments, and extended recovery times when operating at scale. Production systems demand a more robust, auditable, and repeatable method for infrastructure management.


TL;DR

  • Manual AWS resource provisioning is error-prone and scales poorly, leading to drift and slower incident response.
  • Terraform enables declarative Infrastructure as Code (IaC) for AWS, ensuring consistent, auditable, and repeatable deployments.
  • Configuring a remote state backend (S3 with DynamoDB locking) is critical for team collaboration and preventing concurrent state modifications.
  • Leverage Terraform modules to encapsulate common resource patterns, promoting reusability and maintainability across projects.
  • Implement robust security measures like IAM roles and state encryption, alongside monitoring and cost optimization practices for production readiness.


The Problem: Inconsistent Infrastructure and Operational Overhead


In dynamic production environments, infrastructure consistency is paramount. Teams commonly report 30–50% of outage incidents being directly or indirectly linked to configuration discrepancies between environments, or human error during manual changes. Maintaining disparate staging and production environments, often provisioned through a mix of CLI commands, console clicks, and bespoke scripts, creates a breeding ground for these issues. Without a single source of truth, auditing changes becomes a forensic exercise, and disaster recovery efforts are hampered by the inability to reliably re-provision infrastructure. This operational overhead directly impacts development velocity and system reliability, forcing engineering teams to spend significant time on environment management rather than feature development. Adopting an Infrastructure as Code (IaC) solution like Terraform for AWS resources establishes that single source of truth, automating provisioning and ensuring idempotency across all deployments.


How It Works: Building Scalable Terraform AWS Infrastructure


Terraform manages infrastructure resources using declarative configuration files. For AWS, this means defining everything from Virtual Private Clouds (VPCs) and subnets to EC2 instances, S3 buckets, and IAM policies, all as code. The core components are providers, resources, and state. The AWS provider allows Terraform to interact with AWS APIs. Resources represent specific AWS services. The Terraform state file tracks the current state of your managed infrastructure, mapping real-world resources to your configuration. For collaborative and production deployments, a remote state backend is essential to prevent state corruption and enable secure team access.


Configuring the AWS Provider and Remote State


Before provisioning any resources, Terraform needs to know which cloud to target and where to store its state. We will configure the AWS provider and then establish a remote state backend using an S3 bucket for storage and a DynamoDB table for state locking. State locking is critical in team environments to prevent multiple engineers from simultaneously modifying the state, which can lead to data loss or inconsistencies.


main.tf - AWS Provider and S3 Backend Configuration (2026)


Configure the AWS provider for the eu-west-1 region.

provider "aws" {

region = "eu-west-1"

}


Define the backend to store Terraform state in S3.

terraform {

backend "s3" {

bucket = "backendstack-ahmet-tf-state-2026" # Replace with a unique S3 bucket name

key = "environments/dev/terraform.tfstate" # Path to the state file

region = "eu-west-1"

dynamodb_table = "backendstack-ahmet-tf-lock-2026" # Replace with a unique DynamoDB table name

encrypt = true # Encrypt the state file at rest in S3

}

}


--- S3 Bucket for Terraform State (MUST be created manually or via CLI first) ---

For a production setup, this S3 bucket and DynamoDB table are typically provisioned

once using the AWS CLI or a separate, minimal Terraform configuration.

DO NOT include resource blocks for the S3 bucket and DynamoDB table in the

same configuration where they are used as the backend, to avoid circular dependencies.

The following resources are illustrative of what needs to exist.

These will be commented out, as they should be pre-existing.


/*

resource "awss3bucket" "terraform_state" {

bucket = "backendstack-ahmet-tf-state-2026"

tags = {

Name = "backendstack-ahmet-tf-state-2026"

Environment = "production"

}

}


resource "awss3bucketversioning" "terraformstate_versioning" {

bucket = awss3bucket.terraform_state.id

versioning_configuration {

status = "Enabled"

}

}


resource "awss3bucketserversideencryptionconfiguration" "terraformstateencryption" {

bucket = awss3bucket.terraform_state.id

rule {

applyserversideencryptionby_default {

sse_algorithm = "AES256"

}

}

}


resource "awsdynamodbtable" "terraform_locks" {

name = "backendstack-ahmet-tf-lock-2026"

hash_key = "LockID"

read_capacity = 5

write_capacity = 5


attribute {

name = "LockID"

type = "S"

}


tags = {

Name = "backendstack-ahmet-tf-lock-2026"

Environment = "production"

}

}

*/


Defining Core AWS Infrastructure


Once the backend is configured, we can define the foundational network and compute resources. This includes a Virtual Private Cloud (VPC) for network isolation, subnets for organizing resources within the VPC, and an EC2 instance to serve as an example compute resource. We emphasize sensible defaults and security groups to control traffic, reflecting production best practices.


vpc.tf - VPC and Subnet Definitions


Create a new VPC for our application.

resource "aws_vpc" "main" {

cidr_block = "10.0.0.0/16"

enablednshostnames = true

enablednssupport = true


tags = {

Name = "backendstack-ahmet-vpc-2026"

Environment = "dev"

}

}


Create a public subnet within the VPC.

resource "aws_subnet" "public" {

vpcid = awsvpc.main.id

cidr_block = "10.0.1.0/24"

availability_zone = "eu-west-1a" # Consider making this dynamic with data sources

mappubliciponlaunch = true


tags = {

Name = "backendstack-ahmet-public-subnet-2026"

Environment = "dev"

}

}


Define an Internet Gateway to allow communication to/from the internet.

resource "awsinternetgateway" "main" {

vpcid = awsvpc.main.id


tags = {

Name = "backendstack-ahmet-igw-2026"

}

}


Create a route table for the public subnet.

resource "awsroutetable" "public" {

vpcid = awsvpc.main.id


route {

cidr_block = "0.0.0.0/0"

gatewayid = awsinternet_gateway.main.id

}


tags = {

Name = "backendstack-ahmet-public-route-table-2026"

}

}


Associate the public route table with the public subnet.

resource "awsroutetable_association" "public" {

subnetid = awssubnet.public.id

routetableid = awsroutetable.public.id

}


ec2.tf - EC2 Instance and Security Group Definitions


Define a security group to allow SSH and HTTP access.

resource "awssecuritygroup" "web_sg" {

name = "backendstack-ahmet-web-sg-2026"

description = "Allow SSH and HTTP inbound traffic"

vpcid = awsvpc.main.id


ingress {

from_port = 22

to_port = 22

protocol = "tcp"

cidr_blocks = ["0.0.0.0/0"] # In production, restrict this to known IPs or a Bastion Host SG

description = "Allow SSH from anywhere"

}


ingress {

from_port = 80

to_port = 80

protocol = "tcp"

cidr_blocks = ["0.0.0.0/0"] # In production, restrict to known IPs or a Load Balancer SG

description = "Allow HTTP from anywhere"

}


egress {

from_port = 0

to_port = 0

protocol = "-1" # -1 means all protocols

cidr_blocks = ["0.0.0.0/0"]

description = "Allow all outbound traffic"

}


tags = {

Name = "backendstack-ahmet-web-sg-2026"

}

}


Find the latest Amazon Linux 2023 AMI.

data "awsami" "amazonlinux_2023" {

most_recent = true

owners = ["amazon"]


filter {

name = "name"

values = ["al2023-ami-*-x86_64"]

}


filter {

name = "architecture"

values = ["x86_64"]

}

}


Launch an EC2 instance.

resource "aws_instance" "web" {

ami = data.awsami.amazonlinux_2023.id

instance_type = "t3.micro"

subnetid = awssubnet.public.id

vpcsecuritygroupids = [awssecuritygroup.websg.id]

associatepublicip_address = true # Only for public-facing instances

key_name = "your-ssh-key-name-2026" # Replace with your existing SSH key pair name


user_data = <<-EOF

#!/bin/bash

echo "Hello, Backendstack.dev from Terraform! (2026)" > index.html

nohup busybox httpd -f -p 80 &

EOF


tags = {

Name = "backendstack-ahmet-web-server-2026"

Environment = "dev"

}

}


Output the public IP address of the EC2 instance.

output "webserverpublic_ip" {

description = "Public IP address of the web server"

value = awsinstance.web.publicip

}


Step-by-Step Implementation: Managing AWS Resources with Terraform


This section guides you through the process of initializing, planning, applying, and destroying your AWS infrastructure using the Terraform configuration defined above. Remember that the S3 bucket and DynamoDB table for the backend must exist before you run `terraform init`.


Pre-requisites:

  • AWS CLI configured with credentials that have permissions to create S3 buckets, DynamoDB tables, VPCs, EC2 instances, and related network resources.

  • Terraform CLI installed.

  • An existing SSH key pair in `eu-west-1` named `your-ssh-key-name-2026`. If you don't have one, create it via the AWS console or CLI.

  • Manually create the S3 bucket (`backendstack-ahmet-tf-state-2026`) and DynamoDB table (`backendstack-ahmet-tf-lock-2026`) in `eu-west-1` as referenced in `main.tf`. The DynamoDB table needs a primary key named `LockID` of type `String`.


  1. Initialize Terraform

Navigate to your project directory containing the `.tf` files and initialize Terraform. This command downloads the necessary AWS provider and configures the S3 backend.


$ terraform init


Expected Output:

Initializing the backend...

Successfully configured the backend "s3"! Terraform will now automatically

use this backend unless the backend configuration changes.


Initializing provider plugins...

- Reusing previous versions of hashicorp/aws from the dependency lock file

- Installing hashicorp/aws vX.Y.Z...

- Installed hashicorp/aws vX.Y.Z (signed by HashiCorp)


Terraform has been successfully initialized!

...


Common mistake: Forgetting to create the S3 bucket or DynamoDB table beforehand will result in an error during `terraform init`, as Terraform cannot create the backend resources it relies on to store its state.


  1. Format and Validate Configuration

Ensure your Terraform code adheres to standard formatting and is syntactically correct.


$ terraform fmt

$ terraform validate


Expected Output:

$ terraform fmt

main.tf

vpc.tf

ec2.tf

$ terraform validate

Success! The configuration is valid.


  1. Plan Infrastructure Changes

Review the execution plan. This step shows you exactly what Terraform will create, modify, or destroy without making any actual changes to your AWS environment.


$ terraform plan -out tfplan


Expected Output (truncated):

An execution plan has been generated and is shown below.

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-..."

+ arn = (known after apply)

+ associatepublicip_address = true

+ availability_zone = (known after apply)

...

}


# awsinternetgateway.main will be created

+ resource "awsinternetgateway" "main" {

+ arn = (known after apply)

+ owner_id = (known after apply)

+ tags = {

+ "Name" = "backendstack-ahmet-igw-2026"

}

+ vpc_id = (known after apply)

}

...

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


------------------------------------------------------------------------


This plan was saved to: tfplan


  1. Apply Infrastructure Changes

Apply the planned changes to provision the resources in your AWS account. You will be prompted to confirm the action.


$ terraform apply "tfplan"


Expected Output (truncated):

aws_vpc.main: Creating...

aws_vpc.main: Creation complete after 2s [id=vpc-XXXXXXXXXXXX]

awsinternetgateway.main: Creating...

...

aws_instance.web: Creation complete after 30s [id=i-XXXXXXXXXXXX]


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


Outputs:


webserverpublic_ip = "X.X.X.X"

You can then access the IP address printed and navigate to it in your browser to see "Hello, Backendstack.dev from Terraform! (2026)".


  1. Destroy Infrastructure

When the infrastructure is no longer needed, destroy it using Terraform. This removes all resources managed by this configuration. Always review the `terraform plan -destroy` output before proceeding.


$ terraform destroy


Expected Output (truncated):

An execution plan has been generated and is shown below.

Resource actions are indicated with the following symbols:

- destroy


Terraform will perform the following actions:


# aws_instance.web will be destroyed

- resource "aws_instance" "web" {

- ami = "ami-..." -> null

- arn = "arn:aws:ec2:eu-west-1:..." -> null

...

}

...

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


Do you really want to destroy all resources?

Terraform will destroy all your managed infrastructure, as shown above.

There is no undo. Only 'yes' will be accepted to proceed.


Enter a value: yes


Production Readiness: Securing and Operationalizing Terraform AWS Deployments


Moving beyond basic provisioning, production systems demand rigorous attention to security, cost, monitoring, and robust handling of failure modes.


  • Security:

Least Privilege IAM:* The AWS credentials used by Terraform must adhere to the principle of least privilege. Create specific IAM roles or users with only the necessary permissions to manage the defined resources. Avoid using root or overly permissive admin credentials.

State File Encryption:* Our S3 backend configuration includes `encrypt = true`, which ensures the state file is encrypted at rest using AES256. This is non-negotiable for sensitive production data. Additionally, enforce TLS for S3 access.

Secrets Management:* Never hardcode sensitive data (API keys, database passwords) directly in Terraform configurations. Integrate with AWS Secrets Manager or HashiCorp Vault for dynamic secret injection.

Static Analysis: Implement static analysis tools like `tfsec` or Checkov in your CI/CD pipeline. These tools can identify security misconfigurations or policy violations before* deployment, catching issues early.


  • Monitoring and Alerting:

Terraform State Locking:* DynamoDB provides state locking, which is visible in the DynamoDB console. Monitor this table for unusually long locks or frequent lock contention, indicating potential issues with concurrent deployments.

AWS CloudTrail:* All actions performed by Terraform against AWS APIs are logged in CloudTrail. Configure CloudWatch Alarms on CloudTrail logs for critical events, such as failed resource creations or deletions.

Configuration Drift Detection:* While Terraform is the source of truth, manual changes can still occur. Tools like AWS Config can detect drift between the desired state (as defined in Terraform) and the actual state in AWS. Integrate drift detection into your operational dashboards.


  • Cost Management:

Resource Tagging:* Implement mandatory tagging policies. Our example includes `Name` and `Environment` tags. Extend this with `CostCenter`, `Project`, and `Owner` tags to enable detailed cost allocation and reporting using AWS Cost Explorer.

`terraform plan` for Cost Estimation:* While not a precise cost calculator, reviewing the `terraform plan` output can highlight large or expensive resources being provisioned. For more accurate projections, integrate with cost estimation tools that parse Terraform plans.

Rightsizing:* Ensure instance types and storage volumes are appropriately sized for the workload. Review resource utilization metrics regularly and adjust configurations as needed.


  • Edge Cases and Failure Modes:

State File Corruption:* Despite locking, network issues or abrupt terminations can corrupt the state file. Always enable S3 bucket versioning for your state bucket, allowing you to revert to previous healthy state files if corruption occurs.

Concurrent Deployments:* While DynamoDB locking prevents simultaneous writes, a long-running `terraform apply` can block other deployments. Design your Terraform code into smaller, independent modules where appropriate to reduce lock contention.

External Resource Changes:* If a resource managed by Terraform is modified or deleted outside of Terraform (e.g., via AWS Console or CLI), Terraform's state will become out of sync. `terraform plan` will identify these discrepancies, but frequent manual interventions indicate a process problem. Use `terraform refresh` to update the state from real AWS resources without applying changes.


Summary & Key Takeaways


Adopting Terraform for AWS infrastructure provides significant advantages for experienced backend and platform engineers managing production systems. Moving away from manual provisioning drastically reduces operational risk and accelerates deployment cycles.


  • Do configure remote state: Always use an S3 bucket with DynamoDB locking for your Terraform state files to ensure consistency, prevent corruption, and enable team collaboration.

  • Do enforce least privilege: Grant Terraform execution roles only the permissions necessary to manage specific resources, minimizing the blast radius of any security breach.

  • Do leverage modules: Structure your infrastructure into reusable, parameterized modules to reduce boilerplate, improve maintainability, and enforce architectural patterns.

  • Do integrate into CI/CD: Automate `terraform plan` and `terraform apply` within your CI/CD pipelines to ensure consistent, auditable deployments and accelerate changes.

  • Avoid hardcoding secrets: Never embed sensitive information directly in your Terraform configurations. Use a dedicated secrets management solution for production environments.

WRITTEN BY

Ahmet Çelik

Former AWS Solutions Architect, 8 years in cloud and infrastructure. Computer Engineering graduate, Bilkent University. Lead writer for AWS, Terraform and Kubernetes content.Read more

Responses (0)

    Hottest authors

    View all

    Ahmet Çelik

    Lead Writer · ex-AWS Solutions Architect, 8 yrs · AWS, Terraform, K8s

    Alp Karahan

    Contributor · MongoDB certified, NoSQL specialist · MongoDB, DynamoDB

    Ayşe Tunç

    Lead Writer · Engineering Manager, ex-Meta, Google · System Design, Interviews

    Berk Avcı

    Lead Writer · Principal Backend Eng., API design · REST, GraphQL, gRPC

    Burak Arslan

    Managing Editor · Content strategy, developer marketing

    Cansu Yılmaz

    Lead Writer · Database Architect, 9 yrs Postgres · PostgreSQL, Indexing, Perf

    Popular posts

    View all
    Murat Doğan
    ·

    <h1 class="text-3xl font-bold mb-4">Azure DevOps Pipelines: Serverless Functions CI/CD</h1>

    <h1 class="text-3xl font-bold mb-4">Azure DevOps Pipelines: Serverless Functions CI/CD</h1>
    Ozan Kılıç
    ·

    SAST vs DAST vs IAST: Explaining AppSec Testing Tools

    SAST vs DAST vs IAST: Explaining AppSec Testing Tools
    Murat Doğan
    ·

    Azure Managed Identity Explained: Secure Access Simplified

    Azure Managed Identity Explained: Secure Access Simplified
    Ahmet Çelik
    ·

    Terraform AWS Tutorial: Production-Ready IaC Foundations

    Terraform AWS Tutorial: Production-Ready IaC Foundations
    Deniz Şahin
    ·

    Google Cloud Run Tutorial: Deploy Scalable Services

    Google Cloud Run Tutorial: Deploy Scalable Services
    Ahmet Çelik
    ·

    S3 Intelligent-Tiering vs Glacier: A Cost Analysis

    S3 Intelligent-Tiering vs Glacier: A Cost Analysis