DevOps & CI/CD20 min read·By Liyabona Saki·

Infrastructure as Code with Terraform — Deploy AWS Resources Like a Pro

Master Terraform for AWS: workflow, state management, modules, VPC + EC2 + RDS + S3, GitHub Actions CI/CD pipeline, security and production best practices.

Advertisement

Introduction

Infrastructure as Code (IaC) replaces click-driven cloud consoles with declarative source files that describe exactly what your infrastructure should look like. Terraform is the most widely adopted IaC tool because it is cloud-agnostic, has a mature provider ecosystem, and uses a simple, readable language (HCL) that diffs cleanly in pull requests.

This tutorial is a complete, production-grade walkthrough of Terraform on AWS. You will learn the workflow, write a real VPC with public and private subnets, provision EC2, RDS and S3, wire a GitHub Actions CI/CD pipeline with remote state in S3, and apply the security and operational practices that separate a working demo from a system you can hand to an on-call engineer.

Why Terraform matters

Modern teams cannot manage cloud infrastructure by hand. Three reasons make Terraform the default tool of choice:

  • Reproducibility. The same configuration produces the same infrastructure in every environment.
  • Reviewability. Every change is a pull request. Reviewers see the exact resources that will be created, modified or destroyed.
  • Disaster recovery. When a region goes down, you re-run terraform apply against another and your stack is back in minutes, not days.

Terraform also abstracts away each cloud's quirks behind a uniform language, which matters as soon as you operate in more than one cloud or want to swap a managed service for a cheaper equivalent.

Architecture

Terraform Workflow

DEVELOPERCODEPLANAPPLYCLOUDwrite HCLread stateapprovedcreate / updateDeveloperTerraform Codemain.tf · variables.tfState FileS3 + DynamoDB lockAWS Providerhashicorp/awsterraform plandiff desired vs actualterraform applyexecutes change setAWS ResourcesVPC · EC2 · RDS · S3
Developers describe infrastructure as code. terraform plan diffs against the state file via the AWS provider, then terraform apply provisions the matching cloud resources.

Real-world use cases

  • Greenfield AWS accounts. Bootstrap a brand-new account end-to-end: IAM, VPC, EKS, RDS, observability stack.
  • Multi-account platforms. Apply identical baselines across dozens of accounts via Terraform workspaces or Terragrunt.
  • Compliance and audit. Hand auditors the repo instead of a screen recording of the console.
  • Cost control. Tag every resource at creation time and drive showback or chargeback off the state file.
  • Disaster recovery drills. Tear down and rebuild a non-prod environment weekly to prove the code is the truth.

Architecture overview

The infrastructure we will build is a classic three-tier AWS stack: a VPC with public and private subnets, an Application Load Balancer in the public tier, EC2 application servers and an RDS PostgreSQL database in the private tier, and an S3 bucket for static assets.

Architecture

AWS Infrastructure Built with Terraform

INTERNETEDGEPUBLIC SUBNETPRIVATE SUBNETSTORAGEHTTPSroute tableSSH 22forward :8080adminSQL · SG 5432GetObjectInternet UserInternet GatewayVPC entryApplication Load BalancerTLS terminationBastion HostSSH jump boxEC2 App ServersAuto Scaling GroupRDS PostgreSQLMulti-AZS3 BucketStatic assets
A VPC with an Internet Gateway routes public traffic to the ALB and bastion. Application servers in the private subnet talk to RDS and read assets from S3, with security groups gating every hop.

Step 1 — Install Terraform and configure AWS credentials

```bash
# macOS
brew tap hashicorp/tap && brew install hashicorp/tap/terraform

# Linux curl -fsSL https://apt.releases.hashicorp.com/gpg | sudo gpg --dearmor -o /usr/share/keyrings/hashicorp.gpg echo "deb [signed-by=/usr/share/keyrings/hashicorp.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 ```

Configure short-lived credentials via AWS SSO or an IAM role. Never use long-lived access keys for human users:

bash
aws configure sso
aws sts get-caller-identity

Step 2 — Project layout

A small but production-ready layout:

text
infra/
  versions.tf          # required providers + Terraform version
  backend.tf           # remote state config
  providers.tf         # provider configuration
  variables.tf         # input variables
  main.tf              # composition of modules
  outputs.tf           # exported values
  modules/
    vpc/
    security-groups/
    ec2/
    rds/
    s3/

Step 3 — Pin versions and configure the provider

hcl
# versions.tf
terraform {
  required_version = ">= 1.7"
  required_providers {
    aws = {
      source  = "hashicorp/aws"
      version = "~> 5.50"
    }
  }
}
hcl
# providers.tf
provider "aws" {
  region = var.region
  default_tags {
    tags = {
      Project     = var.project
      Environment = var.environment
      ManagedBy   = "terraform"
    }
  }
}

Step 4 — Configure remote state in S3 + DynamoDB lock

Local state files are fine for tinkering, never for a team. Use an S3 bucket with a DynamoDB lock table.

hcl
# backend.tf
terraform {
  backend "s3" {
    bucket         = "acme-tf-state"
    key            = "platform/prod/terraform.tfstate"
    region         = "eu-west-1"
    dynamodb_table = "acme-tf-locks"
    encrypt        = true
  }
}

Create the bucket and table once, manually (or in a separate bootstrap module), with versioning and SSE enabled:

bash
aws s3api create-bucket --bucket acme-tf-state --region eu-west-1 \
  --create-bucket-configuration LocationConstraint=eu-west-1
aws s3api put-bucket-versioning --bucket acme-tf-state \
  --versioning-configuration Status=Enabled
aws dynamodb create-table --table-name acme-tf-locks \
  --attribute-definitions AttributeName=LockID,AttributeType=S \
  --key-schema AttributeName=LockID,KeyType=HASH \
  --billing-mode PAY_PER_REQUEST

Step 5 — Build the VPC

```hcl
# modules/vpc/main.tf
resource "aws_vpc" "this" {
  cidr_block           = var.cidr
  enable_dns_hostnames = true
  enable_dns_support   = true
  tags = { Name = "${var.name}-vpc" }
}

resource "aws_internet_gateway" "this" { vpc_id = aws_vpc.this.id tags = { Name = "${var.name}-igw" } }

resource "aws_subnet" "public" { for_each = toset(var.public_subnets) vpc_id = aws_vpc.this.id cidr_block = each.value availability_zone = element(var.azs, index(var.public_subnets, each.value)) map_public_ip_on_launch = true tags = { Name = "${var.name}-public-${each.value}" } }

resource "aws_subnet" "private" { for_each = toset(var.private_subnets) vpc_id = aws_vpc.this.id cidr_block = each.value availability_zone = element(var.azs, index(var.private_subnets, each.value)) tags = { Name = "${var.name}-private-${each.value}" } }

resource "aws_route_table" "public" { vpc_id = aws_vpc.this.id route { cidr_block = "0.0.0.0/0" gateway_id = aws_internet_gateway.this.id } }

resource "aws_route_table_association" "public" { for_each = aws_subnet.public subnet_id = each.value.id route_table_id = aws_route_table.public.id } ```

Step 6 — Security groups

Never reuse the default SG. Define one per role:

```hcl
# modules/security-groups/main.tf
resource "aws_security_group" "alb" {
  name        = "${var.name}-alb"
  vpc_id      = var.vpc_id
  description = "Public HTTPS to ALB"

ingress { from_port = 443 to_port = 443 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_security_group" "app" { name = "${var.name}-app" vpc_id = var.vpc_id

ingress { from_port = 8080 to_port = 8080 protocol = "tcp" security_groups = [aws_security_group.alb.id] } egress { from_port = 0 to_port = 0 protocol = "-1" cidr_blocks = ["0.0.0.0/0"] } }

resource "aws_security_group" "rds" { name = "${var.name}-rds" vpc_id = var.vpc_id ingress { from_port = 5432 to_port = 5432 protocol = "tcp" security_groups = [aws_security_group.app.id] } } ```

Step 7 — EC2 Auto Scaling Group + ALB

```hcl
# modules/ec2/main.tf
resource "aws_launch_template" "app" {
  name_prefix   = "${var.name}-"
  image_id      = var.ami_id
  instance_type = var.instance_type
  user_data     = base64encode(file("${path.module}/cloud-init.sh"))
  vpc_security_group_ids = [var.app_sg_id]
}

resource "aws_autoscaling_group" "app" { name = "${var.name}-asg" min_size = 2 max_size = 8 desired_capacity = 3 vpc_zone_identifier = var.private_subnet_ids target_group_arns = [aws_lb_target_group.app.arn] launch_template { id = aws_launch_template.app.id, version = "$Latest" } }

resource "aws_lb" "this" { name = "${var.name}-alb" load_balancer_type = "application" subnets = var.public_subnet_ids security_groups = [var.alb_sg_id] }

resource "aws_lb_target_group" "app" { name = "${var.name}-tg" port = 8080 protocol = "HTTP" vpc_id = var.vpc_id target_type = "instance" health_check { path = "/actuator/health", matcher = "200" } } ```

Step 8 — RDS PostgreSQL

```hcl
# modules/rds/main.tf
resource "aws_db_subnet_group" "this" {
  name       = "${var.name}-db-subnets"
  subnet_ids = var.private_subnet_ids
}

resource "aws_db_instance" "this" { identifier = "${var.name}-db" engine = "postgres" engine_version = "16.3" instance_class = "db.t4g.medium" allocated_storage = 50 storage_encrypted = true multi_az = true db_subnet_group_name = aws_db_subnet_group.this.name vpc_security_group_ids = [var.rds_sg_id] username = var.db_username password = var.db_password # source from AWS Secrets Manager in production backup_retention_period = 7 deletion_protection = true skip_final_snapshot = false } ```

Step 9 — S3 bucket for static assets

```hcl
# modules/s3/main.tf
resource "aws_s3_bucket" "assets" {
  bucket = "${var.name}-assets"
}

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 }

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" } } } ```

Step 10 — Compose the modules

```hcl
# main.tf
module "vpc" {
  source          = "./modules/vpc"
  name            = "${var.project}-${var.environment}"
  cidr            = "10.20.0.0/16"
  azs             = ["eu-west-1a", "eu-west-1b", "eu-west-1c"]
  public_subnets  = ["10.20.1.0/24", "10.20.2.0/24", "10.20.3.0/24"]
  private_subnets = ["10.20.11.0/24", "10.20.12.0/24", "10.20.13.0/24"]
}

module "sg" { source = "./modules/security-groups" name = "${var.project}-${var.environment}" vpc_id = module.vpc.vpc_id }

module "ec2" { source = "./modules/ec2" name = "${var.project}-${var.environment}" vpc_id = module.vpc.vpc_id public_subnet_ids = module.vpc.public_subnet_ids private_subnet_ids = module.vpc.private_subnet_ids alb_sg_id = module.sg.alb_sg_id app_sg_id = module.sg.app_sg_id ami_id = data.aws_ami.al2023.id instance_type = "t3.small" }

module "rds" { source = "./modules/rds" name = "${var.project}-${var.environment}" private_subnet_ids = module.vpc.private_subnet_ids rds_sg_id = module.sg.rds_sg_id db_username = var.db_username db_password = var.db_password }

module "s3" { source = "./modules/s3" name = "${var.project}-${var.environment}" } ```

Step 11 — Apply

bash
terraform init
terraform fmt -recursive
terraform validate
terraform plan -out tfplan
terraform apply tfplan

CI/CD pipeline with GitHub Actions

Manual terraform apply does not scale. Wire a pipeline that plans on every PR and applies on merge after manual approval.

Architecture

Terraform CI/CD Pipeline

SOURCECIPLANAPPROVALAPPLYTARGETtriggerreview diffapprovedlock + write stateprovisionGitHubPR / mergeGitHub Actionsworkflow.ymlterraform plandiff posted on PRManual Approvalenvironment gateterraform applyexecutes changeS3 Backendremote stateTerraform Cloudoptional runsAWS EnvironmentVPC · EC2 · RDS
GitHub Actions runs terraform plan on every PR, posts the diff for review, then runs terraform apply against the remote S3 backend after approval — fully auditable infrastructure changes.
```yaml
# .github/workflows/terraform.yml
name: terraform

on: pull_request: paths: ['infra/'] push: branches: [main] paths: ['infra/']

permissions: id-token: write # for AWS OIDC contents: read pull-requests: write

jobs: plan: runs-on: ubuntu-latest defaults: { run: { working-directory: infra } } steps: - uses: actions/checkout@v4 - uses: aws-actions/configure-aws-credentials@v4 with: role-to-assume: arn:aws:iam::123456789012:role/github-actions-tf aws-region: eu-west-1 - uses: hashicorp/setup-terraform@v3 with: { terraform_version: 1.7.5 } - run: terraform init - run: terraform fmt -check -recursive - run: terraform validate - run: terraform plan -out tfplan -no-color - name: Comment plan on PR if: github.event_name == 'pull_request' uses: actions/github-script@v7 with: script: | const fs = require('fs'); // upload-artifact + comment formatting omitted for brevity

apply: needs: plan if: github.ref == 'refs/heads/main' runs-on: ubuntu-latest environment: production # GitHub environment with required reviewers defaults: { run: { working-directory: infra } } steps: - uses: actions/checkout@v4 - uses: aws-actions/configure-aws-credentials@v4 with: role-to-assume: arn:aws:iam::123456789012:role/github-actions-tf aws-region: eu-west-1 - uses: hashicorp/setup-terraform@v3 - run: terraform init - run: terraform apply -auto-approve ```

The environment: production line is the manual approval gate — only listed reviewers can release the apply step.

Security considerations

  • Use OIDC, not long-lived access keys, for CI. GitHub Actions ↔ AWS via aws-actions/configure-aws-credentials.
  • Encrypt the state file. S3 SSE + bucket key, plus a DynamoDB lock to prevent concurrent applies.
  • Never commit secrets. Source DB passwords from AWS Secrets Manager (aws_secretsmanager_secret_version data source).
  • Scope IAM to the resources Terraform manages. Avoid *.
  • Tag everything for cost allocation and incident response.
  • Pin provider versions to avoid surprise upgrades.
  • Run tfsec or checkov in CI to catch insecure defaults (open security groups, unencrypted volumes).

Production best practices

  • Modules per concern, composition in the root. Reuse, do not copy.
  • Separate state per environment. Different S3 keys for dev/staging/prod. Never share state files.
  • Use -target sparingly. It hides drift. Prefer surgical commits.
  • Remote backends only in production. Local state is a recipe for lost infrastructure.
  • Plan before apply, always. Even in CI. A plan you did not read is a deploy you do not trust.
  • Treat terraform.lock.hcl as code. Commit it. Re-run terraform init -upgrade deliberately.
  • Drift detection. Run terraform plan on a schedule and alert on non-empty diffs.

Common mistakes

  • Editing infrastructure in the console while Terraform owns it. Subsequent plans will revert your change.
  • Hardcoding AWS account IDs and region in modules. Pass them as variables.
  • One giant root module. Splits poorly, plans slowly. Refactor as soon as you have more than ~50 resources.
  • Skipping terraform fmt and validate in CI. Both are free and catch real bugs.
  • Using count when for_each is correct. count re-indexes on removal and destroys the wrong resource.
  • Forgetting depends_on for implicit relationships. Terraform's graph cannot infer everything.

Troubleshooting

  • Error acquiring the state lock — a previous run crashed. Inspect with terraform force-unlock <id> only after confirming no other apply is running.
  • InvalidParameterCombination: DB parameter group ... has no value — RDS engine version mismatch with parameter group family.
  • Provider produced inconsistent final plan — usually a known provider bug. Pin to a tested version.
  • Plan: 0 to add, 0 to change, 0 to destroy but a resource has drifted — run terraform refresh then plan again; some attributes are computed only on refresh.
  • VPC limit reached — AWS soft limit on VPCs per region. Request an increase.

FAQ

Should I use Terraform or AWS CDK / Pulumi? Terraform if you want a declarative, language-neutral, multi-cloud tool with the largest community. CDK or Pulumi if your team strongly prefers a general-purpose programming language.

Modules vs Terragrunt? Modules cover most teams. Terragrunt adds DRY composition for very large multi-account estates and is worth it when you have ≥10 environments.

How do I structure state? One state file per environment, per region, per major bounded context (network, data, apps). Avoid one giant state.

How do I handle secrets? Source from AWS Secrets Manager or HashiCorp Vault at apply time. Never commit them. Mark sensitive variables with sensitive = true.

Can Terraform manage Kubernetes resources? Yes via the kubernetes and helm providers, but most teams prefer GitOps (ArgoCD, Flux) for in-cluster resources. Use Terraform for the cluster itself.

How do I move a resource between modules? Use terraform state mv or, in Terraform ≥1.5, the moved block. Never delete-and-recreate in production.

What is the difference between terraform plan and terraform apply? plan computes the diff between desired and actual state. apply executes that diff.

How do I roll back a bad apply? Revert the offending commit and run terraform apply again. Terraform will compute the inverse diff. Some destructive operations (database deletion) cannot be undone — use prevent_destroy lifecycle rules and snapshots.

Is Terraform free? The CLI is open source. Terraform Cloud / Enterprise add collaboration, policy-as-code and a hosted state backend.

How do I keep providers updated? Use Dependabot or Renovate to bump required_providers versions. Pair every bump with a plan in CI.

Key takeaways

  • Terraform is the declarative, reviewable, reversible way to manage cloud infrastructure.
  • Always run with remote state, locking, and version pinning in production.
  • Structure code as small modules composed in a root, with one state file per environment.
  • Wire a plan-on-PR, apply-on-merge GitHub Actions pipeline behind a manual approval.
  • Treat security (OIDC, encryption, least-privilege IAM, tfsec) as part of the definition of done.

Related tutorials

Conclusion

Terraform is the bridge between developers and the cloud. Once you have a versioned, peer-reviewed pipeline that plans on every PR and applies on every merge, your infrastructure stops being a source of incidents and starts being just another part of your application. The modules and pipeline in this tutorial are production-ready — fork the companion repo, swap in your AWS account, and you will have a hardened VPC, ALB, EC2, RDS and S3 stack live in under an hour.

Architecture

CI/CD Deployment Pipeline

SOURCECIREGISTRYDEPLOYRUNTIMEpushimagedeploypromoteGitHubmain branchCI PipelineBuild · TestContainer RegistryImageStagingKubernetesProductionKubernetesUsersMonitoringPrometheus / Grafana
Code pushed to the repository triggers automated build and test. The artifact is published to a registry then promoted from staging to production.

TL;DR

Key takeaways

  • Understand the core concepts behind Infrastructure as Code with Terraform — Deploy AWS Resources Like a Pro in a production context.
  • Apply the patterns to real DevOps & CI/CD systems, not just toy examples.
  • Recognize the trade-offs, failure modes, and operational concerns before adopting them.
  • Get a clear path to the next step — related tutorials, tools, and reference architectures.

Avoid these

Common mistakes

  • 1. Copy-pasting code without understanding the trade-offs

    It's tempting to ship a snippet from a blog post into production, but DevOps & CI/CD patterns only work when the failure modes are understood. Always reason about timeouts, retries, and consistency.

  • 2. Skipping observability from day one

    Structured logs, metrics, and traces are not optional. Wire them in before you ship — debugging DevOps & CI/CD systems without them is painful and expensive.

  • 3. Optimizing too early

    Premature caching, sharding, or microservice extraction adds operational cost. Validate the bottleneck with real measurements first.

  • 4. Ignoring security defaults

    Secrets in env files, open management ports, missing RBAC — these are the most common production incidents. Treat security as part of the definition of done.

Ship it safely

Production best practices

Apply these before promoting Infrastructure as Code with Terraform — Deploy AWS Resources Like a Pro to a real production environment.

Scalability

Design DevOps & CI/CD services to scale horizontally. Keep request handlers stateless, push session and cache state to external stores (Redis, the database), and benchmark p95/p99 latency under realistic load before tuning.

Monitoring & Observability

Emit metrics (RED/USE), structured JSON logs, and distributed traces from day one. Wire dashboards and alerts to SLOs you actually care about — error rate, latency, saturation — not vanity metrics.

Logging

Log with correlation IDs, never log secrets or PII, and centralize logs (ELK, Loki, CloudWatch). Use levels deliberately: INFO for state changes, WARN for recoverable issues, ERROR for incidents.

Security

Apply least-privilege IAM, rotate secrets through a vault, validate every input, and patch dependencies on a schedule. For HTTP services, enable TLS everywhere and set sensible security headers.

Testing

Layer unit, integration, and contract tests. Run them in CI on every PR, and add smoke tests post-deploy. For DevOps & CI/CD systems, also run chaos and load tests before a major release.

Reliability & Rollouts

Ship with health checks, readiness probes, graceful shutdown, and a rollback strategy. Prefer canary or blue/green deploys over big-bang releases.

Questions

Frequently asked questions

Is this tutorial up to date?

Yes. This tutorial was last reviewed and updated on June 2, 2026. We revisit popular DevOps & CI/CD tutorials regularly to keep them aligned with current best practices.

What level is this tutorial aimed at?

It is written for working developers with some backend experience. Beginners can still follow along, and senior engineers will find production-grade patterns and trade-off discussions.

Do I need to follow every step in order?

The walkthrough is sequential because each step depends on the previous one. If you only need a specific concept, the table of contents at the top of the article lets you jump straight to that section.

Where can I find the source code?

The full source code is available on GitHub: https://github.com/masterlabsystems/terraform-aws-starter. Fork it, run it locally, and adapt it to your own project.

Go deeper

Further reading

Source Code

Get the full project on GitHub

View repo →
#Terraform#AWS#Infrastructure as Code#DevOps#CI/CD

More From the Channel

Follow the full tutorial series on YouTube

The MasterLabSystems channel publishes in-depth, project-based tutorials on Java, Spring Boot, microservices, Docker, Kubernetes, AWS and DevOps — the same topics covered on this site, with full code walkthroughs.

Stay in the Loop

Get the next tutorial in your inbox

next tutorial →

Observability in Microservices — Prometheus, Grafana and OpenTelemetry

Related tutorials