Terraform Deployment - ericfitz/tmi GitHub Wiki

Terraform Deployment

This guide covers deploying TMI infrastructure on AWS using Terraform. AWS is the only cloud target with Terraform support. The other supported deployment targets are k3s (deployments/k8s/dev/k3s) and local docker-desktop development; see Planning-Your-Deployment.

Overview

TMI provides two Terraform environments under terraform/environments/:

Environment Purpose
aws-public The live, internet-accessible deployment: VPC, EKS, RDS PostgreSQL, Secrets Manager, ACM certificate, ECR repositories, and bootstrap Kubernetes objects
aws-persistent Long-lived account-level resources that must outlive any single aws-public deployment: the NAT egress Elastic IP, S3 log bucket, CloudTrail, security alerting, and IAM Access Analyzer

Terraform manages infrastructure and bootstrap objects only (namespace, tmi-server-config ConfigMap, secrets, ServiceAccount, AWS Load Balancer Controller). The workloads themselves (server, Redis, workers, ingress) are applied by scripts/deploy-aws.sh using the deployments/k8s/dev/aws kustomize overlay. For a full deployment, use make deploy-aws (see below) rather than running Terraform by hand.

In-cluster Redis requires TLS and a password, and NATS requires mTLS (TMI_NATS_URL=tls://nats.tmi-platform.svc:4222 in the ConfigMap). deploy-aws.sh applies the vendored cert-manager and Stakater Reloader, then the internal CA (deployments/k8s/platform/pki.yml) that issues the Redis and NATS certificates, before the workloads.

Architecture

Route 53 -> ALB (HTTPS 443, ACM certificate, WebSocket-capable)
        ↓
EKS cluster (2 managed t3.medium nodes, private subnets)
  ├── TMI Server pod (1 replica)
  ├── Redis pod (separate Deployment; TLS + password)
  └── NATS JetStream + workers (mTLS)
        ↓
RDS PostgreSQL (db.t3.micro, private subnet)
        ↓
AWS Secrets Manager
Resource Details
Kubernetes EKS, managed node group of 2 × t3.medium in private subnets; public API endpoint restricted to a CIDR set by deploy-aws.sh
Database RDS PostgreSQL db.t3.micro, deletion protection enabled, final snapshot on destroy, storage autoscaling up to 100 GB
Load Balancer ALB (internet-facing) created by the AWS Load Balancer Controller from the ingress in the kustomize overlay
Certificates ACM certificate for domain_name, DNS-validated in Route 53
Secrets AWS Secrets Manager (JWT secret, DB password, Redis password, settings encryption key)
Logging CloudWatch (30-day retention); ALB access logs and VPC flow logs go to the S3 log bucket owned by aws-persistent
Registry ECR (one repository per component: server, Redis, extractor, chunk-embed, controller)
Egress NAT gateway using the fixed Elastic IP owned by aws-persistent

Key constraints:

  • Single TMI server instance always -- TMI is stateful and not designed for HA
  • Multi-arch container images -- amd64 + arm64 via Docker buildx (--arch both; make build-app-aws builds amd64 only)
  • TMI logs to stdout -- collected by CloudWatch

Prerequisites

Required Tools

  • Terraform 1.5+
  • AWS CLI with a configured profile (deploy-aws.sh defaults to the tmi profile; override with --profile)
  • kubectl (for verifying deployment)
  • Docker (for building container images)
  • An existing Route 53 hosted zone for the API hostname
  • aws-persistent applied first: aws-public reads the NAT egress EIP from it

Container Images

make build-app-aws builds linux/amd64 images and pushes them to ECR. For local-only builds use make build-app. The container build script accepts --target local or --target aws.

Quick Start

1. Deploy

make deploy-aws ARGS="--domain tmi.example.com --zone-id Z0123456789ABCDEFGHIJ --api-cidr 203.0.113.5/32"

The EKS public API endpoint allowlist comes from --api-cidr <ip>/32, or, when that is not given, from the untracked .local/aws-deploy.json ({"api_cidr_host": "<hostname that resolves to your public IP>"}), which the script resolves to a /32. The deploy fails if neither is present.

deploy-aws.sh runs terraform apply for aws-public, builds and pushes images to ECR, configures kubeconfig, applies the kustomize overlay, upserts the server DNS record, and verifies. Useful options: --api-cidr, --region, --name-prefix, --profile, --skip-build, --dry-run (plan only), --auto-approve, --destroy. Run scripts/deploy-aws.sh --help for the full list. Preview with make deploy-aws-dry-run ARGS="--domain ... --zone-id ...".

2. Running Terraform directly

The tf-* Make targets wrap scripts/manage-terraform.py. TF_ENV selects the environment and defaults to aws-public:

make tf-init
make tf-plan
make tf-apply
make tf-output
make tf-destroy          # DESTRUCTIVE

make tf-plan TF_ENV=aws-persistent   # operate on the persistent stack

Or run Terraform by hand:

cd terraform/environments/aws-public
cp terraform.tfvars.example terraform.tfvars   # edit values
terraform init -backend-config=backend.hcl
terraform plan -out=tfplan
terraform apply tfplan

Key aws-public variables (see variables.tf and terraform.tfvars.example):

Variable Default Description
aws_region us-east-1 AWS region
name_prefix tmi Prefix for resource names
domain_name (required) API hostname for the ACM certificate
hosted_zone_id (required) Route 53 zone containing domain_name
extra_env_vars {} Extra server environment variables merged into the ConfigMap
eks_public_access_cidrs null CIDRs allowed to reach the EKS public API endpoint (set by deploy-aws.sh)
tags {} Additional resource tags

3. Verify

kubectl get pods -n tmi-platform
curl https://<domain_name>/

Configuration

Environment Variables

Terraform sets the server's defaults through the tmi-server-config ConfigMap. Notable values for aws-public:

Variable Value
TMI_BUILD_MODE production
TMI_AUTH_AUTO_PROMOTE_FIRST_USER false
TMI_AUTH_EVERYONE_IS_A_REVIEWER true
TMI_LOG_ALSO_LOG_TO_CONSOLE true
TMI_LOG_REDACT_AUTH_TOKENS true
TMI_LOG_SUPPRESS_UNAUTH_LOGS true
TMI_SERVER_INTERFACE 0.0.0.0
TMI_SERVER_PORT 8080
TMI_SERVER_TLS_ENABLED true (the ALB re-encrypts to the pod)
TMI_CORS_ALLOWED_ORIGINS Pinned browser origin (set in terraform/modules/kubernetes/aws/k8s_resources.tf)

The ConfigMap is defined in terraform/modules/kubernetes/aws/k8s_resources.tf; that file is the source of truth for the complete list.

Custom Environment Variables

Add custom environment variables (IdP configuration, timeouts, etc.) via the extra_env_vars variable in your .tfvars:

extra_env_vars = {
  "OAUTH_PROVIDERS_GOOGLE_CLIENT_ID"     = "your-client-id"
  "OAUTH_PROVIDERS_GOOGLE_CLIENT_SECRET" = "your-client-secret"
  "TMI_WEBSOCKET_INACTIVITY_TIMEOUT_SECONDS" = "600"
}

Secrets

The JWT secret, database password, and Redis password are generated via Terraform's random_password resource and the settings encryption key via random_id; all are stored in AWS Secrets Manager. They are stable across subsequent terraform apply runs.

WebSocket Support

TMI uses WebSockets for real-time collaborative diagram editing. The ALB idle timeout is set to 3600 seconds (idle_timeout.timeout_seconds=3600 in deployments/k8s/dev/aws/ingress.yml) to support long-lived connections. TMI's default WebSocket inactivity timeout is shorter, so the LB timeout is not the limiting factor.

Post-Deployment Steps

  1. Verify deployment: kubectl get pods -n tmi-platform -- confirm TMI and Redis pods are running
  2. Test connectivity: curl https://<domain_name>/ -- should return TMI version info
  3. Register OAuth provider: Configure at least one OAuth identity provider:
    • Set the callback URL at the IdP to https://<domain_name>/oauth2/callback
    • Add the IdP's client ID/secret via extra_env_vars and re-apply

CORS

The AWS deployment pins the allowed browser origin with TMI_CORS_ALLOWED_ORIGINS (default set in terraform/modules/kubernetes/aws/k8s_resources.tf). If the client is served from a different origin, update that value.

Directory Structure

terraform/
├── environments/
│   ├── aws-public/          # Live EKS/RDS deployment
│   └── aws-persistent/      # NAT EIP, log bucket, CloudTrail, alerts, Access Analyzer
└── modules/
    ├── certificates/aws/    # ACM certificates
    ├── database/aws/        # RDS PostgreSQL
    ├── dns/aws/             # Route 53 records (not used by aws-public; deploy-aws.sh upserts the DNS record)
    ├── kubernetes/aws/      # EKS cluster + bootstrap objects
    ├── logging/aws/         # CloudWatch log collection
    ├── network/aws/         # VPC, subnets, NAT, security groups
    └── secrets/aws/         # Secrets Manager

Terraform State

Both environments use an S3 backend with a lockfile. The bucket and region are supplied at init time:

terraform init -backend-config=backend.hcl

aws-persistent reuses ../aws-public/backend.hcl with its own state key (tmi/aws-persistent/terraform.tfstate).

Tearing Down

make deploy-aws ARGS="--destroy --domain tmi.example.com --zone-id Z0123456789ABCDEFGHIJ"
# or
make tf-destroy

The RDS database has deletion protection enabled and takes a final snapshot, so terraform destroy requires disabling deletion protection first.

aws-persistent is never destroyed by a deploy or undeploy. In particular, the NAT egress Elastic IP is protected: it has prevent_destroy and an IAM deny on ec2:ReleaseAddress, because an external system depends on that address. Do not destroy the aws-persistent stack casually.

Troubleshooting

Pods Not Starting

kubectl describe pod -n tmi-platform <pod-name>
kubectl logs -n tmi-platform <pod-name>

A pod stuck Pending with "Too many pods" means the node has hit the VPC CNI pod-per-node ceiling (17 pods on t3.medium); the deployment uses two nodes to leave room for rolling updates.

WebSocket Connections Dropping

Check the ALB idle timeout. It must be >= the TMI WebSocket inactivity timeout (TMI_WEBSOCKET_INACTIVITY_TIMEOUT_SECONDS).

OAuth Callback Failures

Ensure TMI_OAUTH_CALLBACK_URL matches the URL registered with your identity provider. If you use a custom domain, set it via extra_env_vars.

Cannot Reach the K8s API

The EKS public API endpoint is restricted to a CIDR set by deploy-aws.sh (--api-cidr, or api_cidr_host from .local/aws-deploy.json resolved to a /32). If your IP changed, re-run the deploy script with --api-cidr <your-ip>/32, or let it re-resolve api_cidr_host.

Multi-Arch Container Images

make build-app-aws builds linux/amd64 only. For amd64 + arm64 images, call the build script directly with --arch both --push (Docker buildx):

make build-app-aws   # linux/amd64, build and push to ECR
uv run scripts/build-app-containers.py --target aws --arch both --push   # multi-arch
make build-app       # local platform only (testing)

The Chainguard base images and Go cross-compilation (CGO_ENABLED=0) make this work seamlessly. Oracle-tagged builds (requiring CGO) remain x86_64 only.

Related Documentation

⚠️ **GitHub.com Fallback** ⚠️