Subchapter 28.24
references/phases/generate/generate-terraform.mdMarkdown85 KBView on GitHub
Execute ALL steps in order. Do not skip or optimize.
Transform aws-design.json into review-ready Terraform HCL configurations. Produces a terraform/ directory in $MIGRATION_DIR/ containing valid, terraform validate-passing configurations for all designed AWS resources, plus the selected Elastic Beanstalk deploy artifact when EB is present. Elastic Beanstalk configurations require customer-supplied application port and health check path values before terraform plan can succeed.
Generate $MIGRATION_DIR/terraform/ with the following file organization. Only emit domain files that have resources in aws-design.json:
| File | Domain | Contains |
|---|---|---|
main.tf | core | Provider config, backend, data sources |
baseline.tf | security | Account-wide security baseline (contacts, CloudTrail, GuardDuty, budget, IMDSv2 default; compliance-conditional Config + Security Hub) |
variables.tf | core | All input variables with types and defaults |
outputs.tf | core | Resource outputs and migration summary |
vpc.tf | networking | VPC, subnets, route tables, internet gateway, NAT, peering |
compute.tf | compute | ECS cluster, Fargate task definitions, services, ALBs |
beanstalk.tf | compute | Elastic Beanstalk applications and environments |
pipeline.tf | deploy | Optional CodePipeline source-to-EB deploy path |
database.tf | database | RDS/Aurora instances, parameter groups, RDS Proxy |
cache.tf | cache | ElastiCache replication groups, subnet groups |
messaging.tf | messaging | MSK clusters, configurations |
security.tf | security | Security groups, IAM roles/policies |
File emission rules:
main.tf, baseline.tf, variables.tf, outputs.tf — ALWAYS emitted (baseline.tf is workload-independent; opting out takes two steps — delete the file AND remove its three contact variables — documented in MIGRATION_GUIDE.md Phase 1)vpc.tf — Emitted when vpc_design is present in aws-design.json (either existing or new VPC)compute.tf — Emitted when aws_service contains “Fargate” or “ALB” entriesbeanstalk.tf — Emitted when aws_service contains “Elastic Beanstalk” entries.github/workflows/deploy-eb.yml — Emitted when aws_service contains “Elastic Beanstalk” entries and preferences.design_constraints.eb_deploy_method.value is "github_actions" or absent (default)pipeline.tf — Emitted only when aws_service contains “Elastic Beanstalk” entries and preferences.design_constraints.eb_deploy_method.value is "codepipeline"database.tf — Emitted when aws_service contains “RDS” or “Aurora” entriescache.tf — Emitted when aws_service contains “ElastiCache” entriesmessaging.tf — Emitted when aws_service contains “MSK” entriessecurity.tf — ALWAYS emitted (security groups required for all deployments)Service-to-file routing:
AWS Service in aws-design.json | Target File |
|---|---|
| Fargate, ALB | compute.tf |
| Elastic Beanstalk | beanstalk.tf; plus .github/workflows/deploy-eb.yml for github_actions or pipeline.tf for codepipeline |
| RDS PostgreSQL, Aurora PostgreSQL | database.tf |
| ElastiCache Redis | cache.tf |
| Amazon MSK | messaging.tf |
| VPC, Subnet, Route Table, IGW, NAT | vpc.tf |
| Security Group, IAM Role/Policy | security.tf |
| CloudWatch Logs | compute.tf |
Unmapped services: If aws-design.json contains a service_id with an aws_service value that has no Terraform resource mapping in this file (e.g., CloudWatch + X-Ray composite, Amazon SES, Amazon SNS), skip that resource and record a warning in generation-warnings.json (which is ALWAYS written — see Step 10 — with an empty warnings array when nothing is skipped). Do NOT halt generation.
Before generating any Terraform, invoke the tf-best-practices skill for its authoring posture — it is the single source of truth for “what good AWS Terraform looks like.” Treat it as a black box: pass the caller context below and emit Terraform that satisfies every rule it returns. Do not reach into its files or re-specify its rules here — it evolves independently.
Invoke the
tf-best-practicesskill, telling it you are about to authorterraform/(the pre-generation context).
Pass the caller context (heroku-to-aws supplies these; the skill reads none of our artifacts):
compliance — the normalized compliance array (see Step 1.5 item 0 for the scalar/absent/"none"/"unknown" normalization). Empty ⇒ no compliance-conditional hardening.aws_config values — instance classes, CPU/memory, storage, engine versions from each service’s aws_config in aws-design.json. The posture constrains the shape, not the numbers.The Elastic Beanstalk / Fargate / RDS / ElastiCache / MSK wiring in the steps below is heroku-to-aws’s source glue (value population + EB setting blocks); the security posture on those resources is owned by the skill. Following the posture makes the Step 12 policy gate pass by construction.
# Heroku-to-AWS Migration — Terraform Configuration
#
# Generated by the heroku-to-aws migration skill.
# This configuration implements the architecture designed in aws-design.json.
#
# Apply sequence:
# 1. terraform init
# 2. terraform plan -out=tfplan
# 3. terraform apply tfplan
terraform {
required_version = ">= 1.5.0"
required_providers {
aws = {
source = "hashicorp/aws"
version = "~> 5.80"
}
}
}
provider "aws" {
region = var.aws_region
default_tags {
tags = {
Project = var.project_name
Environment = var.environment
ManagedBy = "terraform"
MigrationId = var.migration_id
Source = "heroku-to-aws"
}
}
}
data "aws_caller_identity" "current" {}
data "aws_region" "current" {}
data "aws_availability_zones" "available" {
state = "available"
}Customization rules:
region value: Use var.aws_region (populated from preferences.json.global.target_region)MigrationId tag: Use the migration run ID from .phase-status.jsonAlways emitted. The baseline applies account-wide security controls that should be in place on any new AWS account. Users who do not want the baseline delete terraform/baseline.tf AND remove its three contact email variables from variables.tf/tfvars (they have no defaults, so plan fails on them even unreferenced) — MIGRATION_GUIDE.md Phase 1 documents both steps. It is workload-independent: emit it regardless of which services aws-design.json contains.
Normalize compliance. Read preferences.json.global.compliance. It is a scalar string ("none", "soc2", "hipaa", "pci") or, when the user specified multiple frameworks in Clarify Q2 option E, an array of strings. Normalize to an array: absent, "none", or "unknown" → [] (an absent or unconfirmed answer is not a framework); scalar → single-element array; array → lowercase as-is, dropping any "none"/"unknown" entries. Every reference to compliance below means this normalized array.
Compute retention. Compute cloudtrail_retention_days from the normalized compliance array using this mapping, taking max() across all declared values (use 90 if the array is empty):
[] → 90soc2 → 365pci → 365hipaa → 2190fedramp → 1095gdpr → 365baseline.tf file-header comment (item 3) — do NOT add it to generation-warnings.json, whose entries are service-shaped and feed the every-service-accounted-for gateCompute budget limit. Read estimation-infra.json.projected_costs.aws_monthly_balanced (the Balanced-tier monthly total — the same key this skill’s Estimate postconditions assert is a positive number). Compute budget_limit = max(50, ceil(aws_monthly_balanced * 1.2)). If estimation-infra.json is missing or the key is unreadable, use 50 and emit an inline comment noting that the projection was unavailable.
Choose file-header variant. If compliance contains any of soc2, pci, hipaa, fedramp, emit the compliance-expansion header. Otherwise emit the base header. Both variants include a two-sentence provenance note stating per-unit rates in the cost-disclosure comments were verified against the AWS Pricing API for us-east-1 on 2026-05-04. Substitute the resolved cloudtrail_retention_days value into the header. When item 1 encountered an unrecognized compliance value, append one header line naming it and the conservative 365-day retention applied (e.g. # Unrecognized compliance framework "iso27001" — applied conservative 365-day CloudTrail retention).
Emit baseline.tf starting with the file-header comment block and a locals block containing the resolved cloudtrail_retention_days integer:
locals {
cloudtrail_retention_days = <N>
}Append the always-on resources, in this order. Provider default_tags (Step 1) supply the standard tags; each baseline resource additionally carries tags = { Component = "security-baseline" } where the resource type supports tags:
aws_account_alternate_contact.operations (ACCT.01; alternate_contact_type = "OPERATIONS", email_address = var.operations_email — fill-once variable, see Step 2. name, title, and phone_number are ALSO required by this resource type: pin name = "Operations Contact", title = "Operations", and the placeholder phone_number = "+1-555-0100" with an inline comment telling the user to update the phone number post-apply — see the golden HCL below)aws_account_alternate_contact.billing (ACCT.01; alternate_contact_type = "BILLING", email_address = var.billing_email; pinned name = "Billing Contact", title = "Billing", same placeholder phone pattern)aws_account_alternate_contact.security (ACCT.01; alternate_contact_type = "SECURITY", email_address = var.security_email; pinned name = "Security Contact", title = "Security", same placeholder phone pattern)aws_iam_account_password_policy.baseline (ACCT.06; minimum_password_length = 14, password_reuse_prevention = 24, max_password_age = 90, all four character-class requirements true, hard_expiry = false)aws_s3_account_public_access_block.baseline (ACCT.08; all four flags true)aws_ebs_encryption_by_default.baseline (defense-in-depth; enabled = true)aws_accessanalyzer_analyzer.baseline (ACCT.11; type = "ACCOUNT")aws_ec2_instance_metadata_defaults.baseline (defense-in-depth; http_tokens = "required", http_put_response_hop_limit = 2)aws_cloudtrail.baseline (ACCT.07; name = "${var.project_name}-baseline" — MUST match the aws:SourceArn in the bucket policy exactly, see the golden HCL; multi-region, management events only, enable_log_file_validation = true, depends_on = [aws_s3_bucket_policy.cloudtrail_logs] — CloudTrail validates the bucket policy at create time)If compliance contains any of soc2, pci, hipaa, fedramp, append the compliance-conditional section, wrapped in ########## Compliance-Conditional ########## / ########## End Compliance-Conditional ########## dividers:
aws_iam_role.config (trust policy for config.amazonaws.com — see the golden HCL below) + aws_iam_role_policy_attachment for the managed policy arn:aws:iam::aws:policy/service-role/AWS_ConfigRole (note the underscore — AWSConfigRole without it is a different, deprecated policy name and fails apply)aws_config_configuration_recorder.baseline with recording_group { all_supported = true, include_global_resource_types = true }aws_config_delivery_channel.baseline pointing at the Config S3 bucketaws_config_configuration_recorder_status.baseline with is_enabled = trueaws_s3_bucket.config_logs plus PAB, SSE, versioning, lifecycle (same local.cloudtrail_retention_days), and a bucket policy allowing the config.amazonaws.com service principalaws_securityhub_account.baselineaws_securityhub_standards_subscription.fsbp (always emitted in this section)aws_securityhub_standards_subscription.pci_dss (only if compliance contains pci)Do NOT emit an NIST 800-53 standards subscription, even if compliance contains hipaa or fedramp. Security Hub does not provide a HIPAA-specific standard; FedRAMP attestation is out-of-band.
Lifecycle rule adjustment. Omit the STANDARD_IA transition block when the resolved retention is less than 90 days. Omit the GLACIER transition block when retention is less than 365 days. Both rules apply to both the CloudTrail log bucket and (when emitted) the Config log bucket.
Attach inline HCL comments:
aws_account_alternate_contact.*: a comment pointing at the tfvars fill-once variable (# set var.operations_email in terraform.tfvars — plan fails until you do) and noting the phone number is a placeholder to update post-apply.aws_cloudtrail.baseline: a collision warning for users who already have a trail in the region.aws_budgets_budget.monthly_spend: the limit-rationale comment (max(50, ceil(aws_monthly_balanced * 1.2)); $50 floor prevents alert noise; users may edit limit_amount directly post-apply).aws_guardduty_detector.baseline: a cost disclosure noting the 30-day free trial and ~$2–25/mo post-trial.aws_config_configuration_recorder.baseline: a cost disclosure ($0.003/CI continuous; $0.012/daily-CI as an opt-in for cost-sensitive users).aws_securityhub_account.baseline: a cost disclosure noting the 30-day free trial and ~$1–15/mo post-trial.defense-in-depth in the inline comment.Golden HCL for the shapes agents get wrong. The resource lists above name types; the shapes below have required arguments, policy documents, or cross-resource name/ordering couplings that must not be improvised. Match them exactly (identifiers/region values may vary, but the trail name and the aws:SourceArn conditions must agree):
# Alternate contact — all four of name / title / email_address / phone_number are REQUIRED
resource "aws_account_alternate_contact" "operations" {
alternate_contact_type = "OPERATIONS"
name = "Operations Contact"
title = "Operations"
email_address = var.operations_email
phone_number = "+1-555-0100" # placeholder — update post-apply with a real number
}
# CloudTrail log bucket policy — service principal scoped by SourceArn
data "aws_iam_policy_document" "cloudtrail_logs" {
statement {
sid
EKS launch-template rider (runs in the eks-generate fragment, not here): when the design routes compute to EKS with self-managed node groups, the aws_launch_template emitted by generate-eks.md receives IMDSv2 enforcement unconditionally:
metadata_options {
http_tokens = "required"
http_put_response_hop_limit = 1
http_endpoint = "enabled"
instance_metadata_tags = "enabled"
}Fargate and Elastic Beanstalk do not emit launch templates in this skill and are unaffected (no synthetic launch template is created). Hop limit 1 here is intentionally different from the account-level default 2 in aws_ec2_instance_metadata_defaults.baseline — strict on templates the plugin owns, permissive at the account default.
Emission conditions:
baseline.tf for every design, including EB-only, Fargate-only, and EKS designs. The baseline is workload-independent.Always include these global variables:
variable "aws_region" {
description = "AWS region for all resources"
type = string
default = "<preferences.json.global.target_region>"
}
variable "project_name" {
description = "Project name used for resource naming"
type = string
default = "<heroku_app_name or migration_id>"
}
variable "environment" {
description = "Environment name (e.g., production, staging)"
type = string
default = "<preferences.json.global.environment_naming>"
}
variable "migration_id" {
description = "Migration run identifier"
type = string
default = "<migration_id from .phase-status.json>"
}Baseline contact variables (always include — baseline.tf depends on them): the three fill-once contact emails referenced by baseline.tf‘s alternate contacts and budget alerts. They intentionally have no default — terraform plan must fail until the customer supplies real values — and each carries a validation block rejecting placeholder tokens, so a copied-through TODO-ops@example.com fails loudly at terraform plan instead of silently becoming the account’s security contact:
variable "operations_email" {
description = "Operations contact for AWS account alternate contacts (MIGRATION_GUIDE.md Phase 1)"
type = string
validation {
condition = !strcontains(var.operations_email, "TODO") && !strcontains(var.operations_email, "example.com") && strcontains(var.operations_email, "@")
error_message = "Set operations_email in terraform.tfvars to a real inbox (see MIGRATION_GUIDE.md Phase 1, Security baseline contacts)."
}
}
variable "billing_email" {
description = "Billing contact + budget alert recipient (MIGRATION_GUIDE.md Phase 1)"
type = string
validation {
condition = !strcontains(var.billing_email, "TODO") && !strcontains(var.billing_email, "example.com") && strcontains(var.billing_email, "@")
error_message = "Set billing_email in terraform.tfvars to a real inbox (see MIGRATION_GUIDE.md Phase 1, Security baseline contacts)."
}
}
variable "security_email" {
description = "Security contact for AWS account alternate contacts (MIGRATION_GUIDE.md Phase 1)"
type = string
validation {
condition = !strcontains(var.security_email, "TODO") && !strcontains(var.security_email, "example.com") && strcontains(var.security_email, "@")
error_message = "Set security_email in terraform.tfvars to a real inbox (see MIGRATION_GUIDE.md Phase 1, Security baseline contacts)."
}
}Per-service variables — Extract from aws-design.json aws_config for each designed service. Include:
container_image_* (one per Fargate service), desired_count_*, EB instance_type_*, min_instances_*, max_instances_*db_instance_class, db_storage_gb, db_engine_version, db_multi_azcache_node_type, cache_engine_version, cache_multi_azmsk_broker_instance_type, msk_broker_count, msk_storage_gbvpc_id (when referencing existing), subnet_ids (when referencing existing), vpc_cidr (when creating new)Elastic Beanstalk web runtime inputs — For each service where aws_service == "Elastic Beanstalk" and aws_config.process_type == "web", sanitize the app name
by replacing - with _, then emit that app’s two variables below. Do not emit
these variables for non-web Elastic Beanstalk services. The variables intentionally
have no default: the generator has no evidence for either application-specific
value, and terraform plan -input=false must fail with Terraform’s
required-variable diagnostic until the customer supplies both values.
variable "eb_application_port_<app_sanitized>_web" {
description = "Exact port value the <heroku_app> Elastic Beanstalk web process listens on"
type = string
validation {
condition = (
can(regex("^[1-9][0-9]{0,4}$", var.eb_application_port_<app_sanitized>_web)) &&
try(tonumber(var.eb_application_port_<app_sanitized>_web) <= 65535, false)
)
error_message = "Elastic Beanstalk application port must be an integer from 1 through 65535."
}
}
variable "eb_health_check_path_<app_sanitized>_web" {
description = "Exact HTTP health check path for the <heroku_app> Elastic Beanstalk web environment"
type = string
validation {
condition = (
startswith(var.eb_health_check_path_<app_sanitized>_web, "/") &&
length(var.eb_health_check_path_<app_sanitized>_web) <= 1024
)
error_message = "Elastic Beanstalk health check path must start with / and contain at most 1024 characters."
}
}Preserve both customer values exactly. Reference each variable directly from the corresponding app’s Elastic Beanstalk setting. Validate but do not trim, normalize, convert, or replace either value, and do not derive a fallback from the source repository.
Naming convention: <resource_type>_<heroku_app>_<attribute> (sanitize app names: replace - with _).
Use aws_config values from aws-design.json as defaults. Add Heroku source as comment:
variable "fargate_cpu_my_web_app_web" {
description = "Fargate CPU units for my-web-app web process"
type = number
default = 512
# Heroku source: standard-2x dyno
}output "migration_summary" {
description = "Summary of migrated Heroku resources"
value = {
source_platform = "heroku"
target_region = var.aws_region
migration_id = var.migration_id
services_migrated = <count of services in aws-design.json>
}
}Add per-service outputs for connection information:
# Compute outputs
output "alb_dns_name" {
description = "ALB DNS name for Fargate web traffic"
value = aws_lb.web.dns_name
}
# EB web outputs: emit only when a web process exists. Worker-only apps have no public EB CNAME.
output "eb_environment_url" {
description = "Elastic Beanstalk web environment URL"
value = aws_elastic_beanstalk_environment.<app_name>_web.cname
}
# Database outputs
output "rds_endpoint" {
description = "RDS PostgreSQL endpoint"
value = aws_db_instance.postgres.endpoint
sensitive = true
}
output "rds_proxy_endpoint" {
description = "RDS Proxy endpoint for connection pooling"
value = aws_db_proxy.postgres.endpoint
sensitive = true
}
# Cache outputs
output "elasticache_endpoint" {
description = "ElastiCache Redis primary endpoint"
value = aws_elasticache_replication_group.redis.primary_endpoint_address
sensitive = true
}
# Messaging outputs
output "msk_bootstrap_brokers" {
description = "MSK bootstrap broker connection string"
value = aws_msk_cluster.kafka.bootstrap_brokers_tls
sensitive = true
}Only emit outputs for services present in aws-design.json. Mark connection strings as sensitive = true.
Read aws-design.json.vpc_design.mode to determine which path to follow.
When vpc_design.mode == "existing_vpc", reference the existing VPC and subnets as data sources or variables. Do NOT create new VPC resources.
# VPC — Referencing existing VPC from Heroku Private Space peering
# Heroku source: Private Space with VPC peering to vpc-0123456789abcdef0
variable "existing_vpc_id" {
description = "Existing AWS VPC ID (from Heroku Private Space peering)"
type = string
default = "<vpc_design.existing_vpc_id>"
}
variable "existing_subnet_ids" {
description = "Existing subnet IDs within the peered VPC"
type = list(string)
default = <vpc_design.subnet_ids as HCL list>
}
data "aws_vpc" "existing" {
id = var.existing_vpc_id
}
data "aws_subnet" "existing" {
for_each = toset(var.existing_subnet_ids)
id = each.value
}When vpc_design.mode == "new_vpc", generate a complete VPC configuration:
# VPC — New VPC for Heroku migration (no Private Space peering detected)
resource "aws_vpc" "main" {
cidr_block = var.vpc_cidr
enable_dns_support = true
enable_dns_hostnames = true
tags = {
Name = "${var.project_name}-${var.environment}-vpc"
}
}
variable "vpc_cidr" {
description = "CIDR block for the new VPC"
type = string
default = "10.0.0.0/16"
}
# Public subnets (for ALB)
resource "aws_subnet" "public" {
count = 2
vpc_id = aws_vpc.main.id
cidr_block = cidrsubnet(var.vpc_cidr, 8, count.index)
availability_zone = data.aws_availability_zones.available.names[count.index]
map_public_ip_on_launch = true
tags = {
Name = "${var.project_name}-${var.environment}-public-${count.index + 1}"
Tier = "public"
}
}
# Private subnets (for Fargate, RDS, ElastiCache, MSK)
resource "aws_subnet" "private" {
count = 2
vpc_id = aws_vpc.main.id
cidr_block = cidrsubnet(var.vpc_cidr, 8, count.index + 10)
availability_zone = data.aws_availability_zones.available.names[count.index]
tags = {
Name = "${var.project_name}-${var.environment}-private-${count.index + 1}"
Tier = "private"
}
}
# Internet Gateway
resource "aws_internet_gateway" "main" {
vpc_id = aws_vpc.main.id
tags = {
Name = "${var.project_name}-${var.environment}-igw"
}
}
# NAT Gateway (for private subnet internet access)
resource "aws_eip" "nat" {
domain = "vpc"
tags = {
Name = "${var.project_name}-${var.environment}-nat-eip"
}
}
resource "aws_nat_gateway" "main" {
allocation_id = aws_eip.nat.id
subnet_id = aws_subnet.public[0].id
tags = {
Name = "${var.project_name}-${var.environment}-nat"
}
depends_on = [aws_internet_gateway.main]
}
# Route Tables
resource "aws_route_table" "public" {
vpc_id = aws_vpc.main.id
route {
cidr_block = "0.0.0.0/0"
gateway_id = aws_internet_gateway.main.id
}
tags = {
Name = "${var.project_name}-${var.environment}-public-rt"
}
}
resource "aws_route_table" "private" {
vpc_id = aws_vpc.main.id
route {
cidr_block = "0.0.0.0/0"
nat_gateway_id = aws_nat_gateway.main.id
}
tags = {
Name = "${var.project_name}-${var.environment}-private-rt"
}
}
resource "aws_route_table_association" "public" {
count = 2
subnet_id = aws_subnet.public[count.index].id
route_table_id = aws_route_table.public.id
}
resource "aws_route_table_association" "private" {
count = 2
subnet_id = aws_subnet.private[count.index].id
route_table_id = aws_route_table.private.id
}VPC rules:
Generate security groups based on aws-design.json.vpc_design.security_groups and the services present.
When the source inventory contains Private Space resources, generate security groups that restrict inbound traffic to declared dependency CIDRs/ports only:
# Security Groups — Restricted inbound for Private Space migration
# Only declared dependency CIDRs and ports are permitted inbound.
resource "aws_security_group" "app" {
name_prefix = "${var.project_name}-${var.environment}-app-"
vpc_id = <vpc_id_reference>
description = "Security group for migrated Heroku app (Private Space)"
# Inbound: Only declared dependencies
dynamic "ingress" {
for_each = var.app_ingress_rules
content {
from_port = ingress.value.port
to_port = ingress.value.port
protocol = ingress.value.protocol
cidr_blocks = [ingress.value.cidr]
description = ingress.value.description
}
}
# Outbound: Allow all (required for Fargate tasks to pull images, etc.)
egress {
from_port = 0
to_port = 0
protocol = "-1"
cidr_blocks = ["0.0.0.0/0"]
description = "Allow all outbound traffic"
}
tags = {
Name = "${var.project_name}-${var.environment}-app-sg"
}
lifecycle {
create_before_destroy = true
}
}
variable "app_ingress_rules" {
description = "Ingress rules for application security group (from Private Space dependencies)"
type = list(object({
port = number
protocol = string
cidr = string
description = string
}))
default = [
# Populated from aws-design.json vpc_design.security_groups[].inbound_rules
# Example:
# { port = 443, protocol = "tcp", cidr = "0.0.0.0/0", description = "HTTPS from internet" },
# { port = 5432, protocol = "tcp", cidr = "10.0.0.0/16", description = "PostgreSQL from VPC" }
]
}When no Private Space is involved, generate standard security groups:
# ALB Security Group
resource "aws_security_group" "alb" {
name_prefix = "${var.project_name}-${var.environment}-alb-"
vpc_id = <vpc_id_reference>
description = "Security group for Application Load Balancer"
ingress {
from_port = 443
to_port = 443
protocol =
Security group rules:
aws-design.jsonmigration_approach == "interim_cutover_data_first", the database SG additionally emits one gated dynamic "ingress" for the Heroku app’s egress addresses. It is a bounded CIDR allowlist driven by interim_heroku_ingress_cidrs, defaults to emitting nothing, and must never contain 0.0.0.0/0 — the variable’s validation block enforces this.Generate ECS task execution and task roles:
# ECS Task Execution Role
resource "aws_iam_role" "ecs_execution" {
name = "${var.project_name}-${var.environment}-ecs-execution"
assume_role_policy = jsonencode({
Version = "2012-10-17"
Statement = [{
Action = "sts:AssumeRole"
Effect = "Allow"
Principal = {
Service = "ecs-tasks.amazonaws.com"
}
}]
})
tags = {
Name = "${var.project_name}-${var.environment}-ecs-execution"
}
}
resource "aws_iam_role_policy_attachment" "ecs_execution" {
role = aws_iam_role.ecs_execution.name
policy_arn = "arn:aws:iam::aws:policy/service-role/AmazonECSTaskExecutionRolePolicy"
}
# ECS Task Role (application permissions)
resource "aws_iam_role" "ecs_task" {
name = "${var.project_name}-${var.environment}-ecs-task"
assume_role_policy = jsonencode({
Version = "2012-10-17"
Statement = [{
Action = "sts:AssumeRole"
Effect = "Allow"
Principal = {
Service = "ecs-tasks.amazonaws.com"
}
}]
})
tags = {
Name = "${var.project_name}-${var.environment}-ecs-task"
}
}For each service in aws-design.json where aws_service is “Fargate” or “ALB”:
# ECS Cluster for migrated Heroku applications
resource "aws_ecs_cluster" "main" {
name = "${var.project_name}-${var.environment}"
setting {
name = "containerInsights"
value = "enabled"
}
tags = {
Name = "${var.project_name}-${var.environment}-cluster"
}
}resource "aws_cloudwatch_log_group" "app" {
name = "/ecs/${var.project_name}-${var.environment}/<process_type>"
retention_in_days = <preferences.json.operational.log_retention_days || 30>
tags = {
Name = "${var.project_name}-${var.environment}-<process_type>-logs"
HerokuApp = "<heroku_app>"
ProcessType = "<process_type>"
}
}Generate one task definition per formation entry in aws-design.json:
# Fargate Task Definition — <heroku_app>:<process_type>
# Heroku source: <dyno_type> dyno, quantity <desired_count>
resource "aws_ecs_task_definition" "<app_sanitized>_<process_type>" {
family = "${var.project_name}-${var.environment}-<process_type>"
network_mode = "awsvpc"
requires_compatibilities = ["FARGATE"]
cpu = <aws_config.task_cpu>
memory = <aws_config.task_memory>
execution_role_arn = aws_iam_role.ecs_execution.arn
task_role_arn = aws_iam_role.ecs_task.arn
container_definitions = jsonencode([{
name = "<process_type>"
image = var.<container_image_variable>
portMappings = [
{
containerPort = <port: 8080 for web, omit for workers>
hostPort = <port: 8080 for web, omit for workers>
protocol = "tcp"
}
]
logConfiguration = {
logDriver = "awslogs"
options = {
"awslogs-group" = aws_cloudwatch_log_group.<ref>.name
"awslogs-region" = var.aws_region
"awslogs-stream-prefix" = "<process_type>"
}
}
essential = true
}])
tags = {
Name = "${var.project_name}-${var.environment}-<process_type>-task"
HerokuApp = "<heroku_app>"
ProcessType = "<process_type>"
}
}Task definition rules:
cpu and memory come from aws_config.task_cpu and aws_config.task_memory (mapped from Dyno Type Table)portMappings included only for web process types (port 8080 default)portMappings. Release process types are run-once hooks and should not be generated as persistent services.# Fargate Service — <heroku_app>:<process_type>
resource "aws_ecs_service" "<app_sanitized>_<process_type>" {
name = "${var.project_name}-${var.environment}-<process_type>"
cluster = aws_ecs_cluster.main.id
task_definition = aws_ecs_task_definition.<app_sanitized>_<process_type>.arn
desired_count = <aws_config.desired_count>
launch_type = "FARGATE"
network_configuration {
subnets = <private_subnet_references>
security_groups = [aws_security_group.app.id]
assign_public_ip = false
}
# Load balancer block included ONLY for web process types
load_balancer {
target_group_arn = aws_lb_target_group.<app_sanitized>_web.arn
container_name = "web"
container_port = 8080
}
depends_on = [aws_lb_listener.https]
tags = {
Name = "${var.project_name}-${var.environment}-<process_type>-svc"
HerokuApp = "<heroku_app>"
ProcessType = "<process_type>"
}
}Service rules:
desired_count from aws_config.desired_count (maps directly from Heroku formation quantity, 0–100)load_balancer block included ONLY when aws_config.load_balancer == true (web process types)load_balancer block and depends_on. Release process types are skipped because they are run-once hooks.assign_public_ip = false — tasks run in private subnets behind NATGenerate ALB resources only when aws-design.json contains ALB service entries:
# Application Load Balancer — <heroku_app> web traffic
# Heroku source: web dyno routing
resource "aws_lb" "<app_sanitized>_web" {
name = "${var.project_name}-${var.environment}-alb"
internal = <false for internet-facing, true for internal>
load_balancer_type = "application"
security_groups = [aws_security_group.alb.id]
subnets = <public_subnet_references>
tags = {
Name = "${var.project_name}-${var.environment}-alb"
HerokuApp = "<heroku_app>"
}
}
resource "aws_lb_target_group" "<app_sanitized>_web" {
name = "${var.project_name}-${var.environment}-tg"
port = 8080
protocol = "HTTP"
vpc_id = <vpc_id_reference>
target_type = "ip"
health_check {
enabled = true
healthy_threshold = 3
unhealthy_threshold = 3
timeout = 5
interval = 30
path = "/"
protocol = "HTTP"
matcher = "200-399"
}
tags = {
Name = "${var.project_name}-${var.environment}-tg"
}
}
resource "aws_lb_listener" "https" {
load_balancer_arn = aws_lb.<app_sanitized>_web.arn
port = 443
protocol = "HTTPS"
ssl_policy = "ELBSecurityPolicy-TLS13-1-2-2021-06"
certificate_arn = var.acm_certificate_arn
default_action {
type = "forward"
target_group_arn = aws_lb_target_group.<app_sanitized>_web.arn
}
}
resource "aws_lb_listener" "http_redirect" {
load_balancer_arn = aws_lb.<app_sanitized>_web.arn
port = 80
protocol = "HTTP"
default_action {
type = "redirect"
redirect {
port = "443"
protocol = "HTTPS"
status_code = "HTTP_301"
}
}
}
variable "acm_certificate_arn" {
description = "ARN of the ACM certificate for HTTPS listener"
type = string
# TODO: Provide your ACM certificate ARN
}ALB rules:
scheme from aws_config.scheme in aws-design.json (default: “internet-facing”)/ (user should customize)Skip this step if no services in aws-design.json have aws_service: "Elastic Beanstalk".
Read preferences.design_constraints.eb_deploy_method.value; default to "github_actions" when the field is absent. Always generate beanstalk.tf for EB services, then generate exactly one deploy path:
"github_actions" → generate $MIGRATION_DIR/.github/workflows/deploy-eb.yml"codepipeline" → generate $MIGRATION_DIR/terraform/pipeline.tf"manual" → generate neither deploy automation artifact; document CLI deployment in MIGRATION_GUIDE.md# Select the latest Elastic Beanstalk Docker platform for Amazon Linux 2023.
# The regex intentionally constrains the lookup to Docker on AL2023 while
# avoiding a hardcoded platform version that can go stale.
data "aws_elastic_beanstalk_solution_stack" "docker" {
most_recent = true
name_regex = "^64bit Amazon Linux 2023 .* running Docker$"
}
resource "aws_elastic_beanstalk_application" "<app_name>" {
name = var.project_name
description = "Migrated from Heroku app: <heroku_app>"
}
resource "aws_elastic_beanstalk_environment" "<app_name>_<process_type>" {
Per-environment rules:
environment_type = "LoadBalanced"; EB auto-provisions the ALB.environment_type = "SingleInstance"; no ALB, no public endpoint, persistent Docker CMD process.release process types. Heroku release-phase commands are run-once deployment hooks and must be handled manually or by a deployment hook.data.aws_elastic_beanstalk_solution_stack.docker.name, not a hardcoded platform version.Emit this file when eb_deploy_method.value is "github_actions" or absent. The workflow uses GitHub OIDC role assumption, packages the source bundle, creates one EB application version, and updates every generated EB environment for the app (web, worker, clock, custom).
name: Deploy Elastic Beanstalk
on:
push:
branches: [main]
permissions:
id-token: write
contents: read
env:
AWS_REGION: <target_region>
EB_APPLICATION_NAME: <app_name>
EB_ENVIRONMENTS: "<space-separated EB environment names from aws-design.json>"
jobs:
deploy:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- uses: aws-actions/configure-aws-credentials@v4
with:
role-to-assume: ${{ secrets.AWS_ROLE_ARN }}
aws-region: ${{ env.AWS_REGION }}
- name: Package source bundle
run: |
zip -r app.zip . -x '.git/*' 'node_modules/*'
- name: Create application version
run: |
VERSION_LABEL="${GITHUB_SHA}-${GITHUB_RUN_NUMBER}"
BUCKET="$(aws elasticbeanstalk create-storage-location --query S3Bucket --output text)"
aws s3 cp app.zip "s3://${BUCKET}/${EB_APPLICATION_NAME}/${VERSION_LABEL}.zip"
aws elasticbeanstalk create-application-version \
--application-name "${EB_APPLICATION_NAME}" \
--version-label "${VERSION_LABEL}" \
--source-bundle "S3Bucket=${BUCKET},S3Key=${EB_APPLICATION_NAME}/${VERSION_LABEL}.zip"
for ENVIRONMENT in ${EB_ENVIRONMENTS}; do
aws elasticbeanstalk update-environment \
--environment-name "${ENVIRONMENT}" \
--version-label "${VERSION_LABEL}"
doneGitHub Actions rules:
EB_ENVIRONMENTS MUST include every generated EB environment for the app, not only <app_name>-web.secrets.AWS_ROLE_ARN; document the required role setup in MIGRATION_GUIDE.md.pipeline.tf when this method is selected.Emit this file only when eb_deploy_method.value is "codepipeline".
resource "aws_codepipeline" "<app_name>_deploy" {
name = "${var.project_name}-deploy"
role_arn = aws_iam_role.codepipeline_<app_name>.arn
artifact_store {
location = aws_s3_bucket.pipeline_artifacts_<app_name>.bucket
type = "S3"
}
stage
CodePipeline rules:
For each service in aws-design.json where aws_service is “RDS PostgreSQL” or “Aurora PostgreSQL”:
resource "aws_db_subnet_group" "main" {
name = "${var.project_name}-${var.environment}-db-subnet"
subnet_ids = <private_subnet_references>
tags = {
Name = "${var.project_name}-${var.environment}-db-subnet"
}
}# RDS PostgreSQL — <heroku_app>
# Heroku source: heroku-postgresql:<plan>
resource "aws_db_instance" "<app_sanitized>_postgres" {
identifier = "${var.project_name}-${var.environment}-postgres"
engine = "postgres"
engine_version = "<aws_config.engine_version>"
instance_class = "<aws_config.instance_class>"
allocated_storage = <aws_config.storage_gb>
# Aurora PostgreSQL — <heroku_app>
# Heroku source: heroku-postgresql:<plan> (multi-az-ha/multi-region availability)
resource "aws_rds_cluster" "<app_sanitized>_aurora" {
cluster_identifier = "${var.project_name}-${var.environment}-aurora"
engine = "aurora-postgresql"
engine_version = "<aws_config.engine_version>"
database_name = var.db_name
master_username = var.db_username
# RDS Proxy — Connection pooling replacement for Heroku connection pooling
resource "aws_db_proxy" "<app_sanitized>_postgres" {
name = "${var.project_name}-${var.environment}-proxy"
debug_logging = false
engine_family = "POSTGRESQL"
idle_client_timeout = 1800
require_tls = true
role_arn = aws_iam_role.rds_proxy.arn
vpc_security_group_ids = [aws_security_group.database.id]
vpc_subnet_ids = <private_subnet_references>
auth {
auth_scheme = "SECRETS"
iam_auth = "DISABLED"
secret_arn = aws_secretsmanager_secret.db_credentials.arn
}
tags = {
Name = "${var.project_name}-${var.environment}-proxy"
HerokuApp = "<heroku_app>"
}
}
resource "aws_db_proxy_default_target_group" "<app_sanitized>_postgres" {
db_proxy_name = aws_db_proxy.<app_sanitized>_postgres.name
connection_pool_config {
max_connections_percent = 100
}
}
resource "aws_db_proxy_target" "<app_sanitized>_postgres" {
db_proxy_name = aws_db_proxy.<app_sanitized>_postgres.name
target_group_name = aws_db_proxy_default_target_group.<app_sanitized>_postgres.name
db_instance_identifier = aws_db_instance.<app_sanitized>_postgres.identifier
}
# Secrets Manager for RDS Proxy authentication
resource "aws_secretsmanager_secret" "db_credentials" {
name = "${var.project_name}-${var.environment}/db-credentials"
tags = {
Name = "${var.project_name}-${var.environment}-db-credentials"
}
}
resource "aws_secretsmanager_secret_version" "db_credentials" {
secret_id = aws_secretsmanager_secret.db_credentials.id
secret_string = jsonencode({
username = var.db_username
password = var.db_password
})
}
# IAM Role for RDS Proxy
resource "aws_iam_role" "rds_proxy" {
name = "${var.project_name}-${var.environment}-rds-proxy"
assume_role_policy = jsonencode({
Version = "2012-10-17"
Statement = [{
Action = "sts:AssumeRole"
Effect = "Allow"
Principal = {
Service = "rds.amazonaws.com"
}
}]
})
tags = {
Name = "${var.project_name}-${var.environment}-rds-proxy-role"
}
}
resource "aws_iam_role_policy" "rds_proxy_secrets" {
name = "secrets-access"
role = aws_iam_role.rds_proxy.id
policy = jsonencode({
Version = "2012-10-17"
Statement = [{
Effect = "Allow"
Action = [
"secretsmanager:GetSecretValue",
"secretsmanager:DescribeSecret"
]
Resource = [aws_secretsmanager_secret.db_credentials.arn]
}]
})
}Database rules:
storage_encrypted = true)skip_final_snapshot = false)max_allocated_storage set to 2× initial for auto-scaling headroomaws_config.rds_proxy == true (connection pooling was enabled on source)For each service in aws-design.json where aws_service is “ElastiCache Redis”:
# ElastiCache Redis — <heroku_app>
# Heroku source: heroku-redis:<plan>
resource "aws_elasticache_subnet_group" "main" {
name = "${var.project_name}-${var.environment}-cache-subnet"
subnet_ids = <private_subnet_references>
tags = {
Name = "${var.project_name}-${var.environment}-cache-subnet"
}
}
resource "aws_elasticache_replication_group" "<app_sanitized>_redis" {
replication_group_id = "${var.project_name}-${var.environment}-redis"
description = "Redis cluster for ${var.project_name} (migrated from Heroku Redis)"
engine = "redis"
engine_version = "<aws_config.engine_version>"
node_type = "<aws_config.node_type>"
num_cache_clusters = <2 if multi_az else 1>
port = 6379
# High Availability
automatic_failover_enabled = <aws_config.automatic_failover>
multi_az_enabled = <aws_config.multi_az>
# Encryption
at_rest_encryption_enabled = true
transit_encryption_enabled = <aws_config.transit_encryption>
# Network
subnet_group_name = aws_elasticache_subnet_group.main.name
security_group_ids = [aws_security_group.cache.id]
# Maintenance
maintenance_window = "<preferences.json.global.maintenance_window formatted>"
snapshot_retention_limit = 7
snapshot_window = "03:00-05:00"
# Parameter group
parameter_group_name = aws_elasticache_parameter_group.<app_sanitized>_redis.name
tags = {
Name = "${var.project_name}-${var.environment}-redis"
HerokuApp = "<heroku_app>"
}
}
resource "aws_elasticache_parameter_group" "<app_sanitized>_redis" {
name = "${var.project_name}-${var.environment}-redis-params"
family = "redis<major_version>"
parameter {
name = "maxmemory-policy"
value = "volatile-lru"
}
tags = {
Name = "${var.project_name}-${var.environment}-redis-params"
}
}ElastiCache rules:
automatic_failover_enabled and multi_az_enabled: Set to true if and only if source Heroku Redis has HA enabled (aws_config.automatic_failover == true)transit_encryption_enabled: Set to true if and only if source has encryption-in-transit (aws_config.transit_encryption == true)at_rest_encryption_enabled: Always true (security best practice)num_cache_clusters: 2 when Multi-AZ enabled, 1 when single-AZengine_version: Matches source Redis version from aws_config.engine_versionnode_type: From aws_config.node_type (mapped from Redis Plan Table)For each service in aws-design.json where aws_service is “Amazon MSK”:
# Amazon MSK — <heroku_app>
# Heroku source: heroku-kafka:<plan>
resource "aws_msk_configuration" "<app_sanitized>_kafka" {
name = "${var.project_name}-${var.environment}-msk-config"
kafka_versions = ["<aws_config.kafka_version || 3.5.1>"]
server_properties = <<PROPERTIES
auto.create.topics.enable=false
default.replication.factor=<aws_config.replication_factor || 3>
num.partitions=<aws_config.default_partitions || 3>
min.insync.replicas=2
log.retention.hours=<preferences.json.data.kafka_retention_days * 24>
PROPERTIES
tags = {
Name = "${var.project_name}-${var.environment}-msk-config"
}
}
resource "aws_msk_cluster" "<app_sanitized>_kafka" {
cluster_name = "${var.project_name}-${var.environment}-msk"
kafka_version = "<aws_config.kafka_version || 3.5.1>"
number_of_broker_nodes = <aws_config.broker_count || 2>
broker_node_group_info {
instance_type = "<aws_config.broker_instance_type>"
client_subnets = <private_subnet_references — one per AZ, matching broker count>
security_groups = [aws_security_group.messaging.id]
storage_info {
ebs_storage_info {
volume_size = <aws_config.storage_gb>
}
}
}
encryption_info {
encryption_in_transit {
client_broker = "TLS"
in_cluster = true
}
}
configuration_info {
arn = aws_msk_configuration.<app_sanitized>_kafka.arn
revision = aws_msk_configuration.<app_sanitized>_kafka.latest_revision
}
logging_info {
broker_logs {
cloudwatch_logs {
enabled = true
log_group = aws_cloudwatch_log_group.msk.name
}
}
}
tags = {
Name = "${var.project_name}-${var.environment}-msk"
HerokuApp = "<heroku_app>"
}
}
resource "aws_cloudwatch_log_group" "msk" {
name = "/msk/${var.project_name}-${var.environment}"
retention_in_days = <preferences.json.operational.log_retention_days || 30>
tags = {
Name = "${var.project_name}-${var.environment}-msk-logs"
}
}MSK rules:
number_of_broker_nodes: Minimum 2, always spread across ≥ 2 AZs (per Requirement 7.4)broker_instance_type: From aws_config.broker_instance_type (mapped from Kafka Plan Table)volume_size: From aws_config.storage_gb (meets or exceeds source plan storage)client_subnets must match the number of broker nodes and span multiple AZspreferences.json.data.kafka_retention_daysreplication_factor and partition counts preserved from source plan topologyAlways write $MIGRATION_DIR/generation-warnings.json — it is a mandatory
artifact of this phase (part of generate’s _produces floor), a manifest that
records whatever could NOT be generated. Write it even when nothing was skipped:
in that case the warnings array is EMPTY ("warnings": []). A consumer can then
rely on the file always existing rather than testing for its absence.
For any service_id in aws-design.json whose aws_service does not have a
Terraform resource mapping defined in Steps 4–9 above:
generation-warnings.json‘s warnings arrayIf every service mapped successfully, still write the file with an empty
warnings array.
{
"generated_at": "<ISO 8601 timestamp>",
"migration_id": "<migration_id>",
"warnings": [
{
"service_id": "<service_id from aws-design.json>",
"aws_service": "<aws_service value>",
"heroku_app": "<heroku_app>",
"source_resource_id": "<source_resource_id>",
"reason": "No Terraform resource mapping available for <aws_service>",
"recommendation": "Configure this service manually in the AWS Console or add a custom Terraform module"
}
],
"total_warnings": <count>,
"total_services_generated": <count of successfully generated services>,
"total_services_skipped": <count of skipped services>
}Warning scenarios that produce entries:
compute.tf log configuration)Exception: If aws_service == "CloudWatch Logs" and it maps from a logging add-on (Papertrail, Rollbar, Sentry), the log group is already emitted in compute.tf Step 6. Do NOT log a warning for this case.
# Terraform state and providers
.terraform/
*.tfstate
*.tfstate.backup
.terraform.lock.hcl
# Variable values (may contain secrets)
terraform.tfvars
*.auto.tfvars
!terraform.tfvars.example
# Crash logs
crash.log
crash.*.log
# Plan files
*.tfplan# Copy this file to terraform.tfvars and fill in values before running terraform plan.
# Do NOT commit terraform.tfvars to source control — it may contain sensitive values.
aws_region = "<target_region>"
project_name = "<project_name>"
environment = "<environment>"
migration_id = "<migration_id>"
# Security baseline contacts (always required — plan fails until all three are real inboxes)
operations_email = "TODO-ops@example.com" # AWS account operations alternate contact
billing_email = "TODO-billing@example.com" # billing alternate contact + budget alert recipient
security_email = "TODO-security@example.com" # security alternate contact
# Database credentials (required if RDS/Aurora is in the design)
# db_username = "app_user"
# db_password = "CHANGE_ME"
# ACM certificate (required if ALB is in the design)
# acm_certificate_arn = "arn:aws:acm:<region>:<account_id>:certificate/<cert-id>"
# Container images (one per Fargate service)
# container_image_<app>_<process_type> = "<account_id>.dkr.ecr.<region>.amazonaws.com/<repo>:<tag>"
# {{IF has_beanstalk_web}}
# Elastic Beanstalk web runtime settings (one required pair per web app; no defaults).
# Repeat these assignments for every Elastic Beanstalk web app, replacing
# <app_sanitized> with its hyphen-to-underscore app name. Leaving any assignment
# absent makes `terraform plan -input=false` stop with a required-variable diagnostic.
# eb_application_port_<app_sanitized>_web = <quoted application listen port>
# eb_health_check_path_<app_sanitized>_web = <quoted HTTP health check path>
# {{ENDIF}}
# Elastic Beanstalk CodePipeline deploy (only when eb_deploy_method = "codepipeline")
# github_connection_arn = "arn:aws:codestar-connections:<region>:<account_id>:connection/<id>"
# github_repo = "owner/repository"
# github_branch = "main"
# Existing VPC (only if Private Space peering is detected)
# existing_vpc_id = "vpc-0123456789abcdef0"
# existing_subnet_ids = ["subnet-aaa", "subnet-bbb"]
# {{IF migration_approach == "interim_cutover_data_first"}}
# Interim Heroku -> RDS access. Bounded allowlist only, never 0.0.0.0/0.
# Source the addresses per MIGRATION_GUIDE.md "Interim Database Exposure" Step 2,
# then reset both to their defaults at cutover.
# interim_heroku_ingress_cidrs = ["203.0.113.10/32", "203.0.113.11/32"]
# interim_db_public_access = false
# {{ENDIF}}After all files are written:
Syntax check: Verify all .tf files are syntactically valid HCL
Reference integrity: Ensure all resource references resolve to declared resources within the same configuration
Variable completeness: Every var.* reference has a corresponding variable block in variables.tf
Output references: Every output references a declared resource attribute
Tag consistency: Every resource has the default tags (applied via provider default_tags)
Security baseline: baseline.tf exists and contains the full always-on resource list from Step 1.5 (three aws_account_alternate_contact, password policy, S3 account PAB, EBS default encryption, Access Analyzer, IMDSv2 account default, CloudTrail + log bucket, budget, GuardDuty); its locals.cloudtrail_retention_days is a positive integer; the compliance-conditional section is present exactly when the normalized compliance array contains soc2/pci/hipaa/fedramp; the three contact email variables are declared without defaults and with placeholder-rejecting validation blocks
Elastic Beanstalk web runtime inputs: For every EB web service, verify its per-app eb_application_port_<app>_web and eb_health_check_path_<app>_web variables are declared without defaults, include the required validation blocks, and are referenced directly by that app’s PORT and HealthCheckPath settings. Verify non-web EB services do not require these variables. Do not report an EB web configuration as ready to plan until the customer has supplied both values for every web app.
Defer the authoritative Terraform policy check to the assembler. Author
terraform/ to satisfy the Step 0 posture, but do not write
validation-report.json here. The assembler runs after every fragment,
including conditional eks-generate, and owns the checker, retry loop, and
canonical v2 report (see generate-assemble.md Step 3).
Scope note:
validate-terraform-policy.pyinspects standaloneaws_lb_listenerblocks. An Elastic Beanstalk LoadBalanced environment’s ALB is provisioned by EB fromaws_elastic_beanstalk_environmentsettingblocks, which the static checker does not read — so a pure-EB design passes the ALB rules vacuously (there is no standalone listener to inspect). That is a known limitation, not a bypass: EB TLS/listener posture is authoring-only here.
When this fragment’s files are written, control returns to generate.md. After all
other fragments finish, generate-assemble.md validates the final Terraform
directory and runs the phase completion handoff gate per its _postconditions.
This file
aws_s3_bucket.cloudtrail_logs plus aws_s3_bucket_public_access_block, aws_s3_bucket_server_side_encryption_configuration, aws_s3_bucket_versioning, aws_s3_bucket_lifecycle_configuration (transitions driven by local.cloudtrail_retention_days per item 7), and aws_s3_bucket_policy restricting the CloudTrail service principal by aws:SourceArnaws_budgets_budget.monthly_spend (ACCT.10; limit_amount = "<budget_limit>" from item 2; three notification blocks at 50/80/100% ACTUAL; subscriber_email_addresses = [var.billing_email] — same fill-once variable as the alternate contact, entered exactly once in tfvars)aws_guardduty_detector.baseline (defense-in-depth; enable = true, finding_publishing_frequency = "FIFTEEN_MINUTES")