deployment/terraform/modules/aws/README.md
This directory contains Terraform modules to provision the core AWS infrastructure for Onyx:
vpc: Creates a VPC with public/private subnets sized for EKSeks: Provisions an Amazon EKS cluster, essential addons (EBS CSI, metrics server, cluster autoscaler), and optional IRSA for S3 accesspostgres: Creates an Amazon RDS for PostgreSQL instance and returns a connection URLredis: Creates an ElastiCache for Redis replication groups3: Creates an S3 bucket and locks access to a provided S3 VPC endpointopensearch: Creates an Amazon OpenSearch domain for managed search workloadsonyx: A higher-level composition that wires the above modules together for a complete, opinionated stackUse the onyx module if you want a working EKS + Postgres + Redis + S3 stack with sane defaults. Use the individual modules if you need more granular control.
The snippet below shows a minimal working example that:
kubernetes and helm providers against the created clusteronyx modulelocals {
region = "us-west-2"
}
provider "aws" {
region = local.region
}
module "onyx" {
# If your root module is next to this modules/ directory:
# source = "./modules/aws/onyx"
# If referencing from this repo as a template, adjust the path accordingly.
source = "./modules/aws/onyx"
region = local.region
name = "onyx" # used as a prefix and workspace-aware
postgres_username = "pgusername"
postgres_password = "your-postgres-password"
# create_vpc = true # default true; set to false to use an existing VPC (see below)
}
resource "null_resource" "wait_for_cluster" {
provisioner "local-exec" {
command = "aws eks wait cluster-active --name ${module.onyx.cluster_name} --region ${local.region}"
}
}
data "aws_eks_cluster" "eks" {
name = module.onyx.cluster_name
depends_on = [null_resource.wait_for_cluster]
}
data "aws_eks_cluster_auth" "eks" {
name = module.onyx.cluster_name
depends_on = [null_resource.wait_for_cluster]
}
provider "kubernetes" {
host = data.aws_eks_cluster.eks.endpoint
cluster_ca_certificate = base64decode(data.aws_eks_cluster.eks.certificate_authority[0].data)
token = data.aws_eks_cluster_auth.eks.token
}
provider "helm" {
kubernetes {
host = data.aws_eks_cluster.eks.endpoint
cluster_ca_certificate = base64decode(data.aws_eks_cluster.eks.certificate_authority[0].data)
token = data.aws_eks_cluster_auth.eks.token
}
}
# Optional: expose handy outputs at the root module level
output "cluster_name" {
value = module.onyx.cluster_name
}
output "postgres_connection_url" {
value = module.onyx.postgres_connection_url
sensitive = true
}
output "redis_connection_url" {
value = module.onyx.redis_connection_url
sensitive = true
}
Apply with:
terraform init
terraform apply
The onyx module takes a size input (small | medium | large, default medium) that sets
coherent defaults for every compute and data-plane knob. Pick a tier from your expected scale:
| Tier | Users | Documents |
|---|---|---|
small | up to ~200 | < ~500k |
medium | ~200–1,000 | ~0.5–2M |
large | 1,000+ | multi-million |
What each tier provisions:
| Setting | small | medium | large |
|---|---|---|---|
| Main EKS node group | m7i.2xlarge ×1–3³ | m7i.4xlarge ×1–5 | m7i.4xlarge ×2–8 |
| Document-index node¹ | none³ | m6i.2xlarge, 100 GB | r6i.4xlarge, 512 GB |
| RDS Postgres | db.t4g.large, 64→256 GB | db.t4g.large, 128→512 GB | db.m7g.xlarge, 256→1024 GB |
| ElastiCache Redis | cache.m6g.large | cache.m6g.xlarge | cache.m6g.2xlarge |
| OpenSearch data² | r7g.large.search ×1, 256 GB | r8g.xlarge.search ×1, 512 GB | r8g.2xlarge.search ×1, 1 TB (12k IOPS) |
| OpenSearch masters² | 3× m7g.medium.search | 3× m7g.medium.search | 3× m7g.medium.search |
Pair each tier with the matching sizing snippets from the Helm chart's
deployment/helm/charts/onyx/SIZING.md (chart ≥ 0.8.0) — the tiers here size the
infrastructure, the chart snippets size the workloads on it.
¹ The dedicated index node group only matters when running the document index in-cluster
(the Helm chart's bundled OpenSearch StatefulSet). It is created tainted
(vespa-dedicated=true), so the StatefulSet must carry the toleration and nodeSelector
from SIZING.md's placement snippet to use it. If you point the chart at a managed
OpenSearch domain instead (enable_opensearch = true + disable the bundled OpenSearch in
chart values), set vespa_node_enabled = false so the node group isn't created at all.
² Only created when enable_opensearch = true. All tiers default to a single data node
without zone awareness; RDS is likewise single-AZ. For HA, set
opensearch_instance_count = 3, opensearch_zone_awareness_enabled = true (and optionally
opensearch_multi_az_with_standby_enabled = true).
³ The small tier creates no index node group — the small chart sizing fits the in-cluster
index on the main nodes. With chart ≥ 0.8.0 and SIZING.md's small snippets the whole stack
fits one m7i.2xlarge (external Postgres/Redis/S3); with plain chart defaults the
autoscaler settles at two nodes. Set vespa_node_enabled = true to add the dedicated
node back.
These defaults are calibrated from Onyx's own managed production fleet: memory, not CPU, is
the binding dimension on the Kubernetes side, and the burstable db.t4g.large holds up to
roughly the medium tier before CPU peaks make a fixed-performance class worthwhile.
Every value in the table is just a default — any sizing variable set to a non-null value
(e.g. postgres_instance_type, opensearch_instance_type, main_node_max_size) overrides
its tier.
Upgrading from a pre-sizing version of these modules: the previous hardcoded defaults
were db.t4g.large with 20 GB gp2 and no storage autoscaling, cache.m6g.xlarge, and a
3×r8g.large multi-AZ OpenSearch domain. The default medium tier keeps the same EKS node
groups and Redis node type, and grows Postgres storage online (gp2→gp3 conversion is also
online; storage can never shrink).
⚠️ If you enabled OpenSearch and relied on the old defaults, applying medium replaces
the domain and loses its index data: a single-AZ domain must live in exactly one subnet,
and the Terraform AWS provider marks vpc_options as ForceNew, so the 3-subnet → 1-subnet
change destroys and recreates the domain (capacity-only changes that don't touch subnets —
instance type/count, masters, EBS — are in-place blue/green updates). To keep the old
topology, pin it explicitly: opensearch_instance_count = 3,
opensearch_zone_awareness_enabled = true, opensearch_multi_az_with_standby_enabled = true, opensearch_dedicated_master_type = "m7g.large.search",
opensearch_instance_type = "r8g.large.search". To adopt the new shape on an existing
domain, take a manual snapshot first and plan a restore. Either way, check terraform plan for -/+ destroy and then create replacement on the domain before applying.
If you already have a VPC and subnets, disable VPC creation and provide IDs, CIDR, and the ID of the existing S3 gateway endpoint in that VPC:
module "onyx" {
source = "./modules/aws/onyx"
region = local.region
name = "onyx"
postgres_username = "pgusername"
postgres_password = "your-postgres-password"
create_vpc = false
vpc_id = "vpc-xxxxxxxx"
private_subnets = ["subnet-aaaa", "subnet-bbbb", "subnet-cccc"]
public_subnets = ["subnet-dddd", "subnet-eeee", "subnet-ffff"]
vpc_cidr_block = "10.0.0.0/16"
s3_vpc_endpoint_id = "vpce-xxxxxxxxxxxxxxxxx"
}
onyxvpc, eks, postgres, redis, and s3name and the current Terraform workspacecluster_name: EKS cluster namepostgres_connection_url (sensitive): postgres://...redis_connection_url (sensitive): hostname:portInputs (common):
name (default onyx), region (default us-west-2), tagssize (small/medium/large, default medium) — see "T-shirt sizing" above — plus per-setting overrides (main_node_*, vespa_node_*, postgres_instance_type, postgres_storage_gb, redis_instance_type, opensearch_*)postgres_username, postgres_passwordcreate_vpc (default true) or existing VPC details and s3_vpc_endpoint_idwaf_allowed_ip_cidrs, waf_common_rule_set_count_rules, rate limits, geo restrictions, and logging retentionenable_opensearch, sizing, credentials, and log retentionvpcvpc_id, private_subnets, public_subnets, vpc_cidr_block, s3_vpc_endpoint_idekscluster_name, cluster_endpoint, cluster_certificate_authority_data, s3_access_role_arn (if created)Key inputs include:
cluster_name, cluster_version (default 1.33)vpc_id, subnet_idspublic_cluster_enabled (default true), private_cluster_enabled (default false)cluster_endpoint_public_access_cidrs (optional)eks_managed_node_groups (defaults include a main and a vespa-dedicated group with GP3 volumes)s3_bucket_names (optional list). If set, creates an IRSA role and Kubernetes service account for S3 accesspostgresredisauth_token and instance sizings3opensearchOnce the cluster is active, deploy application workloads via Helm. You can use the chart in deployment/helm/charts/onyx.
# Set kubeconfig to your new cluster (if you’re not using the TF providers for kubernetes/helm)
aws eks update-kubeconfig --name $(terraform output -raw cluster_name) --region ${AWS_REGION:-us-west-2}
kubectl create namespace onyx --dry-run=client -o yaml | kubectl apply -f -
# If using AWS S3 via IRSA created by the EKS module, consider disabling MinIO
# Replace the path below with the absolute or correct relative path to the onyx Helm chart
helm upgrade --install onyx /path/to/onyx/deployment/helm/charts/onyx \
--namespace onyx \
--set minio.enabled=false \
--set serviceAccount.create=false \
--set serviceAccount.name=onyx-s3-access
Notes:
ServiceAccount named onyx-s3-access (by default in namespace onyx) when s3_bucket_names is provided. Use that service account in the Helm chart to avoid static S3 credentials.minio.enabled=true (default) and skip IRSA.onyx module automatically includes the workspace in resource names.