feat(terraform): AWS Fargate deployment module
Adds a terraform/ directory with an opinionated module that deploys shared-memory to ECS Fargate behind an ALB. The module assumes the operator already provides the VPC, RDS Postgres, ACM cert, ECR images, and OIDC clients, and creates everything else: ECS cluster + services, ALB, Service Connect namespace for app-embedder discovery, EFS-backed model cache for the embedder, Secrets Manager entries, IAM roles, CloudWatch log groups, and a one-shot migrator task definition. Includes examples/basic/ with a worked invocation and a README covering prerequisites, quick start, the post-apply migrator run, image updates, DNS setup, and a security note. Main README gains a short Mode C pointer to the terraform/ guide. Validated with `terraform fmt -check -recursive` and `terraform validate` against AWS provider 5.x. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
@@ -0,0 +1,298 @@
|
||||
# shared-memory — Terraform module (AWS Fargate)
|
||||
|
||||
Deploys [shared-memory](../README.md) to AWS Fargate ECS behind an ALB.
|
||||
Brings up the `app` (Next.js web + MCP endpoint), the `embedder` sidecar
|
||||
(Xenova bge-small on CPU, EFS-backed model cache), and a one-shot
|
||||
`migrator` task definition. Targets an externally-managed RDS Postgres
|
||||
instance and an existing OIDC identity provider — neither is the module's
|
||||
job.
|
||||
|
||||
---
|
||||
|
||||
## What you provide before running
|
||||
|
||||
The module deliberately stops short of creating shared infrastructure
|
||||
that's usually account-wide and not specific to this app. You bring:
|
||||
|
||||
### 1. A VPC with public + private subnets
|
||||
|
||||
At least two of each across two AZs. Public subnets host the internet-facing
|
||||
ALB; private subnets host the Fargate tasks and EFS mount targets. The
|
||||
private subnets need outbound internet access (NAT gateway or VPC endpoints
|
||||
for ECR / Secrets Manager / CloudWatch / Hugging Face) so tasks can pull
|
||||
images, decrypt secrets, and on first cold start download the embedding
|
||||
model.
|
||||
|
||||
### 2. An RDS Postgres instance
|
||||
|
||||
Postgres **≥ 15.5** with `pgvector`, `pg_trgm`, and `pgcrypto`. RDS makes
|
||||
all three available on modern versions; you may need to add them to
|
||||
`rds.allowed_extensions` in the parameter group, but the migrator runs
|
||||
`CREATE EXTENSION IF NOT EXISTS …` itself.
|
||||
|
||||
Connectivity gotcha: the RDS security group is owned by you. After
|
||||
`terraform apply` you must add an inbound rule on it allowing 5432 from
|
||||
the module's task security groups. Use the outputs:
|
||||
|
||||
```
|
||||
app_security_group_id # app needs RDS for runtime queries
|
||||
migrator_security_group_id # migrator needs RDS for DDL on apply
|
||||
```
|
||||
|
||||
The embedder does **not** talk to Postgres.
|
||||
|
||||
### 3. An ACM certificate
|
||||
|
||||
In the **same region** as the ALB (ACM certs are regional). Cover the
|
||||
public hostname you'll use for `domain_name`. DNS validation is the
|
||||
easiest route; AWS docs walk through it.
|
||||
|
||||
### 4. ECR repositories with pushed images
|
||||
|
||||
The module references `var.app_image` and `var.embedder_image` by URI —
|
||||
it doesn't build, doesn't push, doesn't create the repos. Two repos
|
||||
typically:
|
||||
|
||||
```
|
||||
shared-memory-web # built from apps/web/Dockerfile
|
||||
shared-memory-embedder # built from apps/embedder/Dockerfile
|
||||
```
|
||||
|
||||
Build from the repo root and tag with whatever version scheme you prefer
|
||||
(git SHA, semver, etc.). The app and embedder images use unrelated runtime
|
||||
stacks (Node alpine vs Node slim) — keep them as separate repos.
|
||||
|
||||
### 5. OIDC clients
|
||||
|
||||
Two clients in your IdP (Authentik, EntraID, Keycloak, …) — one
|
||||
confidential for the Web UI, one public/PKCE for the MCP endpoint. See the
|
||||
[main README](../README.md#oidc-provider-setup) for the Authentik walkthrough.
|
||||
|
||||
The redirect URI you register on the Web UI client is
|
||||
`https://${domain_name}/api/auth/callback/oidc`, so plan the domain name
|
||||
*before* configuring the IdP.
|
||||
|
||||
---
|
||||
|
||||
## Quick start
|
||||
|
||||
```bash
|
||||
cd terraform/examples/basic
|
||||
|
||||
# 1. Edit main.tf — replace vpc-…, subnet-…, ARN placeholders, image URIs.
|
||||
$EDITOR main.tf
|
||||
|
||||
# 2. Create terraform.tfvars with the sensitive values (0600 perms!).
|
||||
umask 077
|
||||
cat > terraform.tfvars <<EOF
|
||||
database_url = "postgres://memory:CHANGEME@my-rds-host.us-east-1.rds.amazonaws.com:5432/memory"
|
||||
oidc_client_id_web = "abc123…"
|
||||
oidc_client_secret_web = "secretvalue"
|
||||
oidc_client_id_mcp = "def456…"
|
||||
nextauth_secret = "$(openssl rand -base64 32)"
|
||||
cli_token_secret = "$(openssl rand -base64 32)"
|
||||
EOF
|
||||
chmod 600 terraform.tfvars
|
||||
|
||||
# 3. Apply.
|
||||
terraform init
|
||||
terraform plan -out plan.out
|
||||
terraform apply plan.out
|
||||
```
|
||||
|
||||
`terraform apply` creates the ECS cluster, both services, the ALB, EFS,
|
||||
Secrets Manager entries, log groups, security groups, and the migrator
|
||||
task definition. It does **not** run migrations — the migrator is a
|
||||
one-shot task you trigger separately. See the next section.
|
||||
|
||||
After apply, expect the **embedder** to take 60–180 seconds on first
|
||||
boot to download the bge-small model to EFS. Subsequent restarts are
|
||||
warm because EFS keeps the cache.
|
||||
|
||||
---
|
||||
|
||||
## Post-apply: run the migrator and verify
|
||||
|
||||
The migrator creates schema, applies SQL migrations from
|
||||
`apps/web/drizzle/`, and (if any rows already exist) backfills embeddings.
|
||||
It must run **before** the app is useful, but the module ships it as a
|
||||
task definition with no service so you can run it explicitly.
|
||||
|
||||
### Run it
|
||||
|
||||
```bash
|
||||
CLUSTER=$(terraform output -raw ecs_cluster_name)
|
||||
FAMILY=$(terraform output -raw migrator_task_definition_family)
|
||||
SG=$(terraform output -raw migrator_security_group_id)
|
||||
SUBNETS=$(terraform output -json private_subnet_ids | jq -r 'join(",")')
|
||||
|
||||
aws ecs run-task \
|
||||
--cluster "$CLUSTER" \
|
||||
--task-definition "$FAMILY" \
|
||||
--launch-type FARGATE \
|
||||
--network-configuration "awsvpcConfiguration={subnets=[$SUBNETS],securityGroups=[$SG],assignPublicIp=DISABLED}"
|
||||
```
|
||||
|
||||
The task exits 0 on success and a non-zero exit on failure. Watch it:
|
||||
|
||||
```bash
|
||||
aws ecs list-tasks --cluster "$CLUSTER" --family "$FAMILY"
|
||||
aws ecs describe-tasks --cluster "$CLUSTER" --tasks <task-id>
|
||||
```
|
||||
|
||||
### Read its logs
|
||||
|
||||
```bash
|
||||
LOG_GROUP=$(terraform output -raw migrator_log_group_name)
|
||||
|
||||
aws logs tail "$LOG_GROUP" --follow
|
||||
```
|
||||
|
||||
A healthy run prints `Migrations complete.` and (if you have prior data)
|
||||
`Embedding backfill complete: N memories embedded.`.
|
||||
|
||||
You should re-run the migrator after **every** deploy that ships a new
|
||||
SQL migration file. It's idempotent — already-applied migrations are
|
||||
skipped via the `_migrations` ledger table.
|
||||
|
||||
### Verify the app is up
|
||||
|
||||
```bash
|
||||
ALB=$(terraform output -raw alb_dns_name)
|
||||
curl -fsS "https://$ALB/api/health" # — once DNS / cert is wired up
|
||||
```
|
||||
|
||||
(If DNS isn't wired yet, you can `curl --resolve memory.example.com:443:<ALB-IP>`
|
||||
to test against the cert without touching DNS.)
|
||||
|
||||
---
|
||||
|
||||
## Updating images
|
||||
|
||||
Push a new tag to ECR, then re-apply with the new tag:
|
||||
|
||||
```bash
|
||||
terraform apply -var 'app_image=…/shared-memory-web:v0.5.1'
|
||||
```
|
||||
|
||||
ECS performs a rolling deploy: `deployment_minimum_healthy_percent = 50`
|
||||
and `deployment_maximum_percent = 200` mean it stands up new tasks before
|
||||
draining old ones. If the new tasks fail their ALB health check the old
|
||||
ones stay.
|
||||
|
||||
If the new image ships a SQL migration, **run the migrator again first**
|
||||
(or right after; the SQL is backwards-compatible in this codebase), then
|
||||
roll the app.
|
||||
|
||||
The embedder side is rarer to update — the image hardly changes. When it
|
||||
does, EFS keeps the existing model cache so the new revision is warm
|
||||
immediately.
|
||||
|
||||
---
|
||||
|
||||
## DNS setup
|
||||
|
||||
The ALB has a generated DNS name (`…elb.amazonaws.com`); you point your
|
||||
real hostname at it with an A-alias record.
|
||||
|
||||
If your DNS lives in Route53:
|
||||
|
||||
```hcl
|
||||
resource "aws_route53_record" "app" {
|
||||
zone_id = "Z0123456789ABCDEFG" # your hosted zone
|
||||
name = "memory.example.com"
|
||||
type = "A"
|
||||
|
||||
alias {
|
||||
name = module.shared_memory.alb_dns_name
|
||||
zone_id = module.shared_memory.alb_zone_id
|
||||
evaluate_target_health = true
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
If your DNS is elsewhere (Cloudflare, NS1, …), a CNAME from
|
||||
`memory.example.com` → `<alb_dns_name>` works equivalently, modulo apex
|
||||
limitations.
|
||||
|
||||
Once DNS propagates, the OIDC callback URL you registered earlier
|
||||
(`https://memory.example.com/api/auth/callback/oidc`) will start working
|
||||
and you can sign in.
|
||||
|
||||
---
|
||||
|
||||
## Security note
|
||||
|
||||
Several inputs (`database_url`, `nextauth_secret`, `cli_token_secret`,
|
||||
`oidc_client_secret_web`) are sensitive. The module marks them as such so
|
||||
they're scrubbed from CLI output, but they still:
|
||||
|
||||
- Pass through `terraform plan` and `terraform apply`
|
||||
- Land in `terraform.tfstate`
|
||||
- Round-trip through Secrets Manager versions
|
||||
|
||||
Hardening checklist:
|
||||
|
||||
- Put values in `terraform.tfvars` (not committed) with `chmod 600`.
|
||||
- Use a remote state backend with encryption (S3 + KMS) and tight IAM
|
||||
on the bucket. Local state in a shared repo is the failure mode.
|
||||
- Consider an external secret manager (1Password, Doppler, Vault) and
|
||||
feeding values via `-var-file` from a `terraform-data` shim. The
|
||||
module accepts plain strings — keep the indirection outside.
|
||||
- Rotate `nextauth_secret` and `cli_token_secret` periodically. Both can
|
||||
change with no DB migration; in-flight sessions and unexpired CLI
|
||||
tokens will be invalidated.
|
||||
|
||||
The module's Secrets Manager entries are scoped under
|
||||
`${name_prefix}/<ENV_VAR_NAME>` and the task execution role has
|
||||
`secretsmanager:GetSecretValue` on those ARNs only — no wildcard.
|
||||
|
||||
---
|
||||
|
||||
## What the module creates
|
||||
|
||||
| Resource | Purpose |
|
||||
|---|---|
|
||||
| `aws_ecs_cluster` | Fargate cluster, Service Connect default namespace |
|
||||
| `aws_ecs_service.app` | Web/MCP service behind ALB |
|
||||
| `aws_ecs_service.embedder` | Internal sidecar service |
|
||||
| `aws_ecs_task_definition.{app,embedder,migrator}` | Task defs |
|
||||
| `aws_lb` + listener + target group | Public ALB, HTTPS + redirect |
|
||||
| `aws_efs_file_system` + access point + mount targets | Embedder model cache |
|
||||
| `aws_secretsmanager_secret.*` (4) | DATABASE_URL, NEXTAUTH_SECRET, CLI_TOKEN_SECRET, OIDC_CLIENT_SECRET_WEB |
|
||||
| `aws_cloudwatch_log_group.*` (4) | app, embedder, migrator, service-connect |
|
||||
| `aws_security_group.{alb,app,embedder,migrator,efs}` | Tier security groups |
|
||||
| `aws_iam_role.{execution,app_task,embedder_task,migrator_task}` | Execution + per-service task roles |
|
||||
| `aws_service_discovery_http_namespace` | Service Connect namespace `${name_prefix}.internal` |
|
||||
|
||||
## What the module does NOT create
|
||||
|
||||
- VPC, subnets, NAT, route tables — you own these
|
||||
- RDS instance, parameter group, subnet group — you own
|
||||
- ACM certificate or its DNS validation records — you own
|
||||
- ECR repositories or the image build pipeline — you own
|
||||
- OIDC clients — you own
|
||||
- Route53 records — you own (see [DNS setup](#dns-setup))
|
||||
- WAF, Shield, CloudFront — out of scope
|
||||
|
||||
## Module inputs
|
||||
|
||||
See [`variables.tf`](variables.tf) for the full list with descriptions
|
||||
and defaults.
|
||||
|
||||
## Module outputs
|
||||
|
||||
See [`outputs.tf`](outputs.tf). The ones you'll use:
|
||||
|
||||
- `alb_dns_name`, `alb_zone_id` — for the Route53 alias
|
||||
- `ecs_cluster_name`, `migrator_task_definition_family`,
|
||||
`private_subnet_ids_for_run_task`, `migrator_security_group_id` —
|
||||
to assemble the `aws ecs run-task` call
|
||||
- `app_security_group_id` / `migrator_security_group_id` — to whitelist
|
||||
on your RDS SG
|
||||
- `app_log_group_name`, `embedder_log_group_name`, `migrator_log_group_name` —
|
||||
for `aws logs tail`
|
||||
|
||||
## Worked example
|
||||
|
||||
See [`examples/basic/`](examples/basic/).
|
||||
@@ -0,0 +1,85 @@
|
||||
# -----------------------------------------------------------------------------
|
||||
# Application Load Balancer.
|
||||
#
|
||||
# * Internet-facing, in the public subnets
|
||||
# * HTTP listener on :80 returns a 301 to https://${domain}${path}
|
||||
# * HTTPS listener on :443 terminates TLS with the user's ACM cert and
|
||||
# forwards to the app target group on 3000
|
||||
#
|
||||
# Target type is `ip` because Fargate tasks register their ENI IPs directly,
|
||||
# not via an EC2 instance.
|
||||
# -----------------------------------------------------------------------------
|
||||
|
||||
resource "aws_lb" "this" {
|
||||
name = "${var.name_prefix}-alb"
|
||||
load_balancer_type = "application"
|
||||
internal = false
|
||||
subnets = var.public_subnet_ids
|
||||
security_groups = [aws_security_group.alb.id]
|
||||
|
||||
# Keep HTTP/2 on (default) so MCP streaming works smoothly. drop_invalid
|
||||
# headers protects against header smuggling against the upstream.
|
||||
drop_invalid_header_fields = true
|
||||
|
||||
tags = merge(local.tags, { Name = "${var.name_prefix}-alb" })
|
||||
}
|
||||
|
||||
resource "aws_lb_target_group" "app" {
|
||||
name = "${var.name_prefix}-app"
|
||||
port = local.app_port
|
||||
protocol = "HTTP"
|
||||
target_type = "ip"
|
||||
vpc_id = var.vpc_id
|
||||
deregistration_delay = 30
|
||||
|
||||
health_check {
|
||||
enabled = true
|
||||
path = "/api/health"
|
||||
port = "traffic-port"
|
||||
protocol = "HTTP"
|
||||
matcher = "200"
|
||||
interval = 15
|
||||
timeout = 5
|
||||
healthy_threshold = 2
|
||||
unhealthy_threshold = 3
|
||||
}
|
||||
|
||||
tags = local.tags
|
||||
}
|
||||
|
||||
# Port 80 → 301 redirect to HTTPS.
|
||||
resource "aws_lb_listener" "http" {
|
||||
load_balancer_arn = aws_lb.this.arn
|
||||
port = 80
|
||||
protocol = "HTTP"
|
||||
|
||||
default_action {
|
||||
type = "redirect"
|
||||
|
||||
redirect {
|
||||
protocol = "HTTPS"
|
||||
port = "443"
|
||||
status_code = "HTTP_301"
|
||||
}
|
||||
}
|
||||
|
||||
tags = local.tags
|
||||
}
|
||||
|
||||
# Port 443 → app target group. TLS terminates at the ALB; the app speaks
|
||||
# plain HTTP behind it. PUBLIC_URL teaches Auth.js and the MCP route that
|
||||
# the public origin is HTTPS regardless.
|
||||
resource "aws_lb_listener" "https" {
|
||||
load_balancer_arn = aws_lb.this.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.arn
|
||||
}
|
||||
|
||||
tags = local.tags
|
||||
}
|
||||
@@ -0,0 +1,363 @@
|
||||
# -----------------------------------------------------------------------------
|
||||
# ECS cluster + services + task definitions.
|
||||
#
|
||||
# Service Connect (introduced in 2022) handles app→embedder discovery: both
|
||||
# services join the same namespace, the embedder advertises itself as
|
||||
# `embedder` on port 8080, and the app talks to `http://embedder:8080` like
|
||||
# it does in docker-compose. No Route53 records, no Cloud Map manual
|
||||
# wiring, no sidecar plumbing in the app image.
|
||||
#
|
||||
# The migrator runs as a task definition with no service — operators invoke
|
||||
# it via `aws ecs run-task` after a fresh deploy (see README).
|
||||
# -----------------------------------------------------------------------------
|
||||
|
||||
# ---- Cluster + Service Connect namespace ----
|
||||
|
||||
resource "aws_service_discovery_http_namespace" "this" {
|
||||
name = local.service_connect_namespace
|
||||
description = "Service Connect namespace for ${var.name_prefix}"
|
||||
tags = local.tags
|
||||
}
|
||||
|
||||
resource "aws_ecs_cluster" "this" {
|
||||
name = var.name_prefix
|
||||
|
||||
service_connect_defaults {
|
||||
namespace = aws_service_discovery_http_namespace.this.arn
|
||||
}
|
||||
|
||||
setting {
|
||||
name = "containerInsights"
|
||||
value = "enabled"
|
||||
}
|
||||
|
||||
tags = local.tags
|
||||
}
|
||||
|
||||
resource "aws_ecs_cluster_capacity_providers" "this" {
|
||||
cluster_name = aws_ecs_cluster.this.name
|
||||
capacity_providers = ["FARGATE", "FARGATE_SPOT"]
|
||||
|
||||
default_capacity_provider_strategy {
|
||||
capacity_provider = "FARGATE"
|
||||
weight = 1
|
||||
base = 1
|
||||
}
|
||||
}
|
||||
|
||||
# ---- Shared env block (non-secret) for app + migrator ----
|
||||
|
||||
locals {
|
||||
app_environment = [
|
||||
{ name = "NODE_ENV", value = "production" },
|
||||
{ name = "LOG_LEVEL", value = var.log_level },
|
||||
{ name = "PUBLIC_URL", value = local.public_url },
|
||||
{ name = "AUTH_URL", value = local.public_url },
|
||||
{ name = "AUTH_TRUST_HOST", value = "true" },
|
||||
{ name = "OIDC_ISSUER", value = var.oidc_issuer },
|
||||
{ name = "OIDC_CLIENT_ID_WEB", value = var.oidc_client_id_web },
|
||||
{ name = "OIDC_CLIENT_ID_MCP", value = var.oidc_client_id_mcp },
|
||||
{ name = "OIDC_AUDIENCE", value = var.oidc_audience },
|
||||
{ name = "EMBEDDER_URL", value = "http://embedder:${local.embedder_port}" },
|
||||
{ name = "EMBEDDING_MODEL", value = var.embedding_model },
|
||||
{ name = "EMBEDDING_DIM", value = tostring(var.embedding_dim) },
|
||||
]
|
||||
|
||||
# `secrets` block format that ECS expects: name = env-var name, valueFrom
|
||||
# = secret ARN. ECS resolves these to env vars at task start.
|
||||
app_secrets = [
|
||||
{ name = "DATABASE_URL", valueFrom = aws_secretsmanager_secret.database_url.arn },
|
||||
{ name = "NEXTAUTH_SECRET", valueFrom = aws_secretsmanager_secret.nextauth_secret.arn },
|
||||
{ name = "CLI_TOKEN_SECRET", valueFrom = aws_secretsmanager_secret.cli_token_secret.arn },
|
||||
{ name = "OIDC_CLIENT_SECRET_WEB", valueFrom = aws_secretsmanager_secret.oidc_client_secret_web.arn },
|
||||
]
|
||||
|
||||
embedder_environment = [
|
||||
{ name = "LOG_LEVEL", value = var.log_level },
|
||||
{ name = "EMBEDDING_MODEL", value = var.embedding_model },
|
||||
{ name = "EMBEDDING_DIM", value = tostring(var.embedding_dim) },
|
||||
{ name = "MODEL_CACHE_DIR", value = "/data/models" },
|
||||
]
|
||||
|
||||
# Migrator needs only the DB + embedder URL. EMBEDDER_URL is what triggers
|
||||
# the post-migration backfill loop in scripts/migrate.ts.
|
||||
migrator_environment = [
|
||||
{ name = "NODE_ENV", value = "production" },
|
||||
{ name = "LOG_LEVEL", value = var.log_level },
|
||||
{ name = "EMBEDDER_URL", value = "http://embedder:${local.embedder_port}" },
|
||||
]
|
||||
|
||||
migrator_secrets = [
|
||||
{ name = "DATABASE_URL", valueFrom = aws_secretsmanager_secret.database_url.arn },
|
||||
]
|
||||
}
|
||||
|
||||
# ---- App task definition ----
|
||||
|
||||
resource "aws_ecs_task_definition" "app" {
|
||||
family = "${var.name_prefix}-app"
|
||||
network_mode = "awsvpc"
|
||||
requires_compatibilities = ["FARGATE"]
|
||||
cpu = var.app_cpu
|
||||
memory = var.app_memory
|
||||
execution_role_arn = aws_iam_role.execution.arn
|
||||
task_role_arn = aws_iam_role.app_task.arn
|
||||
|
||||
container_definitions = jsonencode([
|
||||
{
|
||||
name = "app"
|
||||
image = var.app_image
|
||||
essential = true
|
||||
|
||||
portMappings = [
|
||||
{
|
||||
name = "app"
|
||||
containerPort = local.app_port
|
||||
hostPort = local.app_port
|
||||
protocol = "tcp"
|
||||
appProtocol = "http"
|
||||
},
|
||||
]
|
||||
|
||||
environment = local.app_environment
|
||||
secrets = local.app_secrets
|
||||
|
||||
# Mirrors the Dockerfile healthcheck — keeps individual tasks honest
|
||||
# even before ALB health checks notice a problem.
|
||||
healthCheck = {
|
||||
command = ["CMD-SHELL", "wget -q -O /dev/null http://localhost:${local.app_port}/api/health || exit 1"]
|
||||
interval = 15
|
||||
timeout = 5
|
||||
retries = 5
|
||||
startPeriod = 30
|
||||
}
|
||||
|
||||
logConfiguration = {
|
||||
logDriver = "awslogs"
|
||||
options = {
|
||||
awslogs-group = aws_cloudwatch_log_group.app.name
|
||||
awslogs-region = data.aws_region.current.name
|
||||
awslogs-stream-prefix = "app"
|
||||
}
|
||||
}
|
||||
},
|
||||
])
|
||||
|
||||
tags = local.tags
|
||||
}
|
||||
|
||||
# ---- Embedder task definition ----
|
||||
|
||||
resource "aws_ecs_task_definition" "embedder" {
|
||||
family = "${var.name_prefix}-embedder"
|
||||
network_mode = "awsvpc"
|
||||
requires_compatibilities = ["FARGATE"]
|
||||
cpu = var.embedder_cpu
|
||||
memory = var.embedder_memory
|
||||
execution_role_arn = aws_iam_role.execution.arn
|
||||
task_role_arn = aws_iam_role.embedder_task.arn
|
||||
|
||||
# EFS-backed volume for the model cache.
|
||||
volume {
|
||||
name = "models"
|
||||
|
||||
efs_volume_configuration {
|
||||
file_system_id = aws_efs_file_system.embedder_models.id
|
||||
transit_encryption = "ENABLED"
|
||||
|
||||
authorization_config {
|
||||
access_point_id = aws_efs_access_point.embedder_models.id
|
||||
iam = "DISABLED"
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
container_definitions = jsonencode([
|
||||
{
|
||||
name = "embedder"
|
||||
image = var.embedder_image
|
||||
essential = true
|
||||
|
||||
portMappings = [
|
||||
{
|
||||
name = "embedder"
|
||||
containerPort = local.embedder_port
|
||||
hostPort = local.embedder_port
|
||||
protocol = "tcp"
|
||||
appProtocol = "http"
|
||||
},
|
||||
]
|
||||
|
||||
environment = local.embedder_environment
|
||||
|
||||
mountPoints = [
|
||||
{
|
||||
sourceVolume = "models"
|
||||
containerPath = "/data/models"
|
||||
readOnly = false
|
||||
},
|
||||
]
|
||||
|
||||
# 180s start period mirrors the Dockerfile — first boot has to load
|
||||
# (and on a cold EFS, download) the model.
|
||||
healthCheck = {
|
||||
command = ["CMD-SHELL", "wget -q -O - http://127.0.0.1:${local.embedder_port}/health | grep -q '\"ready\":true' || exit 1"]
|
||||
interval = 15
|
||||
timeout = 5
|
||||
retries = 8
|
||||
startPeriod = 180
|
||||
}
|
||||
|
||||
logConfiguration = {
|
||||
logDriver = "awslogs"
|
||||
options = {
|
||||
awslogs-group = aws_cloudwatch_log_group.embedder.name
|
||||
awslogs-region = data.aws_region.current.name
|
||||
awslogs-stream-prefix = "embedder"
|
||||
}
|
||||
}
|
||||
},
|
||||
])
|
||||
|
||||
tags = local.tags
|
||||
}
|
||||
|
||||
# ---- Migrator task definition (no service — one-shot via `aws ecs run-task`) ----
|
||||
|
||||
resource "aws_ecs_task_definition" "migrator" {
|
||||
family = "${var.name_prefix}-migrator"
|
||||
network_mode = "awsvpc"
|
||||
requires_compatibilities = ["FARGATE"]
|
||||
cpu = var.migrator_cpu
|
||||
memory = var.migrator_memory
|
||||
execution_role_arn = aws_iam_role.execution.arn
|
||||
task_role_arn = aws_iam_role.migrator_task.arn
|
||||
|
||||
container_definitions = jsonencode([
|
||||
{
|
||||
name = "migrator"
|
||||
image = var.app_image # same web image — runs migrate.mjs instead of server.js
|
||||
essential = true
|
||||
|
||||
# Override the image's CMD to run the bundled migrator. Mirrors the
|
||||
# docker-compose migrator service.
|
||||
command = ["node", "apps/web/migrate.mjs"]
|
||||
|
||||
environment = local.migrator_environment
|
||||
secrets = local.migrator_secrets
|
||||
|
||||
logConfiguration = {
|
||||
logDriver = "awslogs"
|
||||
options = {
|
||||
awslogs-group = aws_cloudwatch_log_group.migrator.name
|
||||
awslogs-region = data.aws_region.current.name
|
||||
awslogs-stream-prefix = "migrator"
|
||||
}
|
||||
}
|
||||
},
|
||||
])
|
||||
|
||||
tags = local.tags
|
||||
}
|
||||
|
||||
# ---- Services ----
|
||||
|
||||
# Embedder is created first because the app's Service Connect client config
|
||||
# references the namespace, not the embedder service ARN — but starting the
|
||||
# embedder first lets the app pass its DNS health probes immediately on first
|
||||
# deploy.
|
||||
resource "aws_ecs_service" "embedder" {
|
||||
name = "${var.name_prefix}-embedder"
|
||||
cluster = aws_ecs_cluster.this.id
|
||||
task_definition = aws_ecs_task_definition.embedder.arn
|
||||
desired_count = var.embedder_desired_count
|
||||
launch_type = "FARGATE"
|
||||
enable_execute_command = true
|
||||
|
||||
network_configuration {
|
||||
subnets = var.private_subnet_ids
|
||||
security_groups = [aws_security_group.embedder.id]
|
||||
assign_public_ip = false
|
||||
}
|
||||
|
||||
service_connect_configuration {
|
||||
enabled = true
|
||||
namespace = aws_service_discovery_http_namespace.this.arn
|
||||
|
||||
# The app reaches this via `embedder:8080`. portName matches the
|
||||
# portMappings entry in the task def; discoveryName is the DNS label.
|
||||
service {
|
||||
port_name = "embedder"
|
||||
discovery_name = "embedder"
|
||||
|
||||
client_alias {
|
||||
port = local.embedder_port
|
||||
dns_name = "embedder"
|
||||
}
|
||||
}
|
||||
|
||||
log_configuration {
|
||||
log_driver = "awslogs"
|
||||
options = {
|
||||
awslogs-group = aws_cloudwatch_log_group.service_connect.name
|
||||
awslogs-region = data.aws_region.current.name
|
||||
awslogs-stream-prefix = "embedder-sc"
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
# Cold-start tolerance: the model load can take ~180s, so don't let ECS
|
||||
# mark the task unhealthy from its perspective during that window.
|
||||
health_check_grace_period_seconds = 240
|
||||
|
||||
deployment_minimum_healthy_percent = 50
|
||||
deployment_maximum_percent = 200
|
||||
|
||||
tags = local.tags
|
||||
}
|
||||
|
||||
resource "aws_ecs_service" "app" {
|
||||
name = "${var.name_prefix}-app"
|
||||
cluster = aws_ecs_cluster.this.id
|
||||
task_definition = aws_ecs_task_definition.app.arn
|
||||
desired_count = var.app_desired_count
|
||||
launch_type = "FARGATE"
|
||||
enable_execute_command = true
|
||||
|
||||
network_configuration {
|
||||
subnets = var.private_subnet_ids
|
||||
security_groups = [aws_security_group.app.id]
|
||||
assign_public_ip = false
|
||||
}
|
||||
|
||||
load_balancer {
|
||||
target_group_arn = aws_lb_target_group.app.arn
|
||||
container_name = "app"
|
||||
container_port = local.app_port
|
||||
}
|
||||
|
||||
service_connect_configuration {
|
||||
enabled = true
|
||||
namespace = aws_service_discovery_http_namespace.this.arn
|
||||
|
||||
log_configuration {
|
||||
log_driver = "awslogs"
|
||||
options = {
|
||||
awslogs-group = aws_cloudwatch_log_group.service_connect.name
|
||||
awslogs-region = data.aws_region.current.name
|
||||
awslogs-stream-prefix = "app-sc"
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
health_check_grace_period_seconds = 60
|
||||
|
||||
deployment_minimum_healthy_percent = 50
|
||||
deployment_maximum_percent = 200
|
||||
|
||||
# The HTTPS listener must exist before the service tries to attach to the
|
||||
# target group — otherwise the first apply races.
|
||||
depends_on = [aws_lb_listener.https]
|
||||
|
||||
tags = local.tags
|
||||
}
|
||||
@@ -0,0 +1,57 @@
|
||||
# -----------------------------------------------------------------------------
|
||||
# EFS for the embedder model cache.
|
||||
#
|
||||
# Without persistent storage, every cold-start embedder task re-downloads
|
||||
# the ~30 MB bge-small model from Hugging Face — slow and rate-limit-risky.
|
||||
# EFS lets us share a warm cache across replicas and across restarts.
|
||||
#
|
||||
# The access point pins ownership to UID/GID 1001, matching the
|
||||
# `node-embedder` user baked into apps/embedder/Dockerfile, so files written
|
||||
# through the access point are owned correctly.
|
||||
# -----------------------------------------------------------------------------
|
||||
|
||||
resource "aws_efs_file_system" "embedder_models" {
|
||||
creation_token = "${var.name_prefix}-embedder-models"
|
||||
encrypted = true
|
||||
|
||||
# General Purpose performance mode + bursting throughput is plenty for a
|
||||
# ~30 MB read-mostly cache. Don't pay for provisioned throughput.
|
||||
performance_mode = "generalPurpose"
|
||||
throughput_mode = "bursting"
|
||||
|
||||
tags = merge(local.tags, { Name = "${var.name_prefix}-embedder-models" })
|
||||
}
|
||||
|
||||
# One mount target per private subnet so any AZ the embedder lands in can
|
||||
# reach the file system.
|
||||
resource "aws_efs_mount_target" "embedder_models" {
|
||||
for_each = toset(var.private_subnet_ids)
|
||||
|
||||
file_system_id = aws_efs_file_system.embedder_models.id
|
||||
subnet_id = each.value
|
||||
security_groups = [aws_security_group.efs.id]
|
||||
}
|
||||
|
||||
# Access point gives the embedder task a chrooted view of the file system,
|
||||
# with files always owned by uid/gid 1001 regardless of which task wrote
|
||||
# them. Matches the `node-embedder` user in the Dockerfile.
|
||||
resource "aws_efs_access_point" "embedder_models" {
|
||||
file_system_id = aws_efs_file_system.embedder_models.id
|
||||
|
||||
posix_user {
|
||||
uid = 1001
|
||||
gid = 1001
|
||||
}
|
||||
|
||||
root_directory {
|
||||
path = "/models"
|
||||
|
||||
creation_info {
|
||||
owner_uid = 1001
|
||||
owner_gid = 1001
|
||||
permissions = "0755"
|
||||
}
|
||||
}
|
||||
|
||||
tags = merge(local.tags, { Name = "${var.name_prefix}-embedder-models" })
|
||||
}
|
||||
@@ -0,0 +1,63 @@
|
||||
# Basic example — shared-memory on AWS Fargate
|
||||
|
||||
Minimal invocation of `../../`. Fill in your real IDs and run.
|
||||
|
||||
## Prereqs
|
||||
|
||||
Before you `terraform apply`, you need (see the [module README](../../README.md)
|
||||
for the long version):
|
||||
|
||||
- A VPC with two public + two private subnets
|
||||
- An RDS Postgres ≥ 15.5 instance with `pgvector`, `pg_trgm`, `pgcrypto`
|
||||
available (or creatable by the migrator on first run)
|
||||
- An ACM certificate in the same region as the ALB, covering `domain_name`
|
||||
- ECR repos populated with images for `apps/web` and `apps/embedder`
|
||||
- OIDC clients registered (web confidential + MCP public/PKCE)
|
||||
|
||||
## Configure
|
||||
|
||||
1. Open `main.tf` and replace the placeholder `vpc-…` / `subnet-…` /
|
||||
`arn:aws:acm:…` / image URIs with your real values.
|
||||
|
||||
2. Create `terraform.tfvars` with the sensitive inputs and chmod it:
|
||||
|
||||
```bash
|
||||
umask 077
|
||||
cat > terraform.tfvars <<EOF
|
||||
database_url = "postgres://memory:CHANGEME@my-rds-host.us-east-1.rds.amazonaws.com:5432/memory"
|
||||
oidc_client_id_web = "abc123…"
|
||||
oidc_client_secret_web = "secretvalue"
|
||||
oidc_client_id_mcp = "def456…"
|
||||
nextauth_secret = "$(openssl rand -base64 32)"
|
||||
cli_token_secret = "$(openssl rand -base64 32)"
|
||||
EOF
|
||||
chmod 600 terraform.tfvars
|
||||
```
|
||||
|
||||
## Apply
|
||||
|
||||
```bash
|
||||
terraform init
|
||||
terraform plan -out plan.out
|
||||
terraform apply plan.out
|
||||
```
|
||||
|
||||
## Post-apply
|
||||
|
||||
Open the [module README](../../README.md#post-apply) for the migrator
|
||||
`aws ecs run-task` invocation and the DNS setup.
|
||||
|
||||
The shortcut, using outputs from this directory:
|
||||
|
||||
```bash
|
||||
CLUSTER=$(terraform output -raw ecs_cluster_name)
|
||||
FAMILY=$(terraform output -raw migrator_task_definition_family)
|
||||
SG=$(terraform output -raw migrator_security_group_id)
|
||||
SUBNETS=$(terraform output -json private_subnet_ids | jq -r 'join(",")')
|
||||
|
||||
aws ecs run-task \
|
||||
--cluster "$CLUSTER" \
|
||||
--task-definition "$FAMILY" \
|
||||
--launch-type FARGATE \
|
||||
--network-configuration "awsvpcConfiguration={subnets=[$SUBNETS],securityGroups=[$SG],assignPublicIp=DISABLED}"
|
||||
```
|
||||
@@ -0,0 +1,100 @@
|
||||
# -----------------------------------------------------------------------------
|
||||
# Worked example for the shared-memory Terraform module.
|
||||
#
|
||||
# This config does NOT create the VPC, RDS, ACM cert, ECR repos, or OIDC
|
||||
# clients — see ../../README.md for the prerequisite checklist. Replace the
|
||||
# placeholders below with the actual IDs from your environment.
|
||||
# -----------------------------------------------------------------------------
|
||||
|
||||
terraform {
|
||||
required_version = "~> 1.5"
|
||||
|
||||
required_providers {
|
||||
aws = {
|
||||
source = "hashicorp/aws"
|
||||
version = "~> 5.0"
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
provider "aws" {
|
||||
# Region inherits from AWS_REGION / AWS_PROFILE / shared-config. Set it
|
||||
# here only if you want to pin it explicitly.
|
||||
# region = "us-east-1"
|
||||
}
|
||||
|
||||
module "shared_memory" {
|
||||
source = "../../"
|
||||
|
||||
# ---- Identity / wiring ----
|
||||
name_prefix = "shared-memory-prod"
|
||||
vpc_id = "vpc-0123456789abcdef0"
|
||||
public_subnet_ids = ["subnet-aaa", "subnet-bbb"]
|
||||
private_subnet_ids = ["subnet-ccc", "subnet-ddd"]
|
||||
|
||||
# ---- TLS / DNS ----
|
||||
acm_certificate_arn = "arn:aws:acm:us-east-1:123456789012:certificate/<uuid>"
|
||||
domain_name = "memory.example.com"
|
||||
|
||||
# ---- Images (push your own, then reference here) ----
|
||||
app_image = "123456789012.dkr.ecr.us-east-1.amazonaws.com/shared-memory-web:v0.5.0"
|
||||
embedder_image = "123456789012.dkr.ecr.us-east-1.amazonaws.com/shared-memory-embedder:v0.5.0"
|
||||
|
||||
# ---- Database (external RDS) ----
|
||||
# Format: postgres://USER:PASSWORD@HOST:5432/DBNAME
|
||||
# Real-world: pull from `aws_secretsmanager_secret_version` or `random_password`,
|
||||
# don't hardcode.
|
||||
database_url = var.database_url
|
||||
|
||||
# ---- OIDC ----
|
||||
oidc_issuer = "https://auth.example.com/application/o/shared-memory/"
|
||||
oidc_client_id_web = var.oidc_client_id_web
|
||||
oidc_client_secret_web = var.oidc_client_secret_web
|
||||
oidc_client_id_mcp = var.oidc_client_id_mcp
|
||||
oidc_audience = "shared-memory"
|
||||
|
||||
# ---- App-level secrets ----
|
||||
# Generate with: openssl rand -base64 32
|
||||
nextauth_secret = var.nextauth_secret
|
||||
cli_token_secret = var.cli_token_secret
|
||||
|
||||
# ---- Sizing (defaults are fine for small deployments) ----
|
||||
app_desired_count = 1
|
||||
embedder_desired_count = 1
|
||||
|
||||
tags = {
|
||||
environment = "prod"
|
||||
project = "shared-memory"
|
||||
}
|
||||
}
|
||||
|
||||
# ---- Sensitive inputs surfaced as vars so they live in terraform.tfvars
|
||||
# with 0600 perms (not in this file). See ../../README.md "Security note".
|
||||
|
||||
variable "database_url" {
|
||||
type = string
|
||||
sensitive = true
|
||||
}
|
||||
|
||||
variable "oidc_client_id_web" {
|
||||
type = string
|
||||
}
|
||||
|
||||
variable "oidc_client_secret_web" {
|
||||
type = string
|
||||
sensitive = true
|
||||
}
|
||||
|
||||
variable "oidc_client_id_mcp" {
|
||||
type = string
|
||||
}
|
||||
|
||||
variable "nextauth_secret" {
|
||||
type = string
|
||||
sensitive = true
|
||||
}
|
||||
|
||||
variable "cli_token_secret" {
|
||||
type = string
|
||||
sensitive = true
|
||||
}
|
||||
@@ -0,0 +1,54 @@
|
||||
# Surface the module outputs so `terraform output` from this directory
|
||||
# gives the operator everything they need without diving into the module.
|
||||
|
||||
output "alb_dns_name" {
|
||||
description = "Point your Route53 record (alias) at this."
|
||||
value = module.shared_memory.alb_dns_name
|
||||
}
|
||||
|
||||
output "alb_zone_id" {
|
||||
description = "Used as alias.zone_id on aws_route53_record."
|
||||
value = module.shared_memory.alb_zone_id
|
||||
}
|
||||
|
||||
output "ecs_cluster_name" {
|
||||
description = "Pass to `aws ecs run-task --cluster`."
|
||||
value = module.shared_memory.ecs_cluster_name
|
||||
}
|
||||
|
||||
output "migrator_task_definition_family" {
|
||||
description = "Pass to `aws ecs run-task --task-definition`."
|
||||
value = module.shared_memory.migrator_task_definition_family
|
||||
}
|
||||
|
||||
output "migrator_security_group_id" {
|
||||
description = "Whitelist on RDS SG (inbound 5432)."
|
||||
value = module.shared_memory.migrator_security_group_id
|
||||
}
|
||||
|
||||
output "app_security_group_id" {
|
||||
description = "Whitelist on RDS SG (inbound 5432)."
|
||||
value = module.shared_memory.app_security_group_id
|
||||
}
|
||||
|
||||
output "private_subnet_ids" {
|
||||
description = "Echoed from input — handy for `aws ecs run-task --network-configuration`."
|
||||
value = module.shared_memory.private_subnet_ids_for_run_task
|
||||
}
|
||||
|
||||
output "app_log_group_name" {
|
||||
value = module.shared_memory.app_log_group_name
|
||||
}
|
||||
|
||||
output "embedder_log_group_name" {
|
||||
value = module.shared_memory.embedder_log_group_name
|
||||
}
|
||||
|
||||
output "migrator_log_group_name" {
|
||||
value = module.shared_memory.migrator_log_group_name
|
||||
}
|
||||
|
||||
output "secret_arns" {
|
||||
description = "Visibility into where the module stored its secrets."
|
||||
value = module.shared_memory.secret_arns
|
||||
}
|
||||
@@ -0,0 +1,69 @@
|
||||
# -----------------------------------------------------------------------------
|
||||
# IAM. Two role kinds:
|
||||
#
|
||||
# * Task execution role — used by the ECS agent itself to pull images,
|
||||
# fetch secrets, and write logs. Shared across all three task defs.
|
||||
# * Task role — assumed by the running container. We give every service
|
||||
# its own (even if empty today) so future per-service permissions (S3,
|
||||
# SES, etc.) can be granted without widening blast radius.
|
||||
# -----------------------------------------------------------------------------
|
||||
|
||||
# ---- Task execution role ----
|
||||
|
||||
data "aws_iam_policy_document" "ecs_tasks_assume" {
|
||||
statement {
|
||||
actions = ["sts:AssumeRole"]
|
||||
principals {
|
||||
type = "Service"
|
||||
identifiers = ["ecs-tasks.amazonaws.com"]
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
resource "aws_iam_role" "execution" {
|
||||
name = "${var.name_prefix}-ecs-execution"
|
||||
assume_role_policy = data.aws_iam_policy_document.ecs_tasks_assume.json
|
||||
tags = local.tags
|
||||
}
|
||||
|
||||
# AWS-managed policy: pull from ECR, write to CloudWatch.
|
||||
resource "aws_iam_role_policy_attachment" "execution_default" {
|
||||
role = aws_iam_role.execution.name
|
||||
policy_arn = "arn:aws:iam::aws:policy/service-role/AmazonECSTaskExecutionRolePolicy"
|
||||
}
|
||||
|
||||
# Allow the execution role to decrypt the specific secrets this module owns.
|
||||
# Scoped to the module's secret ARNs only — no wildcard against the account.
|
||||
data "aws_iam_policy_document" "execution_secrets" {
|
||||
statement {
|
||||
sid = "ReadModuleSecrets"
|
||||
actions = ["secretsmanager:GetSecretValue"]
|
||||
resources = values(local.secret_arns)
|
||||
}
|
||||
}
|
||||
|
||||
resource "aws_iam_role_policy" "execution_secrets" {
|
||||
name = "${var.name_prefix}-execution-secrets"
|
||||
role = aws_iam_role.execution.id
|
||||
policy = data.aws_iam_policy_document.execution_secrets.json
|
||||
}
|
||||
|
||||
# ---- Task roles (one per service; empty by default but ready to be widened) ----
|
||||
|
||||
resource "aws_iam_role" "app_task" {
|
||||
name = "${var.name_prefix}-app-task"
|
||||
assume_role_policy = data.aws_iam_policy_document.ecs_tasks_assume.json
|
||||
tags = local.tags
|
||||
}
|
||||
|
||||
resource "aws_iam_role" "embedder_task" {
|
||||
name = "${var.name_prefix}-embedder-task"
|
||||
assume_role_policy = data.aws_iam_policy_document.ecs_tasks_assume.json
|
||||
tags = local.tags
|
||||
}
|
||||
|
||||
resource "aws_iam_role" "migrator_task" {
|
||||
name = "${var.name_prefix}-migrator-task"
|
||||
assume_role_policy = data.aws_iam_policy_document.ecs_tasks_assume.json
|
||||
tags = local.tags
|
||||
}
|
||||
@@ -0,0 +1,30 @@
|
||||
# -----------------------------------------------------------------------------
|
||||
# CloudWatch log groups — one per service. The ECS task definitions reference
|
||||
# these via `awslogs-group`. Retention is configurable via var.log_retention_days.
|
||||
# -----------------------------------------------------------------------------
|
||||
|
||||
resource "aws_cloudwatch_log_group" "app" {
|
||||
name = "/ecs/${var.name_prefix}/app"
|
||||
retention_in_days = var.log_retention_days
|
||||
tags = local.tags
|
||||
}
|
||||
|
||||
resource "aws_cloudwatch_log_group" "embedder" {
|
||||
name = "/ecs/${var.name_prefix}/embedder"
|
||||
retention_in_days = var.log_retention_days
|
||||
tags = local.tags
|
||||
}
|
||||
|
||||
resource "aws_cloudwatch_log_group" "migrator" {
|
||||
name = "/ecs/${var.name_prefix}/migrator"
|
||||
retention_in_days = var.log_retention_days
|
||||
tags = local.tags
|
||||
}
|
||||
|
||||
# Service Connect proxy (Envoy) logs go here. ECS writes these automatically
|
||||
# when the service has service_connect_configuration with log_configuration.
|
||||
resource "aws_cloudwatch_log_group" "service_connect" {
|
||||
name = "/ecs/${var.name_prefix}/service-connect"
|
||||
retention_in_days = var.log_retention_days
|
||||
tags = local.tags
|
||||
}
|
||||
@@ -0,0 +1,36 @@
|
||||
# -----------------------------------------------------------------------------
|
||||
# shared-memory — AWS Fargate deployment module
|
||||
#
|
||||
# Deploys the three runtime components (app, embedder, migrator) as ECS
|
||||
# tasks behind an internet-facing ALB. The user is responsible for the VPC,
|
||||
# RDS Postgres, ACM cert, ECR images, and OIDC clients (see README).
|
||||
#
|
||||
# Region is inherited from the configured AWS provider — do not hardcode.
|
||||
# -----------------------------------------------------------------------------
|
||||
|
||||
data "aws_region" "current" {}
|
||||
data "aws_caller_identity" "current" {}
|
||||
|
||||
locals {
|
||||
# Merged tag set applied to every resource in the module. Callers can pin
|
||||
# cost-allocation tags / environment markers via var.tags.
|
||||
tags = merge(
|
||||
{
|
||||
"managed-by" = "terraform"
|
||||
"module" = "shared-memory"
|
||||
},
|
||||
var.tags,
|
||||
)
|
||||
|
||||
# Public URL is the canonical external origin — feeds PUBLIC_URL, AUTH_URL,
|
||||
# and OIDC redirect URIs alike.
|
||||
public_url = "https://${var.domain_name}"
|
||||
|
||||
# Service Connect namespace name. One per module instance so multiple
|
||||
# deployments (e.g. staging + prod in one cluster) don't collide.
|
||||
service_connect_namespace = "${var.name_prefix}.internal"
|
||||
|
||||
# Port constants — keep these aligned with the Dockerfiles.
|
||||
app_port = 3000
|
||||
embedder_port = 8080
|
||||
}
|
||||
@@ -0,0 +1,133 @@
|
||||
# -----------------------------------------------------------------------------
|
||||
# Security groups. One per logical tier; rules are kept tight on inbound and
|
||||
# permissive on egress (Fargate needs to reach ECR, Secrets Manager, and
|
||||
# CloudWatch — locking egress requires VPC endpoints, which the user owns).
|
||||
#
|
||||
# Note: the RDS security group is NOT created here. The user must add an
|
||||
# inbound rule on their RDS SG allowing 5432 from the embedder/app task SGs
|
||||
# (see outputs `app_security_group_id` / `embedder_security_group_id`).
|
||||
# -----------------------------------------------------------------------------
|
||||
|
||||
# ALB — internet-facing, terminates TLS, accepts 80 (redirect) and 443.
|
||||
resource "aws_security_group" "alb" {
|
||||
name = "${var.name_prefix}-alb"
|
||||
description = "shared-memory ALB: HTTPS in from internet, app out"
|
||||
vpc_id = var.vpc_id
|
||||
tags = merge(local.tags, { Name = "${var.name_prefix}-alb" })
|
||||
}
|
||||
|
||||
resource "aws_vpc_security_group_ingress_rule" "alb_http" {
|
||||
security_group_id = aws_security_group.alb.id
|
||||
description = "HTTP (redirected to HTTPS)"
|
||||
ip_protocol = "tcp"
|
||||
from_port = 80
|
||||
to_port = 80
|
||||
cidr_ipv4 = "0.0.0.0/0"
|
||||
}
|
||||
|
||||
resource "aws_vpc_security_group_ingress_rule" "alb_https" {
|
||||
security_group_id = aws_security_group.alb.id
|
||||
description = "HTTPS from the internet"
|
||||
ip_protocol = "tcp"
|
||||
from_port = 443
|
||||
to_port = 443
|
||||
cidr_ipv4 = "0.0.0.0/0"
|
||||
}
|
||||
|
||||
resource "aws_vpc_security_group_egress_rule" "alb_all" {
|
||||
security_group_id = aws_security_group.alb.id
|
||||
description = "ALB to app tasks (and anywhere — narrowed by destination SG)"
|
||||
ip_protocol = "-1"
|
||||
cidr_ipv4 = "0.0.0.0/0"
|
||||
}
|
||||
|
||||
# App tasks — accept 3000 only from the ALB SG.
|
||||
resource "aws_security_group" "app" {
|
||||
name = "${var.name_prefix}-app"
|
||||
description = "shared-memory app tasks: 3000 in from ALB only"
|
||||
vpc_id = var.vpc_id
|
||||
tags = merge(local.tags, { Name = "${var.name_prefix}-app" })
|
||||
}
|
||||
|
||||
resource "aws_vpc_security_group_ingress_rule" "app_from_alb" {
|
||||
security_group_id = aws_security_group.app.id
|
||||
description = "App port from ALB"
|
||||
ip_protocol = "tcp"
|
||||
from_port = local.app_port
|
||||
to_port = local.app_port
|
||||
referenced_security_group_id = aws_security_group.alb.id
|
||||
}
|
||||
|
||||
resource "aws_vpc_security_group_egress_rule" "app_all" {
|
||||
security_group_id = aws_security_group.app.id
|
||||
description = "Egress to embedder, RDS, ECR, Secrets Manager, CloudWatch, OIDC IdP"
|
||||
ip_protocol = "-1"
|
||||
cidr_ipv4 = "0.0.0.0/0"
|
||||
}
|
||||
|
||||
# Embedder tasks — accept 8080 only from app SG.
|
||||
resource "aws_security_group" "embedder" {
|
||||
name = "${var.name_prefix}-embedder"
|
||||
description = "shared-memory embedder tasks: 8080 in from app only"
|
||||
vpc_id = var.vpc_id
|
||||
tags = merge(local.tags, { Name = "${var.name_prefix}-embedder" })
|
||||
}
|
||||
|
||||
resource "aws_vpc_security_group_ingress_rule" "embedder_from_app" {
|
||||
security_group_id = aws_security_group.embedder.id
|
||||
description = "Embedder port from app tasks"
|
||||
ip_protocol = "tcp"
|
||||
from_port = local.embedder_port
|
||||
to_port = local.embedder_port
|
||||
referenced_security_group_id = aws_security_group.app.id
|
||||
}
|
||||
|
||||
# The migrator runs the embedding backfill against the embedder, so it
|
||||
# needs the same path as the app does.
|
||||
resource "aws_vpc_security_group_ingress_rule" "embedder_from_migrator" {
|
||||
security_group_id = aws_security_group.embedder.id
|
||||
description = "Embedder port from migrator one-shot task"
|
||||
ip_protocol = "tcp"
|
||||
from_port = local.embedder_port
|
||||
to_port = local.embedder_port
|
||||
referenced_security_group_id = aws_security_group.migrator.id
|
||||
}
|
||||
|
||||
resource "aws_vpc_security_group_egress_rule" "embedder_all" {
|
||||
security_group_id = aws_security_group.embedder.id
|
||||
description = "Egress to Hugging Face (model download), ECR, Secrets Manager, CloudWatch"
|
||||
ip_protocol = "-1"
|
||||
cidr_ipv4 = "0.0.0.0/0"
|
||||
}
|
||||
|
||||
# Migrator one-shot task — gets its own SG so RDS allow-lists are clearer.
|
||||
resource "aws_security_group" "migrator" {
|
||||
name = "${var.name_prefix}-migrator"
|
||||
description = "shared-memory migrator one-shot task (no inbound)"
|
||||
vpc_id = var.vpc_id
|
||||
tags = merge(local.tags, { Name = "${var.name_prefix}-migrator" })
|
||||
}
|
||||
|
||||
resource "aws_vpc_security_group_egress_rule" "migrator_all" {
|
||||
security_group_id = aws_security_group.migrator.id
|
||||
description = "Egress to RDS, embedder, ECR, Secrets Manager, CloudWatch"
|
||||
ip_protocol = "-1"
|
||||
cidr_ipv4 = "0.0.0.0/0"
|
||||
}
|
||||
|
||||
# EFS mount targets — accept NFS only from embedder SG (the only mounter).
|
||||
resource "aws_security_group" "efs" {
|
||||
name = "${var.name_prefix}-efs"
|
||||
description = "shared-memory EFS: 2049/tcp in from embedder tasks"
|
||||
vpc_id = var.vpc_id
|
||||
tags = merge(local.tags, { Name = "${var.name_prefix}-efs" })
|
||||
}
|
||||
|
||||
resource "aws_vpc_security_group_ingress_rule" "efs_from_embedder" {
|
||||
security_group_id = aws_security_group.efs.id
|
||||
description = "NFS from embedder tasks"
|
||||
ip_protocol = "tcp"
|
||||
from_port = 2049
|
||||
to_port = 2049
|
||||
referenced_security_group_id = aws_security_group.embedder.id
|
||||
}
|
||||
@@ -0,0 +1,89 @@
|
||||
# -----------------------------------------------------------------------------
|
||||
# Outputs.
|
||||
#
|
||||
# Designed to give the operator everything they need to:
|
||||
# * point DNS at the ALB
|
||||
# * run the migrator one-shot
|
||||
# * extend the RDS security group with task ingress
|
||||
# * tail logs
|
||||
# -----------------------------------------------------------------------------
|
||||
|
||||
output "alb_dns_name" {
|
||||
description = "ALB DNS name. Create a Route53 alias record pointing var.domain_name at this."
|
||||
value = aws_lb.this.dns_name
|
||||
}
|
||||
|
||||
output "alb_zone_id" {
|
||||
description = "ALB hosted zone ID, used as `alias.zone_id` on aws_route53_record."
|
||||
value = aws_lb.this.zone_id
|
||||
}
|
||||
|
||||
output "ecs_cluster_arn" {
|
||||
description = "ECS cluster ARN."
|
||||
value = aws_ecs_cluster.this.arn
|
||||
}
|
||||
|
||||
output "ecs_cluster_name" {
|
||||
description = "ECS cluster name. Pass to `aws ecs run-task --cluster`."
|
||||
value = aws_ecs_cluster.this.name
|
||||
}
|
||||
|
||||
output "app_service_name" {
|
||||
description = "App ECS service name."
|
||||
value = aws_ecs_service.app.name
|
||||
}
|
||||
|
||||
output "embedder_service_name" {
|
||||
description = "Embedder ECS service name."
|
||||
value = aws_ecs_service.embedder.name
|
||||
}
|
||||
|
||||
output "migrator_task_definition_arn" {
|
||||
description = "Migrator task definition ARN. Use with `aws ecs run-task --task-definition`."
|
||||
value = aws_ecs_task_definition.migrator.arn
|
||||
}
|
||||
|
||||
output "migrator_task_definition_family" {
|
||||
description = "Migrator task definition family — accepts the latest revision automatically when passed to `aws ecs run-task`."
|
||||
value = aws_ecs_task_definition.migrator.family
|
||||
}
|
||||
|
||||
output "app_log_group_name" {
|
||||
description = "CloudWatch log group for the app service."
|
||||
value = aws_cloudwatch_log_group.app.name
|
||||
}
|
||||
|
||||
output "embedder_log_group_name" {
|
||||
description = "CloudWatch log group for the embedder service."
|
||||
value = aws_cloudwatch_log_group.embedder.name
|
||||
}
|
||||
|
||||
output "migrator_log_group_name" {
|
||||
description = "CloudWatch log group for the migrator one-shot task."
|
||||
value = aws_cloudwatch_log_group.migrator.name
|
||||
}
|
||||
|
||||
output "app_security_group_id" {
|
||||
description = "Security group attached to app tasks. Add this as a source on your RDS SG inbound rule for port 5432."
|
||||
value = aws_security_group.app.id
|
||||
}
|
||||
|
||||
output "embedder_security_group_id" {
|
||||
description = "Security group attached to embedder tasks. Embedder doesn't hit RDS today, but expose for symmetry."
|
||||
value = aws_security_group.embedder.id
|
||||
}
|
||||
|
||||
output "migrator_security_group_id" {
|
||||
description = "Security group attached to the migrator one-shot. Must be allowed inbound on your RDS SG (5432) — this is what runs SQL migrations."
|
||||
value = aws_security_group.migrator.id
|
||||
}
|
||||
|
||||
output "private_subnet_ids_for_run_task" {
|
||||
description = "Echo of var.private_subnet_ids so `aws ecs run-task --network-configuration` can be assembled without re-typing them."
|
||||
value = var.private_subnet_ids
|
||||
}
|
||||
|
||||
output "secret_arns" {
|
||||
description = "Map of env-var name to Secrets Manager ARN. For visibility only — do not re-feed back into the module."
|
||||
value = local.secret_arns
|
||||
}
|
||||
@@ -0,0 +1,71 @@
|
||||
# -----------------------------------------------------------------------------
|
||||
# Secrets Manager — one secret per value (not one big JSON blob). Task
|
||||
# definitions pull these via the `secrets` block, which injects them as
|
||||
# environment variables at task start. The raw values never appear in the
|
||||
# task definition, only the secret ARNs.
|
||||
#
|
||||
# Each secret is paired with an aws_secretsmanager_secret_version so the
|
||||
# initial value is populated on apply. Rotating later is the user's call:
|
||||
# either re-run `terraform apply` with a new variable, or update the secret
|
||||
# value out-of-band (AWS console / CLI) and the next task placement picks
|
||||
# it up automatically.
|
||||
# -----------------------------------------------------------------------------
|
||||
|
||||
# DATABASE_URL — full postgres://user:pw@host/db connection string.
|
||||
resource "aws_secretsmanager_secret" "database_url" {
|
||||
name = "${var.name_prefix}/DATABASE_URL"
|
||||
description = "Postgres connection URL for shared-memory app + migrator"
|
||||
tags = local.tags
|
||||
}
|
||||
|
||||
resource "aws_secretsmanager_secret_version" "database_url" {
|
||||
secret_id = aws_secretsmanager_secret.database_url.id
|
||||
secret_string = var.database_url
|
||||
}
|
||||
|
||||
# NEXTAUTH_SECRET — Auth.js cookie signing.
|
||||
resource "aws_secretsmanager_secret" "nextauth_secret" {
|
||||
name = "${var.name_prefix}/NEXTAUTH_SECRET"
|
||||
description = "Auth.js session cookie HMAC key"
|
||||
tags = local.tags
|
||||
}
|
||||
|
||||
resource "aws_secretsmanager_secret_version" "nextauth_secret" {
|
||||
secret_id = aws_secretsmanager_secret.nextauth_secret.id
|
||||
secret_string = var.nextauth_secret
|
||||
}
|
||||
|
||||
# CLI_TOKEN_SECRET — HMAC for /connect-minted bearer tokens.
|
||||
resource "aws_secretsmanager_secret" "cli_token_secret" {
|
||||
name = "${var.name_prefix}/CLI_TOKEN_SECRET"
|
||||
description = "HMAC key for shared-memory CLI bearer tokens"
|
||||
tags = local.tags
|
||||
}
|
||||
|
||||
resource "aws_secretsmanager_secret_version" "cli_token_secret" {
|
||||
secret_id = aws_secretsmanager_secret.cli_token_secret.id
|
||||
secret_string = var.cli_token_secret
|
||||
}
|
||||
|
||||
# OIDC_CLIENT_SECRET_WEB — confidential client secret for the Web UI.
|
||||
resource "aws_secretsmanager_secret" "oidc_client_secret_web" {
|
||||
name = "${var.name_prefix}/OIDC_CLIENT_SECRET_WEB"
|
||||
description = "OIDC confidential client secret for the Web UI"
|
||||
tags = local.tags
|
||||
}
|
||||
|
||||
resource "aws_secretsmanager_secret_version" "oidc_client_secret_web" {
|
||||
secret_id = aws_secretsmanager_secret.oidc_client_secret_web.id
|
||||
secret_string = var.oidc_client_secret_web
|
||||
}
|
||||
|
||||
# Convenience map — used in outputs and to feed the task execution role
|
||||
# policy with the exact ARNs it needs to decrypt.
|
||||
locals {
|
||||
secret_arns = {
|
||||
DATABASE_URL = aws_secretsmanager_secret.database_url.arn
|
||||
NEXTAUTH_SECRET = aws_secretsmanager_secret.nextauth_secret.arn
|
||||
CLI_TOKEN_SECRET = aws_secretsmanager_secret.cli_token_secret.arn
|
||||
OIDC_CLIENT_SECRET_WEB = aws_secretsmanager_secret.oidc_client_secret_web.arn
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,197 @@
|
||||
# -----------------------------------------------------------------------------
|
||||
# Inputs. Required vars have no default; everything else has a sensible one.
|
||||
#
|
||||
# Secrets (database_url, *_secret) are marked sensitive so they don't surface
|
||||
# in `terraform plan` / `apply` output. They still flow through state, so
|
||||
# protect the state backend accordingly (see README "Security note").
|
||||
# -----------------------------------------------------------------------------
|
||||
|
||||
# -------- Identity / wiring --------
|
||||
|
||||
variable "name_prefix" {
|
||||
description = "Prefix applied to every named resource (e.g. shared-memory-prod). Keep under 24 chars so generated names stay within AWS limits."
|
||||
type = string
|
||||
}
|
||||
|
||||
variable "vpc_id" {
|
||||
description = "ID of the VPC where the ALB, ECS tasks, EFS, and security groups are created."
|
||||
type = string
|
||||
}
|
||||
|
||||
variable "public_subnet_ids" {
|
||||
description = "Public subnets (at least 2 AZs) that host the ALB."
|
||||
type = list(string)
|
||||
|
||||
validation {
|
||||
condition = length(var.public_subnet_ids) >= 2
|
||||
error_message = "Provide at least two public subnets for ALB HA."
|
||||
}
|
||||
}
|
||||
|
||||
variable "private_subnet_ids" {
|
||||
description = "Private subnets (at least 2 AZs) where ECS tasks and EFS mount targets live."
|
||||
type = list(string)
|
||||
|
||||
validation {
|
||||
condition = length(var.private_subnet_ids) >= 2
|
||||
error_message = "Provide at least two private subnets for task HA."
|
||||
}
|
||||
}
|
||||
|
||||
# -------- TLS / DNS --------
|
||||
|
||||
variable "acm_certificate_arn" {
|
||||
description = "ACM certificate ARN attached to the ALB's HTTPS listener. Must be in the same region as the ALB."
|
||||
type = string
|
||||
}
|
||||
|
||||
variable "domain_name" {
|
||||
description = "Public hostname (e.g. memory.example.com). Used to build PUBLIC_URL/AUTH_URL passed to the app."
|
||||
type = string
|
||||
}
|
||||
|
||||
# -------- Container images --------
|
||||
|
||||
variable "app_image" {
|
||||
description = "Fully qualified image URI for the web app (e.g. 12345.dkr.ecr.us-east-1.amazonaws.com/shared-memory-web:v0.5.0). Same image is reused for the migrator."
|
||||
type = string
|
||||
}
|
||||
|
||||
variable "embedder_image" {
|
||||
description = "Fully qualified image URI for the embedder sidecar."
|
||||
type = string
|
||||
}
|
||||
|
||||
# -------- Database --------
|
||||
|
||||
variable "database_url" {
|
||||
description = "Postgres connection URL, e.g. postgres://user:pw@host:5432/db. Stored in Secrets Manager. Host must be reachable from the private subnets."
|
||||
type = string
|
||||
sensitive = true
|
||||
}
|
||||
|
||||
# -------- OIDC --------
|
||||
|
||||
variable "oidc_issuer" {
|
||||
description = "OIDC issuer URL (matches `iss` claim). Both web and MCP clients must live at this issuer."
|
||||
type = string
|
||||
}
|
||||
|
||||
variable "oidc_client_id_web" {
|
||||
description = "Confidential client ID for the Web UI."
|
||||
type = string
|
||||
}
|
||||
|
||||
variable "oidc_client_secret_web" {
|
||||
description = "Confidential client secret for the Web UI. Stored in Secrets Manager."
|
||||
type = string
|
||||
sensitive = true
|
||||
}
|
||||
|
||||
variable "oidc_client_id_mcp" {
|
||||
description = "Public (PKCE) client ID used by Claude Code against /api/mcp."
|
||||
type = string
|
||||
}
|
||||
|
||||
variable "oidc_audience" {
|
||||
description = "Expected `aud` claim on MCP access tokens. Typically 'shared-memory'."
|
||||
type = string
|
||||
}
|
||||
|
||||
# -------- App-level secrets --------
|
||||
|
||||
variable "nextauth_secret" {
|
||||
description = "Session cookie signing key for Auth.js. Generate with `openssl rand -base64 32`."
|
||||
type = string
|
||||
sensitive = true
|
||||
}
|
||||
|
||||
variable "cli_token_secret" {
|
||||
description = "HMAC key used by /connect to mint long-lived CLI tokens."
|
||||
type = string
|
||||
sensitive = true
|
||||
}
|
||||
|
||||
# -------- Embedder model knobs (rarely overridden) --------
|
||||
|
||||
variable "embedding_model" {
|
||||
description = "Xenova/transformers model identifier the embedder downloads on cold start."
|
||||
type = string
|
||||
default = "Xenova/bge-small-en-v1.5"
|
||||
}
|
||||
|
||||
variable "embedding_dim" {
|
||||
description = "Output dimension of the chosen embedding model. Must match the pgvector column width."
|
||||
type = number
|
||||
default = 384
|
||||
}
|
||||
|
||||
# -------- Fargate sizing --------
|
||||
|
||||
variable "app_cpu" {
|
||||
description = "Fargate CPU units for the app task. 512 = 0.5 vCPU, 1024 = 1 vCPU."
|
||||
type = number
|
||||
default = 512
|
||||
}
|
||||
|
||||
variable "app_memory" {
|
||||
description = "Fargate memory (MiB) for the app task."
|
||||
type = number
|
||||
default = 1024
|
||||
}
|
||||
|
||||
variable "app_desired_count" {
|
||||
description = "Number of app task replicas."
|
||||
type = number
|
||||
default = 1
|
||||
}
|
||||
|
||||
variable "embedder_cpu" {
|
||||
description = "Fargate CPU units for the embedder. The model needs ~1 vCPU for tolerable latency."
|
||||
type = number
|
||||
default = 1024
|
||||
}
|
||||
|
||||
variable "embedder_memory" {
|
||||
description = "Fargate memory (MiB) for the embedder. 2 GiB is comfortable for bge-small."
|
||||
type = number
|
||||
default = 2048
|
||||
}
|
||||
|
||||
variable "embedder_desired_count" {
|
||||
description = "Number of embedder task replicas."
|
||||
type = number
|
||||
default = 1
|
||||
}
|
||||
|
||||
variable "migrator_cpu" {
|
||||
description = "Fargate CPU units for the one-shot migrator task."
|
||||
type = number
|
||||
default = 512
|
||||
}
|
||||
|
||||
variable "migrator_memory" {
|
||||
description = "Fargate memory (MiB) for the one-shot migrator task."
|
||||
type = number
|
||||
default = 1024
|
||||
}
|
||||
|
||||
# -------- Observability / misc --------
|
||||
|
||||
variable "log_level" {
|
||||
description = "LOG_LEVEL env var passed to app and embedder."
|
||||
type = string
|
||||
default = "info"
|
||||
}
|
||||
|
||||
variable "log_retention_days" {
|
||||
description = "CloudWatch retention applied to every log group the module creates."
|
||||
type = number
|
||||
default = 14
|
||||
}
|
||||
|
||||
variable "tags" {
|
||||
description = "Tags merged onto every resource the module creates."
|
||||
type = map(string)
|
||||
default = {}
|
||||
}
|
||||
@@ -0,0 +1,10 @@
|
||||
terraform {
|
||||
required_version = "~> 1.5"
|
||||
|
||||
required_providers {
|
||||
aws = {
|
||||
source = "hashicorp/aws"
|
||||
version = "~> 5.0"
|
||||
}
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user