1# =============================================================================
2# AI Software Factory — Compute (EC2 node groups behind an ALB)
3#
4# CHOICE (documented): plain EC2 + systemd, NOT ECS/EKS.
5#
6# Why: this is the "simple self-host" path of the repo. A 4-app stack with
7# ~8-16 total nodes is trivially operable with systemd units + an ALB, needs
8# no container orchestration, and every component stays directly debuggable
9# (logs in /var/log/journal, psql/redis-cli via SSH). The k8s manifests in
10# ../kubernetes/ are the alternative (recommended production) path and
11# deploy the SAME components; pick one per environment.
12#
13# Each app (web, api, worker, realtime) gets:
14# - an IAM instance profile (SSM read for its .env, KMS use, logs)
15# - a launch template (Ubuntu 22.04, Node 20, systemd units, healthbeat)
16# - an Auto Scaling Group in the private subnets
17# The api/worker/realtime nodes have no LB attachment (api is reached
18# through the web ALB path routing; worker/realtime are internal).
19# The web nodes are fronted by the ALB (443) with path-based routing:
20# /ws → realtime TG :4001
21# /api → api TG :4000
22# / → web TG :3000
23#
24# Secrets: application env is read from AWS SSM Parameter Store
25# (namespace /factory/<env>) at boot by the cloud-init script below.
26# Pre-create the parameters (same names as .env.example) before first apply,
27# e.g.:
28# aws ssm put-parameter --name /factory/dev/JWT_SECRET \
29# --type SecureString --value "$(openssl rand -hex 32)" \
30# --overwrite
31# =============================================================================
32
33# --- AMI (latest Ubuntu 22.04 LTS) --------------------------------------------
34
35data "aws_ami" "ubuntu" {
36 most_recent = true
37 owners = ["099720109477"] # Canonical
38
39 filter {
40 name = "name"
41 values = ["ubuntu/images/hvm-ssd/ubuntu-jammy-22.04-amd64-server-*"]
42 }
43
44 filter {
45 name = "virtualization-type"
46 values = ["hvm"]
47 }
48}
49
50# --- IAM -----------------------------------------------------------------------
51
52data "aws_iam_policy_document" "app_assume" {
53 statement {
54 actions = ["sts:AssumeRole"]
55
56 principals {
57 type = "Service"
58 identifiers = ["ec2.amazonaws.com"]
59 }
60 }
61}
62
63resource "aws_iam_role" "app" {
64 name = "${local.names.project}-${local.env}-app"
65 assume_role_policy = data.aws_iam_policy_document.app_assume.json
66}
67
68# Read the /factory/<env>/* SSM parameters (the .env values) + KMS decrypt.
69data "aws_iam_policy_document" "app_instance" {
70 statement {
71 effect = "Allow"
72 actions = [
73 "ssm:GetParameters",
74 "ssm:GetParameter",
75 "kms:Decrypt",
76 ]
77 resources = [
78 "arn:aws:ssm:${var.aws_region}:${data.aws_caller_identity.current.account_id}:parameter/${local.names.project}/${local.env}/*",
79 aws_kms_key.app.arn,
80 ]
81 }
82
83 statement {
84 effect = "Allow"
85 actions = [
86 "logs:CreateLogGroup",
87 "logs:CreateLogStream",
88 "logs:PutLogEvents",
89 ]
90 resources = ["arn:aws:logs:${var.aws_region}:${data.aws_caller_identity.current.account_id}:log-group:/factory/${local.env}/*"]
91 }
92}
93
94data "aws_caller_identity" "current" {}
95
96resource "aws_iam_role_policy" "app_instance" {
97 name = "${local.names.project}-${local.env}-app-instance"
98 role = aws_iam_role.app.id
99
100 policy = data.aws_iam_policy_document.app_instance.json
101}
102
103resource "aws_iam_instance_profile" "app" {
104 name = "${local.names.project}-${local.env}-app"
105 role = aws_iam_role.app.name
106}
107
108# --- ALB ------------------------------------------------------------------------
109
110resource "aws_lb" "web" {
111 name = local.names.web_alb
112 internal = false
113 load_balancer_type = "application"
114 security_groups = [aws_security_group.edge.id]
115 subnets = aws_subnet.public[*].id
116
117 enable_deletion_protection = false
118
119 tags = {
120 Name = local.names.web_alb
121 }
122}
123
124resource "aws_lb_listener" "http" {
125 load_balancer_arn = aws_lb.web.arn
126 port = 80
127 protocol = "HTTP"
128
129 # Redirect everything to HTTPS.
130 default_action {
131 type = "redirect"
132 redirect {
133 port = "443"
134 protocol = "HTTPS"
135 status_code = "HTTP_301"
136 }
137 }
138}
139
140# TLS certificate for the public URL.
141# MANUAL STEP: after the first apply creates the ACM certificate + the
142# aws_route53_record below, copy the DNS validation record into your DNS zone
143# (or create the CNAME manually), then `terraform apply` again — the
144# certificate only becomes valid after ACM verifies ownership.
145resource "aws_acm_certificate" "web" {
146 domain_name = replace(var.public_url, "https://", "")
147 validation_method = "DNS"
148
149 subject_alt_names = []
150
151 lifecycle {
152 # Allow replacing the certificate without destroying the whole stack.
153 create_before_destroy = true
154 }
155}
156
157# Waits until ACM has validated the certificate. The validation CNAME records
158# are NOT created automatically (this stack does not manage your DNS zone):
159# 1. run `terraform apply` once (the certificate + records are planned),
160# 2. add the CNAME records from `terraform output acm_validation_records`
161# to your DNS zone (Route53 or wherever factory.example.com lives),
162# 3. run `terraform apply` again — validation completes and the listener
163# is wired to the certificate.
164resource "aws_acm_certificate_validation" "web" {
165 certificate_arn = aws_acm_certificate.web.arn
166 validation_retry_attempts = 30
167}
168
169resource "aws_lb_listener" "https" {
170 load_balancer_arn = aws_lb.web.arn
171 port = 443
172 protocol = "HTTPS"
173 certificate_arn = aws_acm_certificate_validation.web.certificate_arn
174 ssl_policy = "ELBSecurityPolicy-TLS13-1-2-2021-06"
175
176 # /api/* → api (REST)
177 action {
178 type = "forward"
179 target_group_arn = aws_lb_target_group.api.arn
180
181 condition {
182 path_pattern {
183 values = ["/api*"]
184 }
185 }
186 }
187
188 # /ws/* → realtime (WebSocket; ALB forwards the Upgrade/Connection
189 # headers natively for forwarded targets).
190 action {
191 type = "forward"
192 target_group_arn = aws_lb_target_group.realtime.arn
193
194 condition {
195 path_pattern {
196 values = ["/ws*"]
197 }
198 }
199 }
200
201 # default (everything else, incl. "/") → web (Next.js)
202 default_action {
203 type = "forward"
204 target_group_arn = aws_lb_target_group.web.arn
205 }
206}
207
208# --- Target groups -----------------------------------------------------------------
209
210resource "aws_lb_target_group" "web" {
211 name = "${local.names.web_alb}-web"
212 port = 3000
213 protocol = "HTTP"
214 vpc_id = aws_vpc.main.id
215 subnets = aws_subnet.private[*].id
216 target_type = "instance"
217
218 health_check {
219 enabled = true
220 path = "/"
221 port = "traffic-port"
222 protocol = "HTTP"
223 healthy_threshold = 2
224 unhealthy_threshold = 3
225 timeout = 5
226 interval = 15
227 matcher = "200-399"
228 }
229
230 tags = {
231 Name = "${local.names.web_alb}-web"
232 }
233}
234
235resource "aws_lb_target_group" "api" {
236 name = "${local.names.web_alb}-api"
237 port = 4000
238 protocol = "HTTP"
239 vpc_id = aws_vpc.main.id
240 subnets = aws_subnet.private[*].id
241 target_type = "instance"
242
243 health_check {
244 enabled = true
245 path = "/api/v1/health/ready"
246 port = "traffic-port"
247 protocol = "HTTP"
248 healthy_threshold = 2
249 unhealthy_threshold = 3
250 timeout = 5
251 interval = 15
252 matcher = "200"
253 }
254
255 tags = {
256 Name = "${local.names.web_alb}-api"
257 }
258}
259
260resource "aws_lb_target_group" "realtime" {
261 name = "${local.names.web_alb}-realtime"
262 port = 4001
263 protocol = "TCP" # WS is long-lived TCP; L4 health check avoids
264 # HTTP assumptions on the socket port.
265 vpc_id = aws_vpc.main.id
266 subnets = aws_subnet.private[*].id
267 target_type = "instance"
268
269 health_check {
270 enabled = true
271 port = "traffic-port"
272 protocol = "TCP"
273 healthy_threshold = 2
274 unhealthy_threshold = 3
275 timeout = 5
276 interval = 15
277 }
278
279 tags = {
280 Name = "${local.names.web_alb}-realtime"
281 }
282}
283
284# --- Node groups ---------------------------------------------------------------------
285# The cloud-init below is shared by all four app types; the difference is the
286# unit names it enables (FACTORY_APP env) and the launch template instance
287# type. Each group scales independently (see min/max per ASG).
288
289locals {
290 # cloud-init for app nodes. Reads .env values from SSM Parameter Store,
291 # installs Node 20 + pnpm, clones the repo, builds, and enables the app's
292 # systemd unit. FACTORY_APP is one of: web | api | worker | realtime.
293 cloud_init_app = <<EOF
294#!/bin/bash
295set -euxo pipefail
296
297# --- Tooling: git, curl, Node 20, pnpm ---
298apt-get update
299apt-get install -y git curl ca-certificates
300curl -fsSL https:
301apt-get install -y nodejs
302npm install -g pnpm@9.15.0
303systemctl enable --now amazon-ssm-agent
304
305# --- Fetch app env from SSM (SecureString values are KMS-decrypted) ---
306# Parameters live at /factory/<env>/<NAME> — same names as .env.example.
307# Pre-create them with `aws ssm put-parameter` before first boot.
308export FACTORY_APP="__APP__"
309mkdir -p /opt/factory
310cd /opt/factory
311for p in $(aws ssm get-parameters-by-path \
312 --path "/factory/__ENV__" --with-decryption \
313 --query 'Parameters[].{n:Name,v:Value}' --output text); do
314 name=$(echo "$p" | awk '{print $1}')
315 val=$(echo "$p" | awk '{print $2}')
316 echo "$name=$val" >> /opt/factory/.env
317done
318
319# --- Source (in production, replace git clone with your image/artifact
320# distribution — e.g. a golden AMI or S3-deployed build tarball) ---
321git clone --depth 1 https:
322cd /opt/factory/src
323cp /opt/factory/.env .env
324pnpm install --frozen-lockfile
325pnpm build
326
327# --- Systemd unit (generic template rendered per app) ---
328cat > /etc/systemd/system/factory.service <<'UNIT'
329[Unit]
330Description=AI Software Factory __APP__
331After=network.target
332
333[Service]
334User=factory
335WorkingDirectory=/opt/factory/src
336EnvironmentFile=/opt/factory/.env
337ExecStart=/opt/factory/src/apps/__APP__/run.sh
338Restart=always
339RestartSec=5
340LimitNOFILE=65536
341
342[Install]
343WantedBy=multi-user.target
344UNIT
345
346# --- Run as unprivileged user ---
347id factory &>/dev/null || useradd -r -m -d /opt/factory -s /sbin/nologin factory
348chown -R factory:factory /opt/factory
349# run.sh is a thin wrapper (see repo apps/<app>/run.sh); falls back to node.
350cat > /opt/factory/src/apps/__APP__/run.sh <<'RUN'
351#!/bin/bash
352cd "$(dirname "$0")"
353exec node dist/main.js || pnpm start
354RUN
355chmod +x /opt/factory/src/apps/__APP__/run.sh
356
357systemctl daemon-reload
358systemctl enable --now factory.service
359EOF
360}
361
362locals {
363 cloud_init_web = replace(local.cloud_init_app, "__APP__", "web")
364 cloud_init_api = replace(local.cloud_init_app, "__APP__", "api")
365 cloud_init_worker = replace(local.cloud_init_app, "__APP__", "worker")
366 cloud_init_realtime = replace(local.cloud_init_app, "__APP__", "realtime")
367 # (The __ENV__ token is substituted per-stack at launch template creation
368 # via the `__ENV__` → env mapping below; kept as a second pass so the
369 # shared block above stays readable.)
370 cloud_init_web_final = replace(local.cloud_init_web, "__ENV__", local.env)
371 cloud_init_api_final = replace(local.cloud_init_api, "__ENV__", local.env)
372 cloud_init_worker_final = replace(local.cloud_init_worker, "__ENV__", local.env)
373 cloud_init_realtime_final = replace(local.cloud_init_realtime, "__ENV__", local.env)
374}
375
376# --- Web node group (behind ALB, ASG 2..10) ---------------------------------------
377
378resource "aws_launch_template" "web" {
379 name_prefix = "${local.names.project}-${local.env}-web-"
380 image_id = data.aws_ami.ubuntu.id
381 instance_type = var.instance_type_web
382
383 vpc_security_group_ids = [aws_security_group.app.id]
384
385 user_data = base64encode(local.cloud_init_web_final)
386
387 iam_instance_profile {
388 name = aws_iam_instance_profile.app.name
389 }
390
391 block_device_mappings {
392 device_name = "/dev/xvda"
393 ebs {
394 volume_size = 50
395 volume_type = "gp3"
396 encrypted = true
397 }
398 }
399
400 metadata_options {
401 http_tokens = "required" # IMDSv2 only
402 http_endpoint = "enabled"
403 }
404
405 tag_specifications {
406 resource_type = "instance"
407 tags = local.common_tags
408 }
409
410 tag_specifications {
411 resource_type = "volume"
412 tags = local.common_tags
413 }
414}
415
416resource "aws_autoscaling_group" "web" {
417 name = "${local.names.project}-${local.env}-web"
418 desired_capacity = var.node_count
419 min_size = 2
420 max_size = 10
421 vpc_zone_identifier = aws_subnet.private[*].id
422 target_group_arns = [aws_lb_target_group.web.arn]
423
424 launch_template {
425 id = aws_launch_template.web.id
426 version = "$Latest"
427 }
428
429 health_check_type = "ELB"
430 health_check_grace_period = 300
431 terminated_on_shutdown = false
432
433 tags = [
434 {
435 key = "Name"
436 value = "${local.names.project}-${local.env}-web"
437 propagate_at_launch = true
438 },
439 ]
440}
441
442# --- API node group (ASG 2..8; reached via ALB path routing /api) ---------------
443
444resource "aws_launch_template" "api" {
445 name_prefix = "${local.names.project}-${local.env}-api-"
446 image_id = data.aws_ami.ubuntu.id
447 instance_type = var.instance_type_api
448
449 vpc_security_group_ids = [aws_security_group.app.id]
450
451 user_data = base64encode(local.cloud_init_api_final)
452
453 iam_instance_profile {
454 name = aws_iam_instance_profile.app.name
455 }
456
457 block_device_mappings {
458 device_name = "/dev/xvda"
459 ebs {
460 volume_size = 50
461 volume_type = "gp3"
462 encrypted = true
463 }
464 }
465
466 metadata_options {
467 http_tokens = "required"
468 http_endpoint = "enabled"
469 }
470
471 tag_specifications {
472 resource_type = "instance"
473 tags = local.common_tags
474 }
475}
476
477resource "aws_autoscaling_group" "api" {
478 name = "${local.names.project}-${local.env}-api"
479 desired_capacity = var.node_count
480 min_size = 2
481 max_size = 8
482 vpc_zone_identifier = aws_subnet.private[*].id
483 target_group_arns = [aws_lb_target_group.api.arn]
484
485 launch_template {
486 id = aws_launch_template.api.id
487 version = "$Latest"
488 }
489
490 health_check_type = "ELB"
491 health_check_grace_period = 300
492
493 tags = [
494 {
495 key = "Name"
496 value = "${local.names.project}-${local.env}-api"
497 propagate_at_launch = true
498 },
499 ]
500}
501
502# --- Worker node group (ASG 2..8; no LB — consumes Redis queues) -----------------
503
504resource "aws_launch_template" "worker" {
505 name_prefix = "${local.names.project}-${local.env}-worker-"
506 image_id = data.aws_ami.ubuntu.id
507 instance_type = var.instance_type_worker
508
509 vpc_security_group_ids = [aws_security_group.app.id]
510
511 user_data = base64encode(local.cloud_init_worker_final)
512
513 iam_instance_profile {
514 name = aws_iam_instance_profile.app.name
515 }
516
517 block_device_mappings {
518 device_name = "/dev/xvda"
519 ebs {
520 volume_size = 50
521 volume_type = "gp3"
522 encrypted = true
523 }
524 }
525
526 metadata_options {
527 http_tokens = "required"
528 http_endpoint = "enabled"
529 }
530
531 tag_specifications {
532 resource_type = "instance"
533 tags = local.common_tags
534 }
535}
536
537resource "aws_autoscaling_group" "worker" {
538 name = "${local.names.project}-${local.env}-worker"
539 desired_capacity = var.node_count
540 min_size = 2
541 max_size = 8
542 vpc_zone_identifier = aws_subnet.private[*].id
543 # No target group: workers are headless. Health = ELB is impossible, so use
544 # EC2 health checks; a dead node is replaced, a sick-but-alive node is not
545 # (acceptable for the simple path; the k8s path has a heartbeat probe).
546 target_group_arns = []
547
548 launch_template {
549 id = aws_launch_template.worker.id
550 version = "$Latest"
551 }
552
553 health_check_type = "EC2"
554 health_check_grace_period = 300
555
556 tags = [
557 {
558 key = "Name"
559 value = "${local.names.project}-${local.env}-worker"
560 propagate_at_launch = true
561 },
562 ]
563}
564
565# --- Realtime node group (ASG 2..6; WS at :4001 via ALB /ws) ----------------------
566
567resource "aws_launch_template" "realtime" {
568 name_prefix = "${local.names.project}-${local.env}-realtime-"
569 image_id = data.aws_ami.ubuntu.id
570 instance_type = var.instance_type_realtime
571
572 vpc_security_group_ids = [aws_security_group.app.id]
573
574 user_data = base64encode(local.cloud_init_realtime_final)
575
576 iam_instance_profile {
577 name = aws_iam_instance_profile.app.name
578 }
579
580 block_device_mappings {
581 device_name = "/dev/xvda"
582 ebs {
583 volume_size = 50
584 volume_type = "gp3"
585 encrypted = true
586 }
587 }
588
589 metadata_options {
590 http_tokens = "required"
591 http_endpoint = "enabled"
592 }
593
594 tag_specifications {
595 resource_type = "instance"
596 tags = local.common_tags
597 }
598}
599
600resource "aws_autoscaling_group" "realtime" {
601 name = "${local.names.project}-${local.env}-realtime"
602 desired_capacity = var.node_count
603 min_size = 2
604 max_size = 6
605 vpc_zone_identifier = aws_subnet.private[*].id
606 target_group_arns = [aws_lb_target_group.realtime.arn]
607
608 launch_template {
609 id = aws_launch_template.realtime.id
610 version = "$Latest"
611 }
612
613 health_check_type = "ELB"
614 health_check_grace_period = 300
615
616 tags = [
617 {
618 key = "Name"
619 value = "${local.names.project}-${local.env}-realtime"
620 propagate_at_launch = true
621 },
622 ]
623}
624