# ============================================================================= # AI Software Factory — Compute (EC2 node groups behind an ALB) # # CHOICE (documented): plain EC2 + systemd, NOT ECS/EKS. # # Why: this is the "simple self-host" path of the repo. A 4-app stack with # ~8-16 total nodes is trivially operable with systemd units + an ALB, needs # no container orchestration, and every component stays directly debuggable # (logs in /var/log/journal, psql/redis-cli via SSH). The k8s manifests in # ../kubernetes/ are the alternative (recommended production) path and # deploy the SAME components; pick one per environment. # # Each app (web, api, worker, realtime) gets: # - an IAM instance profile (SSM read for its .env, KMS use, logs) # - a launch template (Ubuntu 22.04, Node 20, systemd units, healthbeat) # - an Auto Scaling Group in the private subnets # The api/worker/realtime nodes have no LB attachment (api is reached # through the web ALB path routing; worker/realtime are internal). # The web nodes are fronted by the ALB (443) with path-based routing: # /ws → realtime TG :4001 # /api → api TG :4000 # / → web TG :3000 # # Secrets: application env is read from AWS SSM Parameter Store # (namespace /factory/) at boot by the cloud-init script below. # Pre-create the parameters (same names as .env.example) before first apply, # e.g.: # aws ssm put-parameter --name /factory/dev/JWT_SECRET \ # --type SecureString --value "$(openssl rand -hex 32)" \ # --overwrite # ============================================================================= # --- AMI (latest Ubuntu 22.04 LTS) -------------------------------------------- data "aws_ami" "ubuntu" { most_recent = true owners = ["099720109477"] # Canonical filter { name = "name" values = ["ubuntu/images/hvm-ssd/ubuntu-jammy-22.04-amd64-server-*"] } filter { name = "virtualization-type" values = ["hvm"] } } # --- IAM ----------------------------------------------------------------------- data "aws_iam_policy_document" "app_assume" { statement { actions = ["sts:AssumeRole"] principals { type = "Service" identifiers = ["ec2.amazonaws.com"] } } } resource "aws_iam_role" "app" { name = "${local.names.project}-${local.env}-app" assume_role_policy = data.aws_iam_policy_document.app_assume.json } # Read the /factory//* SSM parameters (the .env values) + KMS decrypt. data "aws_iam_policy_document" "app_instance" { statement { effect = "Allow" actions = [ "ssm:GetParameters", "ssm:GetParameter", "kms:Decrypt", ] resources = [ "arn:aws:ssm:${var.aws_region}:${data.aws_caller_identity.current.account_id}:parameter/${local.names.project}/${local.env}/*", aws_kms_key.app.arn, ] } statement { effect = "Allow" actions = [ "logs:CreateLogGroup", "logs:CreateLogStream", "logs:PutLogEvents", ] resources = ["arn:aws:logs:${var.aws_region}:${data.aws_caller_identity.current.account_id}:log-group:/factory/${local.env}/*"] } } data "aws_caller_identity" "current" {} resource "aws_iam_role_policy" "app_instance" { name = "${local.names.project}-${local.env}-app-instance" role = aws_iam_role.app.id policy = data.aws_iam_policy_document.app_instance.json } resource "aws_iam_instance_profile" "app" { name = "${local.names.project}-${local.env}-app" role = aws_iam_role.app.name } # --- ALB ------------------------------------------------------------------------ resource "aws_lb" "web" { name = local.names.web_alb internal = false load_balancer_type = "application" security_groups = [aws_security_group.edge.id] subnets = aws_subnet.public[*].id enable_deletion_protection = false tags = { Name = local.names.web_alb } } resource "aws_lb_listener" "http" { load_balancer_arn = aws_lb.web.arn port = 80 protocol = "HTTP" # Redirect everything to HTTPS. default_action { type = "redirect" redirect { port = "443" protocol = "HTTPS" status_code = "HTTP_301" } } } # TLS certificate for the public URL. # MANUAL STEP: after the first apply creates the ACM certificate + the # aws_route53_record below, copy the DNS validation record into your DNS zone # (or create the CNAME manually), then `terraform apply` again — the # certificate only becomes valid after ACM verifies ownership. resource "aws_acm_certificate" "web" { domain_name = replace(var.public_url, "https://", "") validation_method = "DNS" subject_alt_names = [] lifecycle { # Allow replacing the certificate without destroying the whole stack. create_before_destroy = true } } # Waits until ACM has validated the certificate. The validation CNAME records # are NOT created automatically (this stack does not manage your DNS zone): # 1. run `terraform apply` once (the certificate + records are planned), # 2. add the CNAME records from `terraform output acm_validation_records` # to your DNS zone (Route53 or wherever factory.example.com lives), # 3. run `terraform apply` again — validation completes and the listener # is wired to the certificate. resource "aws_acm_certificate_validation" "web" { certificate_arn = aws_acm_certificate.web.arn validation_retry_attempts = 30 } resource "aws_lb_listener" "https" { load_balancer_arn = aws_lb.web.arn port = 443 protocol = "HTTPS" certificate_arn = aws_acm_certificate_validation.web.certificate_arn ssl_policy = "ELBSecurityPolicy-TLS13-1-2-2021-06" # /api/* → api (REST) action { type = "forward" target_group_arn = aws_lb_target_group.api.arn condition { path_pattern { values = ["/api*"] } } } # /ws/* → realtime (WebSocket; ALB forwards the Upgrade/Connection # headers natively for forwarded targets). action { type = "forward" target_group_arn = aws_lb_target_group.realtime.arn condition { path_pattern { values = ["/ws*"] } } } # default (everything else, incl. "/") → web (Next.js) default_action { type = "forward" target_group_arn = aws_lb_target_group.web.arn } } # --- Target groups ----------------------------------------------------------------- resource "aws_lb_target_group" "web" { name = "${local.names.web_alb}-web" port = 3000 protocol = "HTTP" vpc_id = aws_vpc.main.id subnets = aws_subnet.private[*].id target_type = "instance" health_check { enabled = true path = "/" port = "traffic-port" protocol = "HTTP" healthy_threshold = 2 unhealthy_threshold = 3 timeout = 5 interval = 15 matcher = "200-399" } tags = { Name = "${local.names.web_alb}-web" } } resource "aws_lb_target_group" "api" { name = "${local.names.web_alb}-api" port = 4000 protocol = "HTTP" vpc_id = aws_vpc.main.id subnets = aws_subnet.private[*].id target_type = "instance" health_check { enabled = true path = "/api/v1/health/ready" port = "traffic-port" protocol = "HTTP" healthy_threshold = 2 unhealthy_threshold = 3 timeout = 5 interval = 15 matcher = "200" } tags = { Name = "${local.names.web_alb}-api" } } resource "aws_lb_target_group" "realtime" { name = "${local.names.web_alb}-realtime" port = 4001 protocol = "TCP" # WS is long-lived TCP; L4 health check avoids # HTTP assumptions on the socket port. vpc_id = aws_vpc.main.id subnets = aws_subnet.private[*].id target_type = "instance" health_check { enabled = true port = "traffic-port" protocol = "TCP" healthy_threshold = 2 unhealthy_threshold = 3 timeout = 5 interval = 15 } tags = { Name = "${local.names.web_alb}-realtime" } } # --- Node groups --------------------------------------------------------------------- # The cloud-init below is shared by all four app types; the difference is the # unit names it enables (FACTORY_APP env) and the launch template instance # type. Each group scales independently (see min/max per ASG). locals { # cloud-init for app nodes. Reads .env values from SSM Parameter Store, # installs Node 20 + pnpm, clones the repo, builds, and enables the app's # systemd unit. FACTORY_APP is one of: web | api | worker | realtime. cloud_init_app = </ — same names as .env.example. # Pre-create them with `aws ssm put-parameter` before first boot. export FACTORY_APP="__APP__" mkdir -p /opt/factory cd /opt/factory for p in $(aws ssm get-parameters-by-path \ --path "/factory/__ENV__" --with-decryption \ --query 'Parameters[].{n:Name,v:Value}' --output text); do name=$(echo "$p" | awk '{print $1}') val=$(echo "$p" | awk '{print $2}') echo "$name=$val" >> /opt/factory/.env done # --- Source (in production, replace git clone with your image/artifact # distribution — e.g. a golden AMI or S3-deployed build tarball) --- git clone --depth 1 https://github.com/ai-software-factory/ai-software-factory.git /opt/factory/src cd /opt/factory/src cp /opt/factory/.env .env pnpm install --frozen-lockfile pnpm build # --- Systemd unit (generic template rendered per app) --- cat > /etc/systemd/system/factory.service <<'UNIT' [Unit] Description=AI Software Factory __APP__ After=network.target [Service] User=factory WorkingDirectory=/opt/factory/src EnvironmentFile=/opt/factory/.env ExecStart=/opt/factory/src/apps/__APP__/run.sh Restart=always RestartSec=5 LimitNOFILE=65536 [Install] WantedBy=multi-user.target UNIT # --- Run as unprivileged user --- id factory &>/dev/null || useradd -r -m -d /opt/factory -s /sbin/nologin factory chown -R factory:factory /opt/factory # run.sh is a thin wrapper (see repo apps//run.sh); falls back to node. cat > /opt/factory/src/apps/__APP__/run.sh <<'RUN' #!/bin/bash cd "$(dirname "$0")" exec node dist/main.js || pnpm start RUN chmod +x /opt/factory/src/apps/__APP__/run.sh systemctl daemon-reload systemctl enable --now factory.service EOF } locals { cloud_init_web = replace(local.cloud_init_app, "__APP__", "web") cloud_init_api = replace(local.cloud_init_app, "__APP__", "api") cloud_init_worker = replace(local.cloud_init_app, "__APP__", "worker") cloud_init_realtime = replace(local.cloud_init_app, "__APP__", "realtime") # (The __ENV__ token is substituted per-stack at launch template creation # via the `__ENV__` → env mapping below; kept as a second pass so the # shared block above stays readable.) cloud_init_web_final = replace(local.cloud_init_web, "__ENV__", local.env) cloud_init_api_final = replace(local.cloud_init_api, "__ENV__", local.env) cloud_init_worker_final = replace(local.cloud_init_worker, "__ENV__", local.env) cloud_init_realtime_final = replace(local.cloud_init_realtime, "__ENV__", local.env) } # --- Web node group (behind ALB, ASG 2..10) --------------------------------------- resource "aws_launch_template" "web" { name_prefix = "${local.names.project}-${local.env}-web-" image_id = data.aws_ami.ubuntu.id instance_type = var.instance_type_web vpc_security_group_ids = [aws_security_group.app.id] user_data = base64encode(local.cloud_init_web_final) iam_instance_profile { name = aws_iam_instance_profile.app.name } block_device_mappings { device_name = "/dev/xvda" ebs { volume_size = 50 volume_type = "gp3" encrypted = true } } metadata_options { http_tokens = "required" # IMDSv2 only http_endpoint = "enabled" } tag_specifications { resource_type = "instance" tags = local.common_tags } tag_specifications { resource_type = "volume" tags = local.common_tags } } resource "aws_autoscaling_group" "web" { name = "${local.names.project}-${local.env}-web" desired_capacity = var.node_count min_size = 2 max_size = 10 vpc_zone_identifier = aws_subnet.private[*].id target_group_arns = [aws_lb_target_group.web.arn] launch_template { id = aws_launch_template.web.id version = "$Latest" } health_check_type = "ELB" health_check_grace_period = 300 terminated_on_shutdown = false tags = [ { key = "Name" value = "${local.names.project}-${local.env}-web" propagate_at_launch = true }, ] } # --- API node group (ASG 2..8; reached via ALB path routing /api) --------------- resource "aws_launch_template" "api" { name_prefix = "${local.names.project}-${local.env}-api-" image_id = data.aws_ami.ubuntu.id instance_type = var.instance_type_api vpc_security_group_ids = [aws_security_group.app.id] user_data = base64encode(local.cloud_init_api_final) iam_instance_profile { name = aws_iam_instance_profile.app.name } block_device_mappings { device_name = "/dev/xvda" ebs { volume_size = 50 volume_type = "gp3" encrypted = true } } metadata_options { http_tokens = "required" http_endpoint = "enabled" } tag_specifications { resource_type = "instance" tags = local.common_tags } } resource "aws_autoscaling_group" "api" { name = "${local.names.project}-${local.env}-api" desired_capacity = var.node_count min_size = 2 max_size = 8 vpc_zone_identifier = aws_subnet.private[*].id target_group_arns = [aws_lb_target_group.api.arn] launch_template { id = aws_launch_template.api.id version = "$Latest" } health_check_type = "ELB" health_check_grace_period = 300 tags = [ { key = "Name" value = "${local.names.project}-${local.env}-api" propagate_at_launch = true }, ] } # --- Worker node group (ASG 2..8; no LB — consumes Redis queues) ----------------- resource "aws_launch_template" "worker" { name_prefix = "${local.names.project}-${local.env}-worker-" image_id = data.aws_ami.ubuntu.id instance_type = var.instance_type_worker vpc_security_group_ids = [aws_security_group.app.id] user_data = base64encode(local.cloud_init_worker_final) iam_instance_profile { name = aws_iam_instance_profile.app.name } block_device_mappings { device_name = "/dev/xvda" ebs { volume_size = 50 volume_type = "gp3" encrypted = true } } metadata_options { http_tokens = "required" http_endpoint = "enabled" } tag_specifications { resource_type = "instance" tags = local.common_tags } } resource "aws_autoscaling_group" "worker" { name = "${local.names.project}-${local.env}-worker" desired_capacity = var.node_count min_size = 2 max_size = 8 vpc_zone_identifier = aws_subnet.private[*].id # No target group: workers are headless. Health = ELB is impossible, so use # EC2 health checks; a dead node is replaced, a sick-but-alive node is not # (acceptable for the simple path; the k8s path has a heartbeat probe). target_group_arns = [] launch_template { id = aws_launch_template.worker.id version = "$Latest" } health_check_type = "EC2" health_check_grace_period = 300 tags = [ { key = "Name" value = "${local.names.project}-${local.env}-worker" propagate_at_launch = true }, ] } # --- Realtime node group (ASG 2..6; WS at :4001 via ALB /ws) ---------------------- resource "aws_launch_template" "realtime" { name_prefix = "${local.names.project}-${local.env}-realtime-" image_id = data.aws_ami.ubuntu.id instance_type = var.instance_type_realtime vpc_security_group_ids = [aws_security_group.app.id] user_data = base64encode(local.cloud_init_realtime_final) iam_instance_profile { name = aws_iam_instance_profile.app.name } block_device_mappings { device_name = "/dev/xvda" ebs { volume_size = 50 volume_type = "gp3" encrypted = true } } metadata_options { http_tokens = "required" http_endpoint = "enabled" } tag_specifications { resource_type = "instance" tags = local.common_tags } } resource "aws_autoscaling_group" "realtime" { name = "${local.names.project}-${local.env}-realtime" desired_capacity = var.node_count min_size = 2 max_size = 6 vpc_zone_identifier = aws_subnet.private[*].id target_group_arns = [aws_lb_target_group.realtime.arn] launch_template { id = aws_launch_template.realtime.id version = "$Latest" } health_check_type = "ELB" health_check_grace_period = 300 tags = [ { key = "Name" value = "${local.names.project}-${local.env}-realtime" propagate_at_launch = true }, ] }