Merge: Terraform module + AWS Fargate guide (Agent B)

This commit is contained in:
2026-05-18 09:41:08 -07:00
16 changed files with 1674 additions and 0 deletions
+19
View File
@@ -83,6 +83,25 @@ docker compose --profile tls up -d
Caddy reads `APP_HOSTNAME` and `ACME_EMAIL` from `.env` and proxies to the Caddy reads `APP_HOSTNAME` and `ACME_EMAIL` from `.env` and proxies to the
app on the internal Docker network. app on the internal Docker network.
### Mode C — AWS Fargate (Terraform)
For deployments where docker-compose on a VM isn't a fit (multi-AZ HA,
managed RDS, no host to babysit), the [`terraform/`](terraform/) directory
ships a module that wires the same three components into ECS Fargate
behind an ALB:
```bash
cd terraform/examples/basic
$EDITOR main.tf terraform.tfvars # plug in your VPC, RDS, ACM, ECR, OIDC
terraform init && terraform apply
```
You bring the VPC, RDS Postgres, ACM cert, ECR images, and OIDC clients;
the module brings ECS, ALB, EFS (for the embedder model cache), Secrets
Manager, IAM, CloudWatch, and Service Connect for app↔embedder discovery.
Full walkthrough in [`terraform/README.md`](terraform/README.md), including
the post-apply migrator invocation and DNS setup.
--- ---
## Quick start ## Quick start
+298
View File
@@ -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 60180 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/).
+85
View File
@@ -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
}
+363
View File
@@ -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
}
+57
View File
@@ -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" })
}
+63
View File
@@ -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}"
```
+100
View File
@@ -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
}
+54
View File
@@ -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
}
+69
View File
@@ -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
}
+30
View File
@@ -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
}
+36
View File
@@ -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
}
+133
View File
@@ -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
}
+89
View File
@@ -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
}
+71
View File
@@ -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
}
}
+197
View File
@@ -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 = {}
}
+10
View File
@@ -0,0 +1,10 @@
terraform {
required_version = "~> 1.5"
required_providers {
aws = {
source = "hashicorp/aws"
version = "~> 5.0"
}
}
}