}

Terraform Tutorial 2026: Deploy AWS Infrastructure for Python Apps — VPC, EC2, RDS, S3

Terraform Tutorial 2026: Deploy AWS Infrastructure for Python Apps

Infrastructure as Code (IaC) turned infrastructure from a manual, error-prone process into something you can version, review, test, and roll back like application code. Terraform, created by HashiCorp in 2014 and open-sourced under the BSL 1.1 license since 2023, remains the most widely-adopted IaC tool in the industry. Its declarative model, massive provider ecosystem, and the Terraform Registry make it possible to express a complete cloud environment — VPCs, compute, databases, object storage, DNS, CDN — in a few hundred lines of HCL.

This tutorial walks through building a production-grade AWS stack for a Python web application from scratch: a VPC with public and private subnets across two availability zones, an EC2 instance running your application, an RDS PostgreSQL database in the private subnet, and an S3 bucket for static assets. Along the way you will configure remote state in S3 with DynamoDB locking, use the official AWS VPC module from the Terraform Registry, manage environment-specific values with terraform.tfvars, and separate environments using Terraform workspaces. Cost estimates are included so you know what this stack costs before you apply it.

If you prefer the fully open-source fork, everything in this tutorial runs identically on OpenTofu, the Linux Foundation fork of Terraform that emerged after the license change. Simply replace terraform with tofu in every command.

Prerequisites

  • Terraform 1.8 or later installed (terraform -version). Download from developer.hashicorp.com/terraform/downloads.
  • An AWS account with programmatic access configured (~/.aws/credentials or environment variables AWS_ACCESS_KEY_ID / AWS_SECRET_ACCESS_KEY).
  • The AWS CLI installed and a default region set (aws configure).
  • Basic familiarity with the Linux command line.

Install Terraform on Ubuntu/Debian:

wget -O - https://apt.releases.hashicorp.com/gpg | sudo gpg --dearmor -o /usr/share/keyrings/hashicorp-archive-keyring.gpg
echo "deb [signed-by=/usr/share/keyrings/hashicorp-archive-keyring.gpg] https://apt.releases.hashicorp.com $(lsb_release -cs) main" \
  | sudo tee /etc/apt/sources.list.d/hashicorp.list
sudo apt update && sudo apt install terraform
terraform -version

What Terraform Is and How HCL Works

Terraform reads configuration files written in HashiCorp Configuration Language (HCL), determines the difference between your desired state and the current state of the real infrastructure, and applies only the changes needed to reconcile them. The state is persisted in a file (or a remote backend) so Terraform knows what it already manages.

HCL is designed to be readable by humans and parseable by machines. A configuration file is made up of blocks. The most common blocks are:

Block type Purpose
terraform Global settings, required provider versions, backend
provider Configures a cloud or SaaS provider
resource Declares a piece of infrastructure to manage
data Reads existing infrastructure without managing it
variable Declares an input parameter
output Exposes a value after apply
locals Defines computed local values
module Calls a reusable module

A minimal resource block looks like this:

resource "aws_s3_bucket" "assets" {
  bucket = "my-app-assets-2026"

  tags = {
    Environment = "production"
    Project     = "python-app"
  }
}

The string "aws_s3_bucket" is the resource type (defined by the AWS provider). "assets" is the local name you use to reference this resource elsewhere in your configuration as aws_s3_bucket.assets. Attributes inside the block configure the resource.

References to other resources use dot notation: aws_s3_bucket.assets.id gives you the bucket ID once it is created.

Project Layout

Terraform does not require a specific file layout, but a conventional structure for a single-environment stack is:

infra/
├── main.tf          # Core resources and module calls
├── variables.tf     # Variable declarations
├── outputs.tf       # Output declarations
├── terraform.tfvars # Variable values (gitignored if sensitive)
├── versions.tf      # Required providers and Terraform version
└── backend.tf       # Remote state configuration

Create the infra/ directory and work inside it for the rest of this tutorial.

mkdir -p infra && cd infra

Provider and Version Pinning

Always pin provider versions. An unpinned provider can silently upgrade between runs and break your configuration. Create versions.tf:

# versions.tf

terraform {
  required_version = ">= 1.8.0"

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

provider "aws" {
  region = var.aws_region

  default_tags {
    tags = {
      Project     = var.project_name
      Environment = terraform.workspace
      ManagedBy   = "terraform"
    }
  }
}

The ~> 5.50 constraint means "any 5.x version >= 5.50". The default_tags block in the AWS provider (available since provider version 3.38) automatically applies those tags to every resource that supports tagging — you no longer need to repeat them on every resource.

Variables

Declare all input parameters in variables.tf:

# variables.tf

variable "aws_region" {
  description = "AWS region to deploy into"
  type        = string
  default     = "us-east-1"
}

variable "project_name" {
  description = "Short name used in resource names and tags"
  type        = string
  default     = "pyapp"
}

variable "environment" {
  description = "Deployment environment (dev, staging, prod)"
  type        = string
  default     = "dev"
}

variable "vpc_cidr" {
  description = "CIDR block for the VPC"
  type        = string
  default     = "10.0.0.0/16"
}

variable "ec2_instance_type" {
  description = "EC2 instance type for the application server"
  type        = string
  default     = "t3.small"
}

variable "ec2_ami" {
  description = "AMI ID for the EC2 instance (Ubuntu 24.04 LTS in us-east-1)"
  type        = string
  default     = "ami-0c7217cdde317cfec"  # Ubuntu 24.04 LTS, us-east-1
}

variable "db_instance_class" {
  description = "RDS instance class"
  type        = string
  default     = "db.t3.micro"
}

variable "db_name" {
  description = "Name of the PostgreSQL database"
  type        = string
  default     = "pyapp"
}

variable "db_username" {
  description = "Master username for RDS"
  type        = string
  default     = "pyapp_admin"
}

variable "db_password" {
  description = "Master password for RDS — use a secrets manager in production"
  type        = string
  sensitive   = true
}

variable "s3_bucket_name" {
  description = "Globally unique S3 bucket name for static assets"
  type        = string
}

Supply values in terraform.tfvars. This file is automatically loaded by Terraform and should be listed in .gitignore if it contains secrets:

# terraform.tfvars

aws_region        = "us-east-1"
project_name      = "pyapp"
environment       = "dev"
vpc_cidr          = "10.0.0.0/16"
ec2_instance_type = "t3.small"
db_instance_class = "db.t3.micro"
db_name           = "pyapp"
db_username       = "pyapp_admin"
db_password       = "change-me-use-secrets-manager"
s3_bucket_name    = "pyapp-assets-dev-a1b2c3"

VPC with the Official AWS VPC Module

Building a VPC manually — subnets, route tables, associations, gateway attachments — takes 150+ lines of HCL and is easy to get wrong. The terraform-aws-modules/vpc module from the Terraform Registry is the community standard. It is maintained by the same team behind many official AWS Terraform modules and covers every VPC pattern.

In main.tf, call the module:

# main.tf

locals {
  name_prefix = "${var.project_name}-${terraform.workspace}"
  azs         = ["${var.aws_region}a", "${var.aws_region}b"]
}

module "vpc" {
  source  = "terraform-aws-modules/vpc/aws"
  version = "~> 5.8"

  name = "${local.name_prefix}-vpc"
  cidr = var.vpc_cidr

  azs             = local.azs
  public_subnets  = ["10.0.1.0/24", "10.0.2.0/24"]
  private_subnets = ["10.0.101.0/24", "10.0.102.0/24"]

  enable_nat_gateway     = true
  single_nat_gateway     = true   # set false in prod for HA
  enable_dns_hostnames   = true
  enable_dns_support     = true

  public_subnet_tags = {
    "kubernetes.io/role/elb" = "1"   # useful if you add EKS later
  }

  private_subnet_tags = {
    "kubernetes.io/role/internal-elb" = "1"
  }
}

This single module call creates: - A VPC with the specified CIDR - Two public subnets (one per AZ) with an Internet Gateway - Two private subnets (one per AZ) - A NAT Gateway in the first public subnet so instances in private subnets can reach the internet - All route tables and associations

single_nat_gateway = true reduces cost in non-production environments. In production, set it to false so each AZ has its own NAT Gateway, eliminating a single point of failure.

Security Groups

# Security group for the EC2 application server
resource "aws_security_group" "app" {
  name        = "${local.name_prefix}-app-sg"
  description = "Allow HTTP/HTTPS inbound; all outbound"
  vpc_id      = module.vpc.vpc_id

  ingress {
    description = "HTTP"
    from_port   = 80
    to_port     = 80
    protocol    = "tcp"
    cidr_blocks = ["0.0.0.0/0"]
  }

  ingress {
    description = "HTTPS"
    from_port   = 443
    to_port     = 443
    protocol    = "tcp"
    cidr_blocks = ["0.0.0.0/0"]
  }

  ingress {
    description = "SSH — restrict to your IP in production"
    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"]
  }
}

# Security group for RDS — only accept connections from the app SG
resource "aws_security_group" "rds" {
  name        = "${local.name_prefix}-rds-sg"
  description = "Allow PostgreSQL from app security group only"
  vpc_id      = module.vpc.vpc_id

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

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

Scoping the RDS ingress rule to security_groups rather than a CIDR block is a key security practice: only EC2 instances associated with the app security group can reach the database port, regardless of what IP addresses those instances have.

EC2 Instance

# Key pair — generate with: ssh-keygen -t ed25519 -f ~/.ssh/pyapp
resource "aws_key_pair" "app" {
  key_name   = "${local.name_prefix}-key"
  public_key = file("~/.ssh/pyapp.pub")
}

resource "aws_instance" "app" {
  ami                         = var.ec2_ami
  instance_type               = var.ec2_instance_type
  subnet_id                   = module.vpc.public_subnets[0]
  vpc_security_group_ids      = [aws_security_group.app.id]
  key_name                    = aws_key_pair.app.key_name
  associate_public_ip_address = true

  root_block_device {
    volume_type           = "gp3"
    volume_size           = 20
    delete_on_termination = true
    encrypted             = true
  }

  user_data = <<-EOF
    #!/bin/bash
    set -e
    apt-get update -y
    apt-get install -y python3-pip python3-venv nginx
    # Your application bootstrap here
  EOF

  lifecycle {
    ignore_changes = [ami]   # prevent replacement on AMI updates mid-cycle
  }
}

# Elastic IP so the address survives stop/start cycles
resource "aws_eip" "app" {
  instance = aws_instance.app.id
  domain   = "vpc"
}

RDS PostgreSQL

resource "aws_db_subnet_group" "main" {
  name       = "${local.name_prefix}-db-subnet-group"
  subnet_ids = module.vpc.private_subnets

  description = "Subnet group for ${local.name_prefix} RDS instance"
}

resource "aws_db_instance" "postgres" {
  identifier              = "${local.name_prefix}-postgres"
  engine                  = "postgres"
  engine_version          = "16.3"
  instance_class          = var.db_instance_class
  allocated_storage       = 20
  max_allocated_storage   = 100          # enable autoscaling up to 100 GiB
  storage_type            = "gp3"
  storage_encrypted       = true

  db_name  = var.db_name
  username = var.db_username
  password = var.db_password

  db_subnet_group_name   = aws_db_subnet_group.main.name
  vpc_security_group_ids = [aws_security_group.rds.id]

  multi_az               = false         # set true in prod (~2x cost)
  publicly_accessible    = false
  skip_final_snapshot    = false
  final_snapshot_identifier = "${local.name_prefix}-final-snapshot"

  backup_retention_period = 7
  backup_window           = "03:00-04:00"
  maintenance_window      = "Mon:04:00-Mon:05:00"

  deletion_protection = false            # set true in prod
}

The max_allocated_storage attribute enables RDS Storage Autoscaling, which expands storage automatically when free space falls below a threshold. storage_encrypted = true enables encryption at rest using the default AWS-managed key at no additional cost.

S3 Bucket for Static Assets

resource "aws_s3_bucket" "assets" {
  bucket = var.s3_bucket_name
}

resource "aws_s3_bucket_versioning" "assets" {
  bucket = aws_s3_bucket.assets.id
  versioning_configuration {
    status = "Enabled"
  }
}

resource "aws_s3_bucket_server_side_encryption_configuration" "assets" {
  bucket = aws_s3_bucket.assets.id
  rule {
    apply_server_side_encryption_by_default {
      sse_algorithm = "AES256"
    }
  }
}

resource "aws_s3_bucket_public_access_block" "assets" {
  bucket                  = aws_s3_bucket.assets.id
  block_public_acls       = true
  block_public_policy     = true
  ignore_public_acls      = true
  restrict_public_buckets = true
}

# IAM policy allowing EC2 to read/write the bucket
resource "aws_iam_role" "app" {
  name = "${local.name_prefix}-app-role"
  assume_role_policy = jsonencode({
    Version = "2012-10-17"
    Statement = [{
      Action    = "sts:AssumeRole"
      Effect    = "Allow"
      Principal = { Service = "ec2.amazonaws.com" }
    }]
  })
}

resource "aws_iam_role_policy" "s3_access" {
  name = "${local.name_prefix}-s3-access"
  role = aws_iam_role.app.id

  policy = jsonencode({
    Version = "2012-10-17"
    Statement = [{
      Effect   = "Allow"
      Action   = ["s3:GetObject", "s3:PutObject", "s3:DeleteObject", "s3:ListBucket"]
      Resource = [
        aws_s3_bucket.assets.arn,
        "${aws_s3_bucket.assets.arn}/*"
      ]
    }]
  })
}

resource "aws_iam_instance_profile" "app" {
  name = "${local.name_prefix}-app-profile"
  role = aws_iam_role.app.name
}

Attach the instance profile to the EC2 resource by adding iam_instance_profile = aws_iam_instance_profile.app.name to aws_instance.app.

Outputs

# outputs.tf

output "vpc_id" {
  description = "ID of the VPC"
  value       = module.vpc.vpc_id
}

output "public_subnets" {
  description = "IDs of the public subnets"
  value       = module.vpc.public_subnets
}

output "private_subnets" {
  description = "IDs of the private subnets"
  value       = module.vpc.private_subnets
}

output "app_public_ip" {
  description = "Elastic IP of the application server"
  value       = aws_eip.app.public_ip
}

output "rds_endpoint" {
  description = "RDS PostgreSQL connection endpoint"
  value       = aws_db_instance.postgres.endpoint
  sensitive   = true
}

output "s3_bucket_name" {
  description = "Name of the S3 assets bucket"
  value       = aws_s3_bucket.assets.bucket
}

After terraform apply, retrieve sensitive outputs with terraform output -raw rds_endpoint.

Remote State: S3 Backend with DynamoDB Locking

By default Terraform stores state in a local terraform.tfstate file. This breaks immediately when multiple engineers or CI pipelines run Terraform concurrently: two simultaneous applies can corrupt state. The solution is a remote backend.

The S3 backend stores state in an S3 bucket and uses a DynamoDB table for distributed locking. You must create these resources before configuring the backend, because Terraform needs the backend to store its own state — a bootstrapping problem. Create them once with the AWS CLI or a separate Terraform root:

# Create the state bucket (versioning is critical — it is your undo history)
aws s3api create-bucket \
  --bucket pyapp-terraform-state-2026 \
  --region us-east-1

aws s3api put-bucket-versioning \
  --bucket pyapp-terraform-state-2026 \
  --versioning-configuration Status=Enabled

aws s3api put-bucket-encryption \
  --bucket pyapp-terraform-state-2026 \
  --server-side-encryption-configuration \
  '{"Rules":[{"ApplyServerSideEncryptionByDefault":{"SSEAlgorithm":"AES256"}}]}'

# Block all public access
aws s3api put-public-access-block \
  --bucket pyapp-terraform-state-2026 \
  --public-access-block-configuration \
  "BlockPublicAcls=true,IgnorePublicAcls=true,BlockPublicPolicy=true,RestrictPublicBuckets=true"

# Create the DynamoDB table for state locking
aws dynamodb create-table \
  --table-name pyapp-terraform-locks \
  --attribute-definitions AttributeName=LockID,AttributeType=S \
  --key-schema AttributeName=LockID,KeyType=HASH \
  --billing-mode PAY_PER_REQUEST \
  --region us-east-1

Now configure the backend in backend.tf:

# backend.tf

terraform {
  backend "s3" {
    bucket         = "pyapp-terraform-state-2026"
    key            = "infra/terraform.tfstate"
    region         = "us-east-1"
    encrypt        = true
    dynamodb_table = "pyapp-terraform-locks"
  }
}

Run terraform init after adding the backend configuration. Terraform will ask whether to copy any existing local state to the remote bucket.

When Terraform acquires the lock, it writes a record to DynamoDB with the LockID set to <bucket>/<key>. Any concurrent terraform apply or terraform plan -lock=true will fail immediately with a clear error message rather than silently corrupting state.

Terraform Workflow: init, plan, apply, destroy

# 1. Initialize: download providers and modules, configure backend
terraform init

# 2. Format code consistently (run this before every commit)
terraform fmt -recursive

# 3. Validate syntax and internal consistency
terraform validate

# 4. Generate and review the execution plan
terraform plan -out=tfplan

# 5. Apply the plan (uses the saved plan — no surprises)
terraform apply tfplan

# 6. Tear everything down (irreversible — use with care)
terraform destroy

Always save the plan with -out=tfplan and apply the saved plan rather than running terraform apply without a plan file. This guarantees that what you reviewed in the plan is exactly what gets applied, even if resources changed between plan and apply.

terraform fmt reformats your HCL to canonical style: consistent indentation, aligned equals signs in argument blocks, and sorted meta-arguments. Run it as a pre-commit hook:

# .git/hooks/pre-commit
#!/bin/sh
terraform fmt -check -recursive

Linting with tflint

terraform validate checks syntax but not best practices. TFLint is a pluggable linter that catches issues like deprecated arguments, missing tags, and invalid instance types before they reach AWS.

# Install tflint
curl -s https://raw.githubusercontent.com/terraform-linters/tflint/master/install_linux.sh | bash

# Initialize with the AWS ruleset plugin
tflint --init

# Run in the infra directory
tflint --recursive

Create .tflint.hcl to configure the AWS plugin:

plugin "aws" {
  enabled = true
  version = "0.32.0"
  source  = "github.com/terraform-linters/tflint-ruleset-aws"
}

TFLint will warn you about things like: using an instance type that does not exist in the target region, missing required tags, deprecated resource arguments, and security group rules that are too permissive.

Workspaces for dev/staging/prod

Terraform workspaces allow a single configuration to manage multiple independent state files. Each workspace gets its own state key under infra/<workspace>/terraform.tfstate in the S3 backend.

# Create and switch to a staging workspace
terraform workspace new staging
terraform workspace list
# * staging
#   default

# Apply with staging-specific variables
terraform apply -var-file=staging.tfvars

# Switch back to default (dev)
terraform workspace select default

In the configuration, terraform.workspace evaluates to the current workspace name. The default_tags in the provider configuration already includes Environment = terraform.workspace, so every resource is tagged with its environment automatically.

Create environment-specific variable files:

infra/
├── terraform.tfvars      # dev (default workspace)
├── staging.tfvars        # staging workspace
└── prod.tfvars           # prod workspace

prod.tfvars would override instance sizes and enable production-grade options:

# prod.tfvars
ec2_instance_type = "t3.medium"
db_instance_class = "db.t3.small"

You would also change single_nat_gateway = false, multi_az = true, and deletion_protection = true for the prod workspace — these can be driven by a local variable that checks terraform.workspace == "prod":

locals {
  is_prod = terraform.workspace == "prod"
}

Then reference local.is_prod in the module and resource arguments.

Estimated Monthly Cost

The following estimates use AWS us-east-1 on-demand pricing as of May 2026. Actual costs depend on traffic, storage growth, and data transfer.

Resource Configuration Est. Monthly Cost
EC2 t3.small 730 hrs/month ~$15
Elastic IP Attached to running instance $0
NAT Gateway 1x, ~10 GB/month data processed ~$35
RDS db.t3.micro PostgreSQL 16, 20 GiB gp3, single-AZ ~$15
S3 10 GiB storage + 1M GET requests ~$1
EBS gp3 20 GiB EC2 root volume ~$1.60
DynamoDB PAY_PER_REQUEST, minimal lock traffic ~$0
S3 state bucket < 1 GiB ~$0.02
Total (dev) ~$68/month

The NAT Gateway dominates cost. For a development environment that does not need internet access from private subnets, you can set enable_nat_gateway = false to drop the monthly bill to around $32.

For a production environment with multi_az = true (RDS ~$30), single_nat_gateway = false (two NAT Gateways ~$70), and a larger EC2 instance (t3.medium ~$30), expect approximately $135–150/month before data transfer charges.

Use AWS Cost Explorer after a few days of running to see actual costs, and set a billing alarm in CloudWatch to alert you if charges exceed a threshold.

Connecting Your Python App to the Stack

Once deployed, your Python application needs the RDS endpoint and S3 bucket name. Retrieve them:

terraform output -raw rds_endpoint
# pyapp-dev-postgres.cxxxxxx.us-east-1.rds.amazonaws.com:5432

terraform output s3_bucket_name
# pyapp-assets-dev-a1b2c3

In your Django or FastAPI application, read these from environment variables rather than hardcoding them. On the EC2 instance, set them in /etc/environment or inject them via AWS Systems Manager Parameter Store. A typical Django settings.py pattern:

import os

DATABASES = {
    "default": {
        "ENGINE": "django.db.backends.postgresql",
        "HOST": os.environ["DB_HOST"],
        "PORT": os.environ.get("DB_PORT", "5432"),
        "NAME": os.environ["DB_NAME"],
        "USER": os.environ["DB_USER"],
        "PASSWORD": os.environ["DB_PASSWORD"],
    }
}

AWS_STORAGE_BUCKET_NAME = os.environ["S3_BUCKET_NAME"]
AWS_S3_REGION_NAME = os.environ.get("AWS_REGION", "us-east-1")

Because the EC2 instance has an IAM instance profile with S3 permissions, the AWS SDK (boto3) will automatically pick up credentials from the instance metadata service. You do not need to store AWS_ACCESS_KEY_ID or AWS_SECRET_ACCESS_KEY on the instance.

OpenTofu: The Open-Source Alternative

In August 2023, HashiCorp changed Terraform's license from MPL 2.0 to BSL 1.1, which restricts use in competing products. The Linux Foundation responded by forking Terraform at version 1.5.5 as OpenTofu. OpenTofu 1.8 is feature-compatible with Terraform 1.8 and adds a few capabilities not yet in upstream Terraform, including early evaluation of variables and provider-defined functions.

Every code example in this tutorial runs unmodified on OpenTofu. Install it with:

# Ubuntu/Debian
curl --proto '=https' --tlsv1.2 -fsSL https://get.opentofu.org/install-opentofu.sh | sh -s -- --install-method deb
tofu version

Replace terraform with tofu in all commands. The terraform {} block in HCL is also accepted as-is — OpenTofu does not require you to rename it.

What to Do Next

The stack you have built is a solid foundation. Some natural next steps:

  • Add an Application Load Balancer in front of the EC2 instance to enable TLS termination and health checks. The terraform-aws-modules/alb module handles this in roughly 20 lines.
  • Use AWS Secrets Manager for the database password instead of terraform.tfvars. The aws_secretsmanager_secret resource stores the secret, and your application retrieves it at runtime via the AWS SDK.
  • Add CloudWatch alarms for CPU, database connections, and free storage space. The aws_cloudwatch_metric_alarm resource pairs well with an SNS topic for email/Slack notifications.
  • Enable AWS Config and the aws_config_configuration_recorder resource to continuously record configuration changes and detect drift.
  • Consider Terragrunt if you need to manage many environments or accounts. Terragrunt wraps Terraform, DRYing up backend configuration and enabling a layered variable system.

The complete source for this tutorial is structured to be dropped directly into a new repository and applied with terraform init && terraform apply. Keep your terraform.tfvars out of version control (add it to .gitignore), commit everything else, and you have auditable, reproducible infrastructure.