SkyForge

Terraform / IaC

SkyForge

Terraform for AWS: a single-EC2 Docker host, or ECS Fargate, tested offline.

Reference architecture and offline tests for a fictional Initech checkout-api. This is not a client project. No AWS account is used and nothing here is a price.

构建时记录 . Recorded when this demo was built. The page does not re-run Terraform, tflint, or checkov.

Two routes

A. Single EC2
Clients Elastic IP EC2 t3, Amazon Linux 2023 nginx checkout-api Docker Compose from user_data Security group 80 and 443 EBS gp3, encrypted DLM daily snapshot SSM Session Manager no port 22 CloudWatch agent logs
B. ECS Fargate
Clients ALB + ACM 443 forward 80 redirect VPC, two availability zones Public subnet one NAT optional off Private AZ a Fargate task Private AZ b Fargate task ECR SSM parameter Optional RDS Postgres private subnets only CloudWatch Logs CPU autoscaling

Resource inventory

Counted with python-hcl2 from the .tf files. A module file counts once, not once per caller.

79 resource blocks, 7 module blocks.

Resource typeCount
aws_appautoscaling_policy1
aws_appautoscaling_target1
aws_cloudwatch_log_group3
aws_db_instance1
aws_db_subnet_group1
aws_default_security_group1
aws_dlm_lifecycle_policy1
aws_dynamodb_table1
aws_ecr_lifecycle_policy1
aws_ecr_repository1
aws_ecs_cluster1
aws_ecs_service1
aws_ecs_task_definition1
aws_eip2
aws_flow_log1
aws_iam_instance_profile1
aws_iam_role6
aws_iam_role_policy6
aws_iam_role_policy_attachment1
aws_instance1
aws_internet_gateway1
aws_kms_alias3
aws_kms_key3
aws_lb1
aws_lb_listener2
aws_lb_target_group1
aws_nat_gateway1
aws_route2
aws_route_table2
aws_route_table_association2
aws_s3_bucket2
aws_s3_bucket_lifecycle_configuration2
aws_s3_bucket_ownership_controls2
aws_s3_bucket_policy2
aws_s3_bucket_public_access_block2
aws_s3_bucket_server_side_encryption_configuration2
aws_s3_bucket_versioning2
aws_security_group5
aws_security_group_rule1
aws_ssm_parameter3
aws_subnet2
aws_vpc1
aws_vpc_endpoint2

Module calls

ModuleSourceFile
state../modules/state_backendbootstrap/main.tf
network../modules/networkecs-fargate/main.tf
ecr../modules/ecrecs-fargate/main.tf
alb../modules/albecs-fargate/main.tf
service../modules/ecs_serviceecs-fargate/main.tf
network../modules/networksingle-ec2/main.tf
host../modules/ec2_docker_hostsingle-ec2/main.tf

Checks

构建时记录. These cards repeat data/checks.json. Opening the page does not run Terraform.

terraform: Terraform v1.9.8 · tflint: TFLint version 0.55.1 · checkov: 3.2.449

AWS_ACCESS_KEY_ID was empty when these commands ran.

terraform fmt -check

pass

exit 0

Started 2026-10-03T06:25:29Z · 0.181s

terraform validate

pass

3 roots

  • single_ec2 exit 0 · 2.04s
  • ecs_fargate exit 0 · 2.431s
  • bootstrap exit 0 · 2.007s

tflint

pass

exit 0

Started 2026-10-03T06:25:35Z · 1.058s

checkov

fail

14 failed

passed 218, failed 14, skipped 2. HIGH failures: 0.

checkov 3.2.449 leaves severity empty unless a Bridgecrew or Prisma API key is set. Each severity below is the Severity field on that check's public Prisma Cloud policy page, fetched while this file was written.

Failed checks

  • CKV_AWS_260 LOW · module.alb.aws_security_group.alb · Ensure no security groups allow ingress from 0.0.0.0:0 to port 80
  • CKV_AWS_150 LOW · module.alb.aws_lb.this · Ensure that Load Balancer has deletion protection enabled
  • CKV_AWS_260 LOW · module.host.aws_security_group.host · Ensure no security groups allow ingress from 0.0.0.0:0 to port 80
  • CKV2_AWS_62 LOW · module.alb.aws_s3_bucket.logs · Ensure S3 buckets should have event notifications enabled
  • CKV2_AWS_62 LOW · module.state.aws_s3_bucket.state · Ensure S3 buckets should have event notifications enabled
  • CKV2_AWS_19 LOW · module.network.aws_eip.nat[0] · Ensure that all EIP addresses allocated to a VPC are attached to EC2 instances
  • CKV_AWS_144 LOW · module.alb.aws_s3_bucket.logs · Ensure that S3 bucket has cross-region replication enabled
  • CKV_AWS_144 LOW · module.state.aws_s3_bucket.state · Ensure that S3 bucket has cross-region replication enabled
  • CKV2_AWS_5 LOW · module.network.aws_security_group.endpoints[0] · Ensure that Security Groups are attached to another resource
  • CKV2_AWS_34 LOW · module.host.aws_ssm_parameter.cwagent · AWS SSM Parameter should be Encrypted
  • CKV2_AWS_30 LOW · aws_db_instance.app · Ensure Postgres RDS as aws_db_instance has Query Logging enabled
  • CKV_AWS_18 LOW · module.alb.aws_s3_bucket.logs · Ensure the S3 bucket has access logging enabled
  • CKV_AWS_18 LOW · module.state.aws_s3_bucket.state · Ensure the S3 bucket has access logging enabled
  • CKV2_AWS_28 LOW · module.alb.aws_lb.this · Ensure public facing ALB are protected by WAF

HIGH exceptions

  • CKV_AWS_355 HIGH · module.host.aws_iam_role_policy.session — ssmmessages, ec2messages, and ssm:UpdateInstanceInformation do not support resource-level permissions; AWS requires Resource *
  • CKV_AWS_290 HIGH · module.host.aws_iam_role_policy.session — those Session Manager write actions cannot be constrained to a resource ARN

terraform test

pass

6 runs

Started 2026-10-03T06:25:41Z · 5.071s. passed 6, failed 0, errored 0, skipped 0.

  • ec2_dev_sg_and_tags pass
  • ec2_prod_size pass
  • ecs_dev_alb_role_and_tags pass
  • ecs_prod_size pass
  • ecs_without_nat pass
  • ecs_with_rds pass

Which route

No prices. Sizes are read from envs/dev.tfvars and envs/prod.tfvars.

Single EC2ECS Fargate
ShapeOne host, Compose, user_dataVPC, ALB, ECR, Fargate service
AvailabilityOne machine in one zoneTwo zones, load balancer, rolling deploy
OperationsPatch the OS. SSM instead of SSH. Daily EBS snapshots.No host OS. Ship a new task definition.
Change in sizeReplace the instanceCPU target tracking between min and max
Dev in the examplet3.small256 CPU / 512 MiB
Prod in the examplet3.medium512 CPU / 1024 MiB
Fits whenThe service can live on one machineA task or a zone failing should not take the service down

Files

modules/ec2_docker_host/main.tf

data "aws_ami" "al2023" {
  most_recent = true
  owners      = ["amazon"]

  filter {
    name   = "name"
    values = ["al2023-ami-2023.*-x86_64"]
  }

  filter {
    name   = "architecture"
    values = ["x86_64"]
  }

  filter {
    name   = "virtualization-type"
    values = ["hvm"]
  }
}

data "aws_caller_identity" "current" {}

locals {
  tags = {
    Project = var.project
    Env     = var.env
    Owner   = var.owner
  }

  account_id = data.aws_caller_identity.current.account_id
}

resource "aws_security_group" "host" {
  name        = "${var.name}-host"
  description = "HTTP and HTTPS only. Administration uses Session Manager."
  vpc_id      = var.vpc_id
  tags        = merge(local.tags, { Name = "${var.name}-host" })

  ingress {
    description = "http"
    from_port   = 80
    to_port     = 80
    protocol    = "tcp"
    cidr_blocks = ["0.0.0.0/0"]
  }

  ingress {
    description = "https"
    from_port   = 443
    to_port     = 443
    protocol    = "tcp"
    cidr_blocks = ["0.0.0.0/0"]
  }

  egress {
    description = "https for packages images and aws apis"
    from_port   = 443
    to_port     = 443
    protocol    = "tcp"
    cidr_blocks = ["0.0.0.0/0"]
  }

  egress {
    description = "dns"
    from_port   = 53
    to_port     = 53
    protocol    = "udp"
    cidr_blocks = ["0.0.0.0/0"]
  }
}

resource "aws_cloudwatch_log_group" "agent" {
  name              = "/${var.project}/${var.env}/ec2"
  retention_in_days = 365
  kms_key_id        = var.kms_key_arn
  tags              = local.tags
}

resource "aws_ssm_parameter" "app_secret" {
  name   = "/${var.project}/${var.env}/checkout-api/app_secret"
  type   = "SecureString"
  key_id = var.kms_key_arn
  value  = var.app_secret
  tags   = local.tags
}

resource "aws_ssm_parameter" "cwagent" {
  name = "/${var.project}/${var.env}/cloudwatch-agent"
  type = "String"
  tags = local.tags
  value = jsonencode({
    logs = {
      logs_collected = {
        files = {
          collect_list = [{
            file_path       = "/var/log/messages"
            log_group_name  = aws_cloudwatch_log_group.agent.name
            log_stream_name = "{instance_id}"
          }]
        }
      }
    }
  })
}

resource "aws_iam_role" "instance" {
  name = "${var.name}-instance"
  tags = local.tags

  assume_role_policy = jsonencode({
    Version = "2012-10-17"
    Statement = [{
      Effect    = "Allow"
      Principal = { Service = "ec2.amazonaws.com" }
      Action    = "sts:AssumeRole"
    }]
  })
}

resource "aws_iam_role_policy" "instance" {
  name = "${var.name}-instance"
  role = aws_iam_role.instance.id

  policy = jsonencode({
    Version = "2012-10-17"
    Statement = [
      {
        Sid    = "ReadParameters"
        Effect = "Allow"
        Action = ["ssm:GetParameter"]
        Resource = [
          aws_ssm_parameter.cwagent.arn,
          aws_ssm_parameter.app_secret.arn,
        ]
      },
      {
        Sid      = "DecryptParameters"
        Effect   = "Allow"
        Action   = ["kms:Decrypt"]
        Resource = var.kms_key_arn
      },
      {
        Sid    = "WriteAgentLogs"
        Effect = "Allow"
        Action = [
          "logs:CreateLogStream",
          "logs:PutLogEvents",
          "logs:DescribeLogStreams",
        ]
        Resource = "${aws_cloudwatch_log_group.agent.arn}:*"
      },
    ]
  })
}

# Session Manager's channel and message APIs do not support resource-level
# permissions. AWS documents Resource "*" for them. Checkov still treats the
# write actions as unconstrained, so the exception is recorded in checks.json.
resource "aws_iam_role_policy" "session" {
  #checkov:skip=CKV_AWS_355:ssmmessages, ec2messages, and ssm:UpdateInstanceInformation do not support resource-level permissions; AWS requires Resource *
  #checkov:skip=CKV_AWS_290:those Session Manager write actions cannot be constrained to a resource ARN
  name = "${var.name}-session"
  role = aws_iam_role.instance.id

  policy = jsonencode({
    Version = "2012-10-17"
    Statement = [{
      Sid    = "SessionManager"
      Effect = "Allow"
      Action = [
        "ssmmessages:CreateControlChannel",
        "ssmmessages:CreateDataChannel",
        "ssmmessages:OpenControlChannel",
        "ssmmessages:OpenDataChannel",
        "ssm:UpdateInstanceInformation",
        "ec2messages:GetMessages",
        "ec2messages:AcknowledgeMessage",
        "ec2messages:SendReply",
      ]
      Resource = "*"
    }]
  })
}

resource "aws_iam_instance_profile" "instance" {
  name = "${var.name}-instance"
  role = aws_iam_role.instance.name
  tags = local.tags
}

resource "aws_instance" "host" {
  ami                         = data.aws_ami.al2023.id
  instance_type               = var.instance_type
  subnet_id                   = var.subnet_id
  vpc_security_group_ids      = [aws_security_group.host.id]
  iam_instance_profile        = aws_iam_instance_profile.instance.name
  associate_public_ip_address = false
  monitoring                  = true
  ebs_optimized               = true
  user_data = templatefile("${path.module}/user_data.sh.tpl", {
    app_image    = var.app_image
    aws_region   = var.region
    ssm_param    = aws_ssm_parameter.cwagent.name
    secret_param = aws_ssm_parameter.app_secret.name
  })
  tags = merge(local.tags, { Name = var.name })

  metadata_options {
    http_endpoint               = "enabled"
    http_tokens                 = "required"
    http_put_response_hop_limit = 1
  }

  root_block_device {
    volume_type = "gp3"
    volume_size = var.volume_size
    encrypted   = true
    kms_key_id  = var.kms_key_arn
    tags        = merge(local.tags, { Snapshot = "daily" })
  }
}

resource "aws_eip" "host" {
  domain   = "vpc"
  instance = aws_instance.host.id
  tags     = merge(local.tags, { Name = "${var.name}-eip" })
}

resource "aws_iam_role" "dlm" {
  name = "${var.name}-dlm"
  tags = local.tags

  assume_role_policy = jsonencode({
    Version = "2012-10-17"
    Statement = [{
      Effect    = "Allow"
      Principal = { Service = "dlm.amazonaws.com" }
      Action    = "sts:AssumeRole"
    }]
  })
}

resource "aws_iam_role_policy" "dlm" {
  name = "${var.name}-dlm"
  role = aws_iam_role.dlm.id

  policy = jsonencode({
    Version = "2012-10-17"
    Statement = [
      {
        Sid    = "ManageSnapshots"
        Effect = "Allow"
        Action = [
          "ec2:CreateSnapshot",
          "ec2:CreateTags",
          "ec2:DeleteSnapshot",
        ]
        Resource = [
          "arn:aws:ec2:${var.region}:${local.account_id}:volume/*",
          "arn:aws:ec2:${var.region}:${local.account_id}:snapshot/*",
        ]
      },
      {
        Sid    = "DescribeForDlm"
        Effect = "Allow"
        Action = [
          "ec2:DescribeInstances",
          "ec2:DescribeVolumes",
          "ec2:DescribeSnapshots",
        ]
        # Describe actions for DLM do not support resource-level permissions.
        Resource = "*"
      },
    ]
  })
}

resource "aws_dlm_lifecycle_policy" "daily" {
  description        = "Daily snapshots of ${var.name} volumes"
  execution_role_arn = aws_iam_role.dlm.arn
  state              = "ENABLED"
  tags               = local.tags

  policy_details {
    resource_types = ["VOLUME"]

    target_tags = {
      Snapshot = "daily"
    }

    schedule {
      name = "daily"

      create_rule {
        interval      = 24
        interval_unit = "HOURS"
        times         = ["03:00"]
      }

      retain_rule {
        count = var.snapshot_retain
      }

      copy_tags = true
    }
  }
}