DEV Community

Cover image for Modular AWS Infrastructure With Terraform: 2-Tier VPC, CIS-Hardened EC2, OIDC CI/CD, and a $6 Sprint Model
cypher682
cypher682

Posted on

Modular AWS Infrastructure With Terraform: 2-Tier VPC, CIS-Hardened EC2, OIDC CI/CD, and a $6 Sprint Model

infra-core is a production-grade AWS infrastructure platform built with Terraform 1.7, Ansible, and GitHub Actions. It provisions a complete multi-environment AWS stack — 2-tier VPC, EC2 Auto Scaling Group, RDS PostgreSQL, ALB, SSM-based secrets, CloudWatch observability, and a fully automated CI/CD pipeline using OIDC federation.

This article documents the system as built: architecture, module design, security decisions, configuration management, and the cost model that makes it viable for portfolio and proof-of-concept use.

Repo: cypher682/infra-core


Architecture

Internet
  │ HTTP :80
  ▼
ALB (public subnets, 2 AZs)
  │
  ▼
EC2 ASG (private subnets, 2 AZs)
  │ :5432
  ▼
RDS PostgreSQL (private subnets)

SSM Session Manager ──► EC2            (no SSH port, no bastion)
SSM Parameter Store ──► app secrets    (pulled at runtime)
S3 ────────────────────► VPC flow logs + app assets
CloudWatch ────────────► metrics, log groups, alarms, SNS, dashboard
GitHub Actions OIDC ──► Terraform CI/CD (no static credentials)
Enter fullscreen mode Exit fullscreen mode

The VPC enforces a hard 2-tier separation. Public subnets hold the ALB and NAT Gateway. Private subnets hold the EC2 ASG and RDS. No EC2 instance is directly reachable from the internet. RDS is reachable only from the application security group (sg-app) on port 5432 — not from the ALB, not from a bastion, not from the internet.


Module Design

The infrastructure is composed of eight Terraform modules, each owning a single concern:

modules/
  vpc/        — VPC, subnets, SGs, NAT GW, VPC flow logs
  iam/        — GitHub OIDC role, EC2 instance profile
  s3/         — app assets + flow logs buckets
  ssm/        — Parameter Store secret namespace
  rds/        — PostgreSQL, subnet group, parameter group
  alb/        — ALB, target group, HTTP listener
  ec2-asg/    — launch template, ASG, CPU scaling policies
  cloudwatch/ — alarms, log groups, SNS topic, dashboard
Enter fullscreen mode Exit fullscreen mode

Each module receives only what it needs. ec2-asg takes sg_app_id, target_group_arn, and ec2_instance_profile_name — not the full VPC object. cloudwatch takes alb_arn_suffix and target_group_arn_suffix as direct outputs from the ALB module, not derived strings.

One specific pattern worth noting — the ALB module exposes arn_suffix directly from the AWS provider resource, rather than leaving consumers to parse it from the full ARN:

# modules/alb/outputs.tf
output "alb_arn_suffix" {
  value = aws_lb.main.arn_suffix
}

output "target_group_arn_suffix" {
  value = aws_lb_target_group.app.arn_suffix
}
Enter fullscreen mode Exit fullscreen mode

The AWS provider exposes this natively. Using split(":", arn)[5] is fragile — if the ARN format changes, CloudWatch alarms silently bind to a wrong dimension string. Using the native attribute avoids that entirely.


Security: No Bastion Host

The conventional EC2 access pattern uses an SSH jump box in a public subnet. It requires an open port 22, a key pair, and an additional instance to maintain and patch.

AWS Systems Manager Session Manager replaces all of it:

  • No port 22 in any security group
  • No key pairs distributed or stored
  • No bastion instance
  • Access gated by IAM policy, not network rules
  • Session activity logged to CloudWatch automatically

The EC2 IAM role is granted AmazonSSMManagedInstanceCore. The SSM agent ships pre-installed on Amazon Linux 2023. No additional setup.

# Interactive shell on a private EC2 instance — no SSH, no key pair
aws ssm start-session --target <instance-id>
Enter fullscreen mode Exit fullscreen mode

This is also the mechanism Ansible uses. The dynamic inventory plugin queries EC2 by tag and connects via the SSM connection plugin — no static IP addresses, no host files that drift after an ASG refresh.


Security: OIDC for GitHub Actions

Storing AWS_ACCESS_KEY_ID and AWS_SECRET_ACCESS_KEY in GitHub Secrets is the default pattern. These are long-lived credentials with no automatic expiry, and they carry real risk if leaked.

The alternative is OIDC federation. GitHub Actions generates a short-lived JWT per job run. AWS validates it against the registered OIDC provider and issues temporary session credentials. No long-lived keys exist.

# modules/iam/main.tf

resource "aws_iam_openid_connect_provider" "github" {
  url             = "https://token.actions.githubusercontent.com"
  client_id_list  = ["sts.amazonaws.com"]
  thumbprint_list = ["6938fd4d98bab03faadb97b34396831e3780aea1"]
}

data "aws_iam_policy_document" "github_actions_assume" {
  statement {
    effect  = "Allow"
    actions = ["sts:AssumeRoleWithWebIdentity"]

    principals {
      type        = "Federated"
      identifiers = [aws_iam_openid_connect_provider.github.arn]
    }

    condition {
      test     = "StringLike"
      variable = "token.actions.githubusercontent.com:sub"
      values   = ["repo:${var.github_org}/${var.github_repo}:*"]
    }
  }
}
Enter fullscreen mode Exit fullscreen mode

The StringLike condition scopes the role to a single repository. No other repo in the org can assume it.


Secrets: SSM Parameter Store

All credentials are stored as SecureString parameters in SSM Parameter Store, encrypted with the AWS managed KMS key at no additional cost. The EC2 instance profile grants read access scoped to the project namespace only:

"arn:aws:ssm:${var.aws_region}:${var.aws_account_id}:parameter/${var.project}/*"
Enter fullscreen mode Exit fullscreen mode

The application reads secrets at runtime via the AWS SDK. Credentials never appear in Terraform state, .env files committed to source control, or CI/CD logs.

SSM Parameter Store standard parameters cost $0. AWS Secrets Manager costs $0.40/secret/month. For this use case, SSM is the correct choice.


Configuration Management: Ansible

Terraform provisions the infrastructure. Ansible handles instance configuration. Six roles, run in sequence:

common/      — system update, NTP, non-root app user, directory layout
hardening/   — CIS AL2023 baseline: fail2ban, auditd, root SSH disabled, sysctl hardening
docker/      — Docker install + daemon hardening (no-new-privileges, live-restore)
nginx/       — reverse proxy configuration, security headers
monitoring/  — CloudWatch agent fetch-config from SSM + start
app-deploy/  — SSM secret fetch, docker pull from ECR, container run
Enter fullscreen mode Exit fullscreen mode

The CloudWatch agent configuration is stored as a JSON parameter in SSM, managed by Terraform:

# modules/cloudwatch/main.tf
resource "aws_ssm_parameter" "cloudwatch_agent_config" {
  name  = "/AmazonCloudWatch-linux-${var.project}-${var.environment}"
  type  = "String"
  value = jsonencode({ ... })
}
Enter fullscreen mode Exit fullscreen mode

The monitoring Ansible role runs amazon-cloudwatch-agent-ctl --fetch-config ssm:/<param-name>. Configuration is owned by Terraform, consumed by Ansible, applied to the instance. Clean handoff between layers.

The hardening role applies the CIS Amazon Linux 2023 baseline:

  • Root SSH login disabled
  • Password authentication disabled — key-based only
  • fail2ban with SSH brute-force protection
  • auditd rules for sensitive file monitoring (/etc/passwd, /etc/sudoers, /etc/ssh/sshd_config)
  • Sysctl hardening: IP forwarding off, source routing disabled, ICMP redirects disabled, SYN cookies enabled

CI/CD Pipeline

Two GitHub Actions workflows, both authenticated via OIDC:

tf-plan.yml — triggered on every pull request to main:

  1. Configure AWS credentials (OIDC — no static keys)
  2. terraform init
  3. terraform validate
  4. Checkov IaC security scan — pipeline blocks on failure
  5. terraform plan with scoped variable injection
  6. Plan output posted as a PR comment

tf-apply.yml — triggered on merge to main:

  1. Configure AWS credentials (OIDC)
  2. terraform apply -auto-approve
  3. Retrieve ALB DNS name from Terraform output
  4. Wait 60 seconds for EC2 instances to register with the target group
  5. HTTP health check against /health — 5 retries, 15-second interval
  6. Exit 0 on HTTP 200, pipeline failure on any other outcome

The smoke test is a real HTTP check against the provisioned ALB. If the EC2 instances fail to register within the health check grace period, the pipeline fails and the merge is flagged.


Cost Model

Continuous operation of this stack would cost approximately $75–100/month. The sprint model eliminates idle cost entirely: provision, collect evidence, destroy.

Resource Hourly Rate 2-Day Total
NAT Gateway $0.045/hr ~$2.20
ALB $0.008/hr ~$0.40
EC2 t3.micro x1 $0.0104/hr ~$0.50
RDS db.t3.micro $0.017/hr ~$0.82
S3 + CloudWatch minimal ~$0.10
Total ~$4–6

A single NAT Gateway serves both AZs in dev and staging. Multi-AZ NAT Gateway would double the NAT cost (~$2.20 → ~$4.40) with no reliability benefit in non-production environments — NAT Gateway itself is the dominant cost driver.


Multi-Environment

Dev and staging are identical in structure, differentiated by CIDR blocks to allow simultaneous deployment without VPC overlap:

  • dev: 10.0.0.0/16 — public 10.0.1-2.0/24, private 10.0.10-11.0/24
  • staging: 10.1.0.0/16 — public 10.1.1-2.0/24, private 10.1.10-11.0/24

Each environment has its own Terraform state, its own SSM parameter namespace (/infra-core/dev/*, /infra-core/staging/*), and its own CloudWatch log groups and alarms.


What Comes Next

The following are documented in code but not yet enabled — intentional trade-offs for sprint cost control:

  • S3 remote backend with DynamoDB lock — commented-out block in environments/*/main.tf, ready to uncomment for team use
  • HTTPS listener — full listener block documented in modules/alb/main.tf; add ACM cert ARN and flip the listener
  • Multi-AZ RDSmulti_az = true in modules/rds/main.tf; disabled for cost
  • Kubernetes — this architecture maps directly: EC2 ASG → Deployment, ALB → Ingress Controller, RDS → managed database, SSM Parameter Store → Secrets Store CSI Driver

Repo: cypher682/infra-core

Top comments (0)