Provision a secure, auditable, cloud-native environment using Terraform on AWS, deployed via GitHub Actions using OIDC (no long-lived secrets).
This project demonstrates DevSecOps best practice (βDevOps done properlyβ): least-privilege IAM, encrypted storage, audit logging, CI/CD guardrails, and remote state with locking.
- ποΈ Hardened Platform-as-Code (Terraform Β· AWS Β· GitHub Actions)
- Table of Contents
- β¨ TL;DR
- π§± Architecture (High Level)
- π Security Decisions (Secure-by-Default)
- π¦ Whatβs Included
- π§ Prerequisites
- π Bootstrapping Remote State (One-off)
- βοΈ Configuration
βΆοΈ Usage- π€ Example Outputs
- ποΈ Repo Structure
- π§ͺ CI/CD Overview (GitHub Actions)
- π§ Design Notes & Rationale
- π§© Optional Enhancements
- π° Costs & Clean-up
- What it builds: VPC, hardened EC2, encrypted S3 (logs), IAM (least privilege), Security Groups, CloudTrail.
- How it deploys: GitHub Actions with OIDC role assumption β
plan
on PR/merge; manualapply
viaworkflow_dispatch
. - State: S3 backend with DynamoDB locking (via a small bootstrap stack).
- Security: S3 SSE, TLS-only bucket policy, IAM least privilege, EC2 with IMDSv2 & SSM Session Manager preferred, CloudTrail β S3.
GitHub Repo (Terraform + Workflow) βββΆ GitHub Actions (OIDC) βββΆ AWS Account
- fmt/validate/plan
- manual apply onlyβββββββββββββββββ
β VPC + Subnet β
β + Route β
ββββββββ¬βββββββββ
β
ββββββββββββΌβββββββββββ
β EC2 (Amazon Linux) β
β - IMDSv2 required β
β - SSM by default β
βββββββββββ¬ββββββββββββ
β
ββββββββββββΌβββββββββββ
β S3 (logs, private) β
β - SSE, TLS only β
β - CloudTrail logs β
ββββββββββββββββββββββββ
Area | Control | Why it matters |
---|---|---|
Identity & Access | OIDC from GitHub Actions to AWS IAM Role (no long-lived keys) | Eliminates static secrets; short-lived, auditable credentials. |
Terraform State | S3 backend + state locking | Prevents concurrent applies; centralised, durable state. |
Logging & Audit | CloudTrail (multi-region) β encrypted S3 | Full API audit trail for investigations and compliance. |
Storage | S3 bucket: SSE (AES256), block public access, TLS-only policy, versioning, lifecycle | Confidentiality, integrity, and basic retention controls; denies unencrypted or non-TLS requests. |
Compute | EC2 with IMDSv2 required | Reduces credential exposure; avoids opening port 22 to the Internet. |
Network | Security Groups: default-deny inbound; explicit, minimal ingress | Least-privilege networking reduces attack surface. |
CI/CD Guardrails | Plan on PR/merge; apply only via manual trigger; backend health checks; job concurrency | Safe deployment flow; fail fast if remote state is unavailable; no overlapping runs. |
Note: SSE-KMS with a CMK can be used if you want stronger key control; this project uses SSE-S3 for simplicity.
- Terraform modules:
vpc
,security_group
,iam
,s3
,cloudtrail
,ec2
,key_pair
- Root config: backend, providers, variables, outputs
- GitHub Actions:
.github/workflows/terraform-provision.yml
(OIDC, fmt/validate/plan, manual apply, outputs) - Defence-in-depth S3 bucket policy: TLS-only + CloudTrail write permissions
- Lifecycle rule with
filter { prefix = "" }
(provider-compatible; applies to all objects)
- AWS account with permissions to assume the GitHub OIDC role used by the workflow
- Terraform v1.13+
- A pre-created S3 bucket for remote state and a DynamoDB table for locks (via the bootstrap stack below)
Use a tiny βbootstrapβ stack (separate repo) to create:
- S3 bucket:
my-eu-tf-state-bucket
- DynamoDB table:
terraform-locks
Then the root projectβs backend.tf
points at:
terraform {
backend "s3" {
bucket = "my-eu-tf-state-bucket"
key = "envs/dev/terraform.tfstate"
region = "eu-west-2"
encrypt = true
dynamodb_table = "terraform-locks" # enables state locking
}
}
Edit terraform.tfvars
(or supply via CI variables):
# ---------- EC2 ----------
...
allowed_ips = ["20.0.100.1/32"]
public_key = "ssh-ed25519 AAAAC3NzaC1lZDI1NTE5AAAAIMockPublicKeyForTestingOnlyDoNotUseInProduction"
Defaults target London (eu-west-2).
- Configure the repository secret
AWS_OIDC_ROLE
with the ARN of your AWS IAM role for GitHub OIDC. - Push a PR β workflow runs
fmt/validate/plan
(no apply). - Merge to
main
β workflow re-checks and plans again. - From Actions β Run workflow β manually trigger apply (
workflow_dispatch
). - Outputs are printed as a dedicated step (JSON), e.g. EC2 public IP and SSH helper.
Guardrails in the workflow
-
Backend sanity checks:
aws s3api head-bucket
andaws dynamodb describe-table
-
Concurrency control: one run per branch
-
Apply only when manually approved (no automatic applies)
terraform init
terraform validate
terraform plan -out=tfplan
terraform apply tfplan
terraform output -json
{
"ec2_public_ip": { "value": "18.135.15.200" },
"public_subnet_id": { "value": "subnet-0dff5aeb2b8a6fed1" },
"ssh_command": { "value": "ssh [email protected] -i ~/.ssh/id_aws_ed25519" },
"vpc_id": { "value": "vpc-029d36bdff4b18fff" }
}
project1_refactor/
βββ backend.tf
βββ main.tf
βββ outputs.tf
βββ providers.tf
βββ terraform.tfvars
βββ variables.tf
βββ modules/
β βββ vpc/
β βββ security_group/
β βββ iam/
β βββ s3/ # owns bucket + full bucket policy (incl. CloudTrail permissions)
β βββ cloudtrail/ # consumes bucket name; no policy duplication
β βββ ec2/
β βββ key_pair/
βββ .github/workflows/terraform-provision.yml
-
Triggers
pull_request
β pre-mergefmt/validate/plan
push
tomain
β post-mergeplan
workflow_dispatch
β manual apply
-
Credentials: OIDC β
aws-actions/configure-aws-credentials@v4
withrole-to-assume: ${{ secrets.AWS_OIDC_ROLE }}
-
Safety
- Remote state S3/DynamoDB checks before init
- Concurrency guard per branch
- Apply never runs automatically
- Bucket policy ownership lives in the S3 module (single source of truth); the CloudTrail module only references the bucket. This avoids policy drift and respects module boundaries (DRY).
- Lifecycle rule uses
filter { prefix = "" }
which is required by newer AWS provider versions and applies to all objects.
- Swap S3 SSE-S3 for SSE-KMS (CMK) and restrict key usage
- Add tfsec/tflint jobs to the pipeline
- Multi-environment pattern (e.g.,
envs/dev/staging/prod
with separate state keys) - GuardDuty / AWS Config integration
- Post-provision tests (e.g., curl health checks) before surfacing outputs
This uses Free-Tier-friendly resources, but charges can accrue.
Destroy when youβre done:
terraform destroy