A public aws-platform repository containing about 400 lines of HCL split into network, database and service modules; a running stack reachable at an ALB DNS name that returns your API's greeting and reports a healthy database connection; the state in S3 with lockfile locking; an infra.yml workflow that posts the plan on pull requests and applies on merge using OIDC; and a terraform destroy you have run at least once and rebuilt from, proving the environment is reproducible.
- The zero-to-prod project completed — you need the
ghcr.io/YOUR_USER/zero-to-prodimage, the AWS account, the AWS CLI logged in, and the GitHub OIDC provider it created - The DevOps track's Terraform, cloud and networking modules (VPC, subnets, security groups, IAM roles)
- A credit card on the AWS account. Everything here is small, but a NAT gateway, an ALB and RDS are not free-tier; expect roughly $0.10 per hour while the stack is up, so destroy it when you stop for the day
- Terraform 1.10+ — the S3 backend's native lockfile locking needs 1.10 or newer, so no DynamoDB table ↗
- AWS CLI v2 — bootstrapping the state bucket, checking what Terraform built, reading logs ↗
- TFLint — catches invalid instance types, deprecated arguments and unused variables before
plandoes ↗ - GitHub CLI — creating the repo, setting variables, opening and merging pull requests from the terminal ↗
- psql — one connection to prove the database works; install the PostgreSQL client only, not the server ↗
aws-platform/
├── .github/workflows/
│ └── infra.yml # fmt/validate/tflint, plan on PR, apply on merge
├── modules/
│ ├── network/ # VPC, subnets, IGW, NAT, route tables
│ │ ├── main.tf
│ │ ├── variables.tf
│ │ └── outputs.tf
│ ├── database/ # RDS Postgres, subnet group, SG, secret
│ │ ├── main.tf
│ │ ├── variables.tf
│ │ └── outputs.tf
│ └── service/ # ALB, ECS cluster + Fargate service, IAM, logs
│ ├── main.tf
│ ├── variables.tf
│ └── outputs.tf
├── envs/
│ └── dev/
│ ├── main.tf # wires the three modules together
│ ├── backend.tf # S3 remote state
│ ├── versions.tf
│ ├── variables.tf
│ ├── outputs.tf
│ └── dev.tfvars # non-secret values, committed
├── .tflint.hcl
├── .gitignore
└── README.mdTick each step as you finish it — your progress is saved in this browser only (back up or restore on the hub). Every code block has a copy button. If something goes wrong, the troubleshooting section at the end covers the usual suspects.
Set up the repository and a spending guardrail
A new repository with the module layout, pinned tool versions, and an AWS budget alert so a forgotten stack cannot surprise you.
- Check the tools. Terraform must be 1.10 or newer for lockfile-based state locking; anything older needs a DynamoDB table, which this project deliberately avoids.bash
terraform version aws --version tflint --version gh --version aws sts get-caller-identity # confirms which account you are about to build in - Create a budget with an email alert at $5. AWS Budgets is free; the alert arrives when forecast or actual spend crosses the threshold, which is exactly the safety net you want while learning.bash
ACCOUNT=$(aws sts get-caller-identity --query Account --output text) cat > budget.json <<EOF {"BudgetName": "aws-platform-guardrail", "BudgetLimit": {"Amount": "5", "Unit": "USD"}, "TimeUnit": "MONTHLY", "BudgetType": "COST"} EOF cat > notify.json <<EOF [{"Notification": {"NotificationType": "ACTUAL", "ComparisonOperator": "GREATER_THAN", "Threshold": 80}, "Subscribers": [{"SubscriptionType": "EMAIL", "Address": "you@example.com"}]}] EOF aws budgets create-budget --account-id "$ACCOUNT" --budget file://budget.json --notifications-with-subscribers file://notify.json rm budget.json notify.jsonPut your real address in before running it. The alert fires at 80% of $5; raise the limit later if you keep the stack up for longer experiments. - Create the repository and the directory layout. Modules hold reusable pieces;
envs/devis the one place that says which pieces, in which region, with which sizes.bashmkdir aws-platform && cd aws-platform git init -b main mkdir -p modules/network modules/database modules/service envs/dev .github/workflows gh repo create aws-platform --public --source=. --remote=origin - Add a
.gitignorethat keeps state, provider binaries and local variable files out of the repo. The committeddev.tfvarsholds only non-secret values; anything secret comes from AWS itself.text.terraform/ *.tfstate *.tfstate.* crash.log *.auto.tfvars secret.tfvars .terraform.lock.hcl.bak tfplan - Pin the provider and Terraform versions in
envs/dev/versions.tf. A pinned provider means the plan you review today is the plan that applies tomorrow.hclterraform { required_version = ">= 1.10, < 2.0" required_providers { aws = { source = "hashicorp/aws" version = "~> 5.80" } random = { source = "hashicorp/random" version = "~> 3.6" } } } provider "aws" { region = var.region default_tags { tags = { project = "aws-platform" environment = "dev" managed_by = "terraform" } } }default_tagsstamps every resource. When you look at the bill or the console later, you can filter onproject=aws-platformand see exactly what this repo owns. - Commit the skeleton so the first pull request has something to diff against.bash
git add . git commit -m "chore: module layout, pinned versions, gitignore" git push -u origin main
Remote state with locking
State lives in a versioned, encrypted S3 bucket and every apply takes a lock, so two people (or you and the pipeline) cannot corrupt it.
- Create the state bucket by hand — it is the one resource Terraform cannot manage for itself, because it has to exist before the first
init. Versioning gives you every previous state; public access is blocked outright.bashACCOUNT=$(aws sts get-caller-identity --query Account --output text) REGION=eu-west-1 BUCKET="tfstate-aws-platform-$ACCOUNT" aws s3api create-bucket --bucket "$BUCKET" --region "$REGION" --create-bucket-configuration LocationConstraint="$REGION" aws s3api put-bucket-versioning --bucket "$BUCKET" --versioning-configuration Status=Enabled aws s3api put-bucket-encryption --bucket "$BUCKET" --server-side-encryption-configuration '{"Rules":[{"ApplyServerSideEncryptionByDefault":{"SSEAlgorithm":"AES256"}}]}' aws s3api put-public-access-block --bucket "$BUCKET" --public-access-block-configuration BlockPublicAcls=true,IgnorePublicAcls=true,BlockPublicPolicy=true,RestrictPublicBuckets=true echo "$BUCKET"If you did the zero-to-prod project you already have atfstate-zero-to-prod-…bucket. Reusing it with a differentkeyis fine; a bucket per project is also fine. Do not share a key. - Write
envs/dev/backend.tf.use_lockfile = truemakes Terraform createterraform.tfstate.tflockin the bucket for the duration of a run, which is the locking mechanism that replaced the DynamoDB table.hclterraform { backend "s3" { bucket = "tfstate-aws-platform-123456789012" key = "envs/dev/terraform.tfstate" region = "eu-west-1" encrypt = true use_lockfile = true } } - Declare the environment's inputs in
envs/dev/variables.tfand give them values inenvs/dev/dev.tfvars. The image is the one your zero-to-prod release workflow publishes.hcl# envs/dev/variables.tf variable "region" { type = string default = "eu-west-1" } variable "name" { type = string default = "aws-platform-dev" } variable "vpc_cidr" { type = string default = "10.20.0.0/16" } variable "image" { description = "Container image the service runs" type = string } variable "desired_count" { type = number default = 2 } variable "db_instance_class" { type = string default = "db.t4g.micro" } # envs/dev/dev.tfvars # image = "ghcr.io/YOUR_GITHUB_USER/zero-to-prod:latest" # desired_count = 2Write the twodev.tfvarslines into their own file without the leading#. They are shown here in one block so the variables and their values sit together. - Initialise against the remote backend and confirm the lock works by holding one open in a second terminal.bash
cd envs/dev terraform init terraform plan -var-file=dev.tfvars # nothing to build yet; it should say 'No changes' # in a second terminal, inside envs/dev: # terraform console (keeps the state lock while open) # back in the first terminal: terraform plan -var-file=dev.tfvars # -> 'Error acquiring the state lock' until you exit the consoleSeeing the lock error once, on purpose, is worth more than reading about it. Close the console and the plan goes through.
The network module
A VPC with two public and two private subnets across two availability zones, an internet gateway for the public side, and one NAT gateway so private tasks can pull images and reach AWS APIs without being reachable themselves.
- Declare the module's inputs in
modules/network/variables.tf. Taking the availability zones as data rather than hard-coding them makes the module work in any region.hclvariable "name" { type = string } variable "cidr" { type = string } variable "az_count" { type = number default = 2 } - Write
modules/network/main.tf: the VPC, one public and one private subnet per AZ, an internet gateway, and a single NAT gateway with an Elastic IP.cidrsubnetcarves/20blocks out of the VPC range so the addressing is derived, not typed.hcldata "aws_availability_zones" "available" { state = "available" } locals { azs = slice(data.aws_availability_zones.available.names, 0, var.az_count) } resource "aws_vpc" "this" { cidr_block = var.cidr enable_dns_support = true enable_dns_hostnames = true tags = { Name = var.name } } resource "aws_internet_gateway" "this" { vpc_id = aws_vpc.this.id tags = { Name = var.name } } resource "aws_subnet" "public" { count = var.az_count vpc_id = aws_vpc.this.id cidr_block = cidrsubnet(var.cidr, 4, count.index) availability_zone = local.azs[count.index] map_public_ip_on_launch = true tags = { Name = "${var.name}-public-${local.azs[count.index]}", tier = "public" } } resource "aws_subnet" "private" { count = var.az_count vpc_id = aws_vpc.this.id cidr_block = cidrsubnet(var.cidr, 4, count.index + 8) availability_zone = local.azs[count.index] tags = { Name = "${var.name}-private-${local.azs[count.index]}", tier = "private" } } resource "aws_eip" "nat" { domain = "vpc" tags = { Name = "${var.name}-nat" } } resource "aws_nat_gateway" "this" { allocation_id = aws_eip.nat.id subnet_id = aws_subnet.public[0].id tags = { Name = var.name } depends_on = [aws_internet_gateway.this] } 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 } tags = { Name = "${var.name}-public" } } resource "aws_route_table" "private" { vpc_id = aws_vpc.this.id route { cidr_block = "0.0.0.0/0" nat_gateway_id = aws_nat_gateway.this.id } tags = { Name = "${var.name}-private" } } resource "aws_route_table_association" "public" { count = var.az_count subnet_id = aws_subnet.public[count.index].id route_table_id = aws_route_table.public.id } resource "aws_route_table_association" "private" { count = var.az_count subnet_id = aws_subnet.private[count.index].id route_table_id = aws_route_table.private.id }One NAT gateway is a deliberate dev-environment trade-off: it costs about $0.045/hour and is a single point of failure for outbound traffic. Production runs one per AZ; the change iscount = var.az_counton the EIP, the gateway and the private route table. - Expose what the other modules need in
modules/network/outputs.tf.hcloutput "vpc_id" { value = aws_vpc.this.id } output "public_subnet_ids" { value = aws_subnet.public[*].id } output "private_subnet_ids" { value = aws_subnet.private[*].id } output "vpc_cidr" { value = aws_vpc.this.cidr_block } - Wire the module into
envs/dev/main.tfand apply just the network first. Building in layers keeps each plan small enough to actually read.hclmodule "network" { source = "../../modules/network" name = var.name cidr = var.vpc_cidr az_count = 2 } - Format, validate, plan and apply. Read the plan: it should add exactly 14 resources — 1 VPC, 4 subnets, 1 IGW, 1 EIP, 1 NAT gateway, 2 route tables, 4 associations. The availability-zone data source is read, not created, so it does not count.bash
cd envs/dev terraform fmt -recursive .. terraform validate terraform plan -var-file=dev.tfvars -out=tfplan terraform apply tfplan aws ec2 describe-subnets --filters Name=tag:project,Values=aws-platform \ --query 'Subnets[].{az:AvailabilityZone,cidr:CidrBlock,tier:Tags[?Key==`tier`].Value|[0]}' --output tableThe NAT gateway takes a minute or two to become available; Terraform waits for it. If your count differs, read the plan to find out why before applying — that habit is the whole skill. - Commit the module on a branch and merge it. From here on, every phase is a pull request, so you build the habit before the pipeline enforces it.bash
cd ../.. git switch -c feat/network git add modules/network envs/dev git commit -m "feat(network): VPC with public/private subnets, IGW and NAT" git push -u origin feat/network gh pr create --fill && gh pr merge --squash --delete-branch git switch main && git pull
The database module
A Postgres instance in the private subnets that only the service's security group can reach, with a generated password stored in Secrets Manager and never written to disk or state as plain text.
- Declare the inputs in
modules/database/variables.tf. The module takes the security group that is allowed to connect rather than a CIDR, so the rule follows the service wherever it runs.hclvariable "name" { type = string } variable "vpc_id" { type = string } variable "subnet_ids" { type = list(string) } variable "allowed_security_group_id" { description = "Security group of the workload that may connect on 5432" type = string } variable "instance_class" { type = string default = "db.t4g.micro" } variable "db_name" { type = string default = "app" } - Write
modules/database/main.tf.random_passwordgenerates the password inside Terraform, the secret stores it, and RDS reads it from the resource — nobody types it and it never appears in a tfvars file.hclresource "aws_db_subnet_group" "this" { name = var.name subnet_ids = var.subnet_ids } resource "aws_security_group" "db" { name = "${var.name}-db" description = "Postgres, reachable only from the service" vpc_id = var.vpc_id ingress { from_port = 5432 to_port = 5432 protocol = "tcp" security_groups = [var.allowed_security_group_id] } egress { from_port = 0 to_port = 0 protocol = "-1" cidr_blocks = ["0.0.0.0/0"] } } resource "random_password" "db" { length = 32 special = true override_special = "!#$%^&*()-_=+" } resource "aws_secretsmanager_secret" "db" { name = "${var.name}/database" recovery_window_in_days = 0 } resource "aws_db_instance" "this" { identifier = var.name engine = "postgres" engine_version = "16" instance_class = var.instance_class allocated_storage = 20 storage_type = "gp3" db_name = var.db_name username = "app" password = random_password.db.result db_subnet_group_name = aws_db_subnet_group.this.name vpc_security_group_ids = [aws_security_group.db.id] publicly_accessible = false multi_az = false backup_retention_period = 1 skip_final_snapshot = true deletion_protection = false apply_immediately = true } resource "aws_secretsmanager_secret_version" "db" { secret_id = aws_secretsmanager_secret.db.id secret_string = jsonencode({ username = aws_db_instance.this.username password = random_password.db.result host = aws_db_instance.this.address port = aws_db_instance.this.port dbname = aws_db_instance.this.db_name url = "postgresql://${aws_db_instance.this.username}:${random_password.db.result}@${aws_db_instance.this.address}:${aws_db_instance.this.port}/${aws_db_instance.this.db_name}" }) }skip_final_snapshot,deletion_protection = falseand a zero-day secret recovery window makedestroyclean for a learning environment. In production all three flip the other way — that is the difference between an environment you can recreate and one you must protect. The password is still in the state file, which is why the state bucket is encrypted and private. - Export the connection details and the secret's ARN in
modules/database/outputs.tf. The service will inject the secret by ARN, so the URL itself never appears in a task definition.hcloutput "endpoint" { value = aws_db_instance.this.address } output "secret_arn" { value = aws_secretsmanager_secret.db.arn } output "security_group_id" { value = aws_security_group.db.id } - The database module needs the service's security group, and the service needs the database's secret — a cycle if both are created inside their modules. Break it by creating the service security group in
envs/dev/main.tf, where both modules can see it, then add the database module.hclresource "aws_security_group" "service" { name = "${var.name}-service" description = "ECS tasks" vpc_id = module.network.vpc_id egress { from_port = 0 to_port = 0 protocol = "-1" cidr_blocks = ["0.0.0.0/0"] } } module "database" { source = "../../modules/database" name = var.name vpc_id = module.network.vpc_id subnet_ids = module.network.private_subnet_ids allowed_security_group_id = aws_security_group.service.id instance_class = var.db_instance_class }This is a common Terraform design decision: the resource that two modules share is owned by neither of them. Ingress to the service group is added in the next phase, by the ALB. - Apply on a branch. RDS takes five to ten minutes to create, so this is a good moment to read ahead. Then confirm the secret exists without printing it.bash
git switch -c feat/database cd envs/dev && terraform fmt -recursive .. && terraform validate terraform plan -var-file=dev.tfvars -out=tfplan && terraform apply tfplan aws secretsmanager describe-secret --secret-id "aws-platform-dev/database" --query '{name:Name,created:CreatedDate}' aws rds describe-db-instances --db-instance-identifier aws-platform-dev --query 'DBInstances[0].{status:DBInstanceStatus,public:PubliclyAccessible,az:AvailabilityZone}' - Commit and merge.bash
cd ../.. git add modules/database envs/dev git commit -m "feat(database): private RDS Postgres with generated password in Secrets Manager" git push -u origin feat/database gh pr create --fill && gh pr merge --squash --delete-branch git switch main && git pull
The service module: ALB and ECS Fargate
An Application Load Balancer in the public subnets forwarding to two Fargate tasks in the private subnets, with the database URL injected from Secrets Manager and logs in CloudWatch.
- Declare the inputs in
modules/service/variables.tf.hclvariable "name" { type = string } variable "vpc_id" { type = string } variable "public_subnet_ids" { type = list(string) } variable "private_subnet_ids" { type = list(string) } variable "service_security_group_id" { type = string } variable "image" { type = string } variable "container_port" { type = number default = 8000 } variable "desired_count" { type = number default = 2 } variable "cpu" { type = number default = 256 } variable "memory" { type = number default = 512 } variable "db_secret_arn" { type = string } variable "environment" { description = "Plain (non-secret) environment variables" type = map(string) default = {} } - Write the load balancer half of
modules/service/main.tf: the ALB's own security group open on 80, a target group that health-checks/health, and a listener. The ingress rule on the service's group is created here too, allowing traffic only from the ALB.hclresource "aws_security_group" "alb" { name = "${var.name}-alb" description = "Public HTTP in" vpc_id = var.vpc_id ingress { from_port = 80 to_port = 80 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_vpc_security_group_ingress_rule" "service_from_alb" { security_group_id = var.service_security_group_id referenced_security_group_id = aws_security_group.alb.id from_port = var.container_port to_port = var.container_port ip_protocol = "tcp" } resource "aws_lb" "this" { name = var.name load_balancer_type = "application" security_groups = [aws_security_group.alb.id] subnets = var.public_subnet_ids } resource "aws_lb_target_group" "this" { name = var.name port = var.container_port protocol = "HTTP" target_type = "ip" vpc_id = var.vpc_id health_check { path = "/health" matcher = "200" interval = 15 healthy_threshold = 2 unhealthy_threshold = 3 } deregistration_delay = 10 } resource "aws_lb_listener" "http" { load_balancer_arn = aws_lb.this.arn port = 80 protocol = "HTTP" default_action { type = "forward" target_group_arn = aws_lb_target_group.this.arn } }target_type = "ip"is required for Fargate: tasks have no instance, only an ENI with an address. HTTPS is left for thenextlist — it needs a domain you own and an ACM certificate. - Now the compute half, appended to the same file: a log group, the execution role (what ECS itself needs: pull images, write logs, read the secret), the task definition and the service. The
secretsblock is what turns the Secrets Manager entry intoDATABASE_URLinside the container.hclresource "aws_cloudwatch_log_group" "this" { name = "/ecs/${var.name}" retention_in_days = 7 } data "aws_iam_policy_document" "ecs_assume" { statement { actions = ["sts:AssumeRole"] principals { type = "Service" identifiers = ["ecs-tasks.amazonaws.com"] } } } resource "aws_iam_role" "execution" { name = "${var.name}-execution" assume_role_policy = data.aws_iam_policy_document.ecs_assume.json } resource "aws_iam_role_policy_attachment" "execution" { role = aws_iam_role.execution.name policy_arn = "arn:aws:iam::aws:policy/service-role/AmazonECSTaskExecutionRolePolicy" } resource "aws_iam_role_policy" "read_secret" { name = "read-db-secret" role = aws_iam_role.execution.id policy = jsonencode({ Version = "2012-10-17" Statement = [{ Effect = "Allow" Action = ["secretsmanager:GetSecretValue"] Resource = var.db_secret_arn }] }) } resource "aws_ecs_cluster" "this" { name = var.name } resource "aws_ecs_task_definition" "this" { family = var.name network_mode = "awsvpc" requires_compatibilities = ["FARGATE"] cpu = var.cpu memory = var.memory execution_role_arn = aws_iam_role.execution.arn runtime_platform { cpu_architecture = "X86_64" operating_system_family = "LINUX" } container_definitions = jsonencode([{ name = "app" image = var.image essential = true portMappings = [{ containerPort = var.container_port protocol = "tcp" }] environment = [for k, v in var.environment : { name = k, value = v }] secrets = [{ name = "DATABASE_URL" valueFrom = "${var.db_secret_arn}:url::" }] logConfiguration = { logDriver = "awslogs" options = { "awslogs-group" = aws_cloudwatch_log_group.this.name "awslogs-region" = data.aws_region.current.name "awslogs-stream-prefix" = "app" } } }]) } data "aws_region" "current" {} resource "aws_ecs_service" "this" { name = var.name cluster = aws_ecs_cluster.this.id task_definition = aws_ecs_task_definition.this.arn desired_count = var.desired_count launch_type = "FARGATE" network_configuration { subnets = var.private_subnet_ids security_groups = [var.service_security_group_id] assign_public_ip = false } load_balancer { target_group_arn = aws_lb_target_group.this.arn container_name = "app" container_port = var.container_port } deployment_minimum_healthy_percent = 50 deployment_maximum_percent = 200 depends_on = [aws_lb_listener.http] }valueFrom = "<secret-arn>:url::"picks theurlkey out of the JSON secret. The trailing::means 'latest version, no version stage'. If your zero-to-prod image was built on an Apple Silicon Mac without--platform linux/amd64, changecpu_architecturetoARM64— Fargate supports both, but the image and the setting must agree. - Export the ALB address and the identifiers you will want for the CLI in
modules/service/outputs.tf.hcloutput "alb_dns_name" { value = aws_lb.this.dns_name } output "cluster_name" { value = aws_ecs_cluster.this.name } output "service_name" { value = aws_ecs_service.this.name } output "log_group" { value = aws_cloudwatch_log_group.this.name } - Add the module to
envs/dev/main.tfand the environment outputs toenvs/dev/outputs.tf.hcl# envs/dev/main.tf (append) module "service" { source = "../../modules/service" name = var.name vpc_id = module.network.vpc_id public_subnet_ids = module.network.public_subnet_ids private_subnet_ids = module.network.private_subnet_ids service_security_group_id = aws_security_group.service.id image = var.image desired_count = var.desired_count db_secret_arn = module.database.secret_arn environment = { GREETING = "hello from ECS Fargate" } } # envs/dev/outputs.tf output "url" { value = "http://${module.service.alb_dns_name}/" } output "db_endpoint" { value = module.database.endpoint } output "cluster" { value = module.service.cluster_name } - Make the image pullable. Fargate pulls
ghcr.io/…anonymously only if the package is public; check it, and flip it if needed (Packages → your package → Package settings → Change visibility).bashgh api "/users/$(gh api user -q .login)/packages/container/zero-to-prod" -q .visibility # 'public' -> fine. 'private' -> make it public in the package settings, or use # repositoryCredentials in the task definition with a Secrets Manager entry holding a GHCR token. - Apply, then watch the service reach a steady state and the targets turn healthy. The first deployment takes two or three minutes: pull, start, pass two health checks.bash
git switch -c feat/service cd envs/dev && terraform fmt -recursive .. && terraform validate terraform plan -var-file=dev.tfvars -out=tfplan && terraform apply tfplan aws ecs wait services-stable --cluster aws-platform-dev --services aws-platform-dev TG=$(aws elbv2 describe-target-groups --names aws-platform-dev --query 'TargetGroups[0].TargetGroupArn' --output text) aws elbv2 describe-target-health --target-group-arn "$TG" --query 'TargetHealthDescriptions[].{ip:Target.Id,state:TargetHealth.State}' curl -s "$(terraform output -raw url)" curl -s "$(terraform output -raw url)health"services-stablewaits up to ten minutes and exits non-zero if tasks keep dying; if it does, go straight to the CloudWatch logs in the troubleshooting section rather than re-applying.
Prove the database connection
The running service actually reaches Postgres through the private network using the injected secret, and you can see it happen in the logs.
- The zero-to-prod app does not talk to a database yet. Add a
/dbendpoint to it — in the zero-to-prod repository, on a branch — that opens a connection withDATABASE_URLand returns the server version. Addpsycopg[binary]to its requirements.python# app/main.py (zero-to-prod repo) — add below the existing routes import psycopg @app.get("/db") def db(): url = os.environ.get("DATABASE_URL") if not url: return {"database": "not configured"} with psycopg.connect(url, connect_timeout=3) as conn: version = conn.execute("select version()").fetchone()[0] return {"database": "ok", "version": version.split(",")[0]}Keeposimported at the top as it already is.psycopg[binary]==3.2.*inrequirements.txtis enough; no build tools needed in the image. - Add a test that the endpoint degrades gracefully without a database, then push the branch, let CI pass, merge, and wait for the release workflow to publish a new
:latestimage.python# tests/test_main.py (zero-to-prod repo) — append def test_db_reports_not_configured_without_url(monkeypatch): monkeypatch.delenv("DATABASE_URL", raising=False) assert client.get("/db").json() == {"database": "not configured"} - Back in
aws-platform, force a new deployment so the service pulls the new image. Because the tag is still:latest, Terraform sees no change — this is the moment you learn why teams deploy by digest or immutable tag, which the pipeline phase fixes.bashaws ecs update-service --cluster aws-platform-dev --service aws-platform-dev --force-new-deployment --query 'service.deployments[].{status:status,running:runningCount,taskDef:taskDefinition}' aws ecs wait services-stable --cluster aws-platform-dev --services aws-platform-dev curl -s "$(cd envs/dev && terraform output -raw url)db"Expected:{"database":"ok","version":"PostgreSQL 16.x"}. The request went ALB → private task → RDS, and the password came from Secrets Manager at task start, never from your laptop. - Look at the logs the way you will during an incident: tail the log group and make a few requests. Then confirm the database is unreachable from the internet, which is the point of the private subnet.bash
aws logs tail /ecs/aws-platform-dev --follow --since 5m & for i in 1 2 3; do curl -s "$(cd envs/dev && terraform output -raw url)db" > /dev/null; done sleep 5; kill %1 DB=$(cd envs/dev && terraform output -raw db_endpoint) timeout 5 psql "postgresql://app@$DB:5432/app" -c 'select 1' || echo "unreachable from here — correct" - Commit the environment changes and merge.bash
git add modules/service envs/dev git commit -m "feat(service): ALB + ECS Fargate service with DATABASE_URL from Secrets Manager" git push -u origin feat/service gh pr create --fill && gh pr merge --squash --delete-branch git switch main && git pull
Let GitHub Actions own the environment
Every pull request that touches HCL gets fmt, validate, tflint and a plan posted as a comment; merging to main applies. Credentials are short-lived OIDC tokens, and image tags become immutable so a deploy is a reviewable diff.
- Create an IAM role for this repository. The OIDC provider already exists from zero-to-prod; the trust policy is scoped to this repo's
mainbranch and pull requests.bashACCOUNT=$(aws sts get-caller-identity --query Account --output text) USER=$(gh api user -q .login) cat > trust.json <<EOF { "Version": "2012-10-17", "Statement": [{ "Effect": "Allow", "Principal": {"Federated": "arn:aws:iam::$ACCOUNT:oidc-provider/token.actions.githubusercontent.com"}, "Action": "sts:AssumeRoleWithWebIdentity", "Condition": { "StringEquals": {"token.actions.githubusercontent.com:aud": "sts.amazonaws.com"}, "StringLike": {"token.actions.githubusercontent.com:sub": "repo:$USER/aws-platform:*"} } }] } EOF aws iam create-role --role-name github-aws-platform --assume-role-policy-document file://trust.json rm trust.jsonIfcreate-open-id-connect-providerwas never run in this account, do it now:aws iam create-open-id-connect-provider --url https://token.actions.githubusercontent.com --client-id-list sts.amazonaws.com. - Attach permissions. For a learning account the managed policies below are acceptable; the
nextlist points at scoping them down. The state bucket policy is the one you should write by hand even here.bashACCOUNT=$(aws sts get-caller-identity --query Account --output text) for p in AmazonVPCFullAccess AmazonECS_FullAccess AmazonRDSFullAccess ElasticLoadBalancingFullAccess CloudWatchLogsFullAccess SecretsManagerReadWrite IAMFullAccess; do aws iam attach-role-policy --role-name github-aws-platform --policy-arn "arn:aws:iam::aws:policy/$p" done cat > state.json <<EOF {"Version": "2012-10-17", "Statement": [ {"Effect": "Allow", "Action": ["s3:ListBucket"], "Resource": "arn:aws:s3:::tfstate-aws-platform-$ACCOUNT"}, {"Effect": "Allow", "Action": ["s3:GetObject", "s3:PutObject", "s3:DeleteObject"], "Resource": "arn:aws:s3:::tfstate-aws-platform-$ACCOUNT/envs/*"} ]} EOF aws iam put-role-policy --role-name github-aws-platform --policy-name tfstate --policy-document file://state.json rm state.json - Store the role ARN and region as repository variables — they are not secrets, and variables show up in logs, which helps debugging.bash
ACCOUNT=$(aws sts get-caller-identity --query Account --output text) gh variable set AWS_ROLE_ARN --body "arn:aws:iam::$ACCOUNT:role/github-aws-platform" gh variable set AWS_REGION --body "eu-west-1" gh variable list - Add a TFLint configuration at the repo root. The AWS ruleset knows which instance classes exist and which arguments are deprecated — errors that
validatecannot see because they need the provider's knowledge.hcl# .tflint.hcl plugin "terraform" { enabled = true preset = "recommended" } plugin "aws" { enabled = true version = "0.38.0" source = "github.com/terraform-linters/tflint-ruleset-aws" } - Write
.github/workflows/infra.yml. Theplanjob runs on pull requests and posts the plan; theapplyjob runs only onmainand only after a fresh plan, so what applies is what was reviewed.TF_VAR_imageoverrides the tfvars image so the pipeline can pin a tag.yamlname: infra on: pull_request: paths: ["modules/**", "envs/**", ".tflint.hcl"] push: branches: [main] paths: ["modules/**", "envs/**"] permissions: contents: read id-token: write pull-requests: write concurrency: group: infra-dev cancel-in-progress: false defaults: run: working-directory: envs/dev jobs: check: runs-on: ubuntu-latest steps: - uses: actions/checkout@v4 - uses: hashicorp/setup-terraform@v3 with: terraform_version: "1.10.5" - uses: terraform-linters/setup-tflint@v4 - run: terraform fmt -check -recursive .. - run: terraform init -backend=false - run: terraform validate - run: tflint --init && tflint --recursive --chdir=.. plan: needs: check if: github.event_name == 'pull_request' runs-on: ubuntu-latest steps: - uses: actions/checkout@v4 - uses: aws-actions/configure-aws-credentials@v4 with: role-to-assume: ${{ vars.AWS_ROLE_ARN }} aws-region: ${{ vars.AWS_REGION }} - uses: hashicorp/setup-terraform@v3 with: terraform_version: "1.10.5" - run: terraform init - id: plan run: terraform plan -var-file=dev.tfvars -no-color -out=tfplan 2>&1 | tee plan.txt - uses: actions/github-script@v7 with: script: | const fs = require('fs'); const plan = fs.readFileSync('envs/dev/plan.txt', 'utf8').slice(-60000); await github.rest.issues.createComment({ owner: context.repo.owner, repo: context.repo.repo, issue_number: context.issue.number, body: '### Terraform plan (envs/dev)\n```\n' + plan + '\n```' }); apply: needs: check if: github.event_name == 'push' runs-on: ubuntu-latest environment: dev steps: - uses: actions/checkout@v4 - uses: aws-actions/configure-aws-credentials@v4 with: role-to-assume: ${{ vars.AWS_ROLE_ARN }} aws-region: ${{ vars.AWS_REGION }} - uses: hashicorp/setup-terraform@v3 with: terraform_version: "1.10.5" - run: terraform init - run: terraform plan -var-file=dev.tfvars -out=tfplan - run: terraform apply -auto-approve tfplanconcurrencywithcancel-in-progress: falsequeues applies instead of running two at once; the S3 lockfile is the second line of defence. Theenvironment: devline lets you add a required reviewer under Settings → Environments later, which turns apply-on-merge into apply-on-approval without touching the workflow. - Switch the image from
:latestto an immutable tag, so that changing the image is a one-line diff Terraform can see. Your zero-to-prod release workflow also tags images with the commit SHA; find the newest one.bashgit switch -c ci/infra-pipeline USER=$(gh api user -q .login) TAG=$(gh api "/users/$USER/packages/container/zero-to-prod/versions" -q '.[0].metadata.container.tags[] | select(. != "latest")' | head -1) echo "$TAG" sed -i.bak "s#zero-to-prod:latest#zero-to-prod:$TAG#" envs/dev/dev.tfvars && rm envs/dev/dev.tfvars.bak cat envs/dev/dev.tfvarsIf your release workflow only pushes:latest, addtype=shato itsdocker/metadata-actiontags first — the zero-to-prod project'srelease.ymlshows where. From now on a deploy is: change the tag, open a PR, read the plan (one task definition revision), merge. - Open the pull request and read the plan comment. It should show one change: a new task definition revision with the new image, and the service pointing at it.bash
git add .github .tflint.hcl envs/dev/dev.tfvars git commit -m "ci: plan on PR, apply on merge with OIDC; pin image tag" git push -u origin ci/infra-pipeline gh pr create --fill gh pr checks --watch gh pr view --comments | tail -40 - Merge and watch the apply. Then confirm the service is running the pinned tag.bash
gh pr merge --squash --delete-branch git switch main && git pull gh run watch aws ecs describe-services --cluster aws-platform-dev --services aws-platform-dev --query 'services[0].taskDefinition' --output text | xargs -I{} aws ecs describe-task-definition --task-definition {} --query 'taskDefinition.containerDefinitions[0].image'
Operate it, then destroy it
Scale, deploy and roll back through pull requests; check what it costs; tear the whole environment down and prove you can bring it back.
- Scale through a PR. Change
desired_countto 3, read the plan (one attribute on one resource), merge, and watch the third task appear behind the ALB.bashgit switch -c ops/scale-3 sed -i.bak 's/desired_count = 2/desired_count = 3/' envs/dev/dev.tfvars && rm envs/dev/dev.tfvars.bak git commit -am "ops: scale service to 3 tasks" && git push -u origin ops/scale-3 gh pr create --fill && gh pr checks --watch && gh pr merge --squash --delete-branch git switch main && git pull && gh run watch aws ecs describe-services --cluster aws-platform-dev --services aws-platform-dev --query 'services[0].{desired:desiredCount,running:runningCount}' - Roll back a deploy the same way you roll forward: revert the commit that changed the image tag. The plan shows the old task definition revision coming back; ECS drains new tasks behind the ALB with no downtime because
deployment_minimum_healthy_percentkeeps half of them serving.bashgit log --oneline -5 -- envs/dev/dev.tfvars # pick the commit that changed the image tag and put its SHA here: SHA=abc1234 git switch -c ops/rollback && git revert --no-edit "$SHA" git push -u origin ops/rollback && gh pr create --fill # read the plan comment, merge, watch — then revert the revert to move forward again - Look at what the environment has cost so far, by the
projecttag thatdefault_tagsapplied to every resource. Cost Explorer data lags about a day, so run this tomorrow as well.bashaws ce get-cost-and-usage --time-period Start=$(date -v-7d +%F 2>/dev/null || date -d '7 days ago' +%F),End=$(date +%F) \ --granularity DAILY --metrics UnblendedCost \ --filter '{"Tags":{"Key":"project","Values":["aws-platform"]}}' \ --query 'ResultsByTime[].{day:TimePeriod.Start,usd:Total.UnblendedCost.Amount}' --output tableCost allocation tags must be activated once in Billing → Cost allocation tags before Cost Explorer can filter on them; the activation takes up to 24 hours. - Destroy everything. Because nothing was created by hand except the state bucket, one command removes it all; the plan lists every resource it will delete — read it before typing yes.bash
cd envs/dev terraform plan -destroy -var-file=dev.tfvars terraform destroy -var-file=dev.tfvars aws ecs list-clusters; aws rds describe-db-instances --query 'DBInstances[].DBInstanceIdentifier'; aws ec2 describe-nat-gateways --filter Name=state,Values=available --query 'NatGateways[].NatGatewayId'RDS deletion takes several minutes; the NAT gateway a couple more. The three checks at the end should print empty lists. The state bucket stays — it is tiny and it holds the history. - Rebuild it from nothing to prove the point, then destroy it again. If the second apply needs any manual step you did not write down, that step belongs in the README or in Terraform.bash
terraform apply -var-file=dev.tfvars aws ecs wait services-stable --cluster aws-platform-dev --services aws-platform-dev curl -s "$(terraform output -raw url)db" terraform destroy -var-file=dev.tfvars - Write the README: what the stack is, the bootstrap steps (bucket, OIDC role), how a deploy and a rollback work, the hourly cost, and the one-line destroy. Commit it and you are done.bash
cd ../.. git switch -c docs/readme cat > README.md <<'EOF' # aws-platform Terraform for a dev environment: VPC (2 AZ, public/private, 1 NAT), ALB, ECS Fargate service, RDS Postgres, Secrets Manager. ## Bootstrap (once) 1. State bucket: `tfstate-aws-platform-<account>` (versioned, encrypted, private). 2. IAM role `github-aws-platform` trusted by this repo via GitHub OIDC. ## Deploy Change `image` in `envs/dev/dev.tfvars`, open a PR, read the plan comment, merge. Rollback = revert the commit. ## Cost About $0.10/hour while up (NAT, ALB, RDS db.t4g.micro, 2 x 0.25 vCPU Fargate). `terraform destroy` when idle. EOF git add README.md && git commit -m "docs: bootstrap, deploy, rollback, cost" && git push -u origin docs/readme gh pr create --fill && gh pr merge --squash --delete-branch
Troubleshooting
services-stabletimes out; tasks start and stop every minute- Read the stopped reason:
aws ecs list-tasks --cluster aws-platform-dev --desired-status STOPPEDthendescribe-tasks … --query 'tasks[].stoppedReason'.CannotPullContainerErrormeans the image is private or the private subnets have no NAT route;ResourceInitializationError … secretsmanagermeans the execution role cannot read the secret or the ARN invalueFromis wrong;exec format errorin the logs means the image architecture andcpu_architecturedisagree. - Targets stay
unhealthyalthough tasks are running - The ALB reaches the task on the container port through the service security group; check that the ingress rule from the ALB's group exists (
aws ec2 describe-security-group-rules --filters Name=group-id,Values=<service-sg>), and that/healthreturns 200 on port 8000 —curlit from inside a task with ECS Exec if you enable it, or check the app logs for the health-check requests. Error acquiring the state lockin the pipeline with no other run in progress- A previous run was cancelled mid-apply and left
terraform.tfstate.tflockin the bucket. Confirm nothing is running, thenterraform force-unlock <ID>from your laptop (the ID is in the error). Never delete the lock object by hand while a run might be active. /dbreturns a connection timeout- The database security group only admits the service group on 5432; if you changed the service group's name or replaced it, the rule points at the old group. Also check the task is in a private subnet whose route table has the NAT route — RDS is inside the VPC, but DNS resolution for the endpoint still needs
enable_dns_support. destroyfails on the security group or the subnet withDependencyViolation- ENIs from the ALB or from tasks that are still draining hold the group. Wait a minute and run
destroyagain; if it persists, find the ENI withaws ec2 describe-network-interfaces --filters Name=group-id,Values=<sg>and check what still owns it. - The plan comment on the PR is empty or truncated
- The script takes the last 60,000 characters of
plan.txt; a very large plan is cut from the top. Look at the job log for the full plan, and considerterraform show -no-color tfplanpiped through a summariser action as a next step.
Where to go from here
- Add HTTPS: a Route 53 hosted zone for a domain you own, an ACM certificate validated by DNS, a 443 listener, and a redirect from 80. All four are Terraform resources; the change is about 40 lines.
- Replace the managed
*FullAccesspolicies on the pipeline role with a policy generated from what the apply actually called (IAM Access Analyzer's policy generation reads CloudTrail and writes it for you). - Add a
envs/prodthat reuses the three modules withmulti_az = true, one NAT per AZ,deletion_protection = trueand a required reviewer on theprodenvironment — the whole point of writing modules. - Wire the SRE track's SLO project to this stack: scrape the ALB's CloudWatch metrics or run the Prometheus stack as a second ECS service, and alert on the same error-ratio and latency objectives.
- Run the infra workflow's
planjob againstterraform plan -refresh-onlyon a schedule to detect drift — anything changed in the console shows up as a diff.
Did a step fail or feel unclear? Tell me which one →