Skip to content

BYOC on AWS with Terraform

If your platform team manages infrastructure as code, you can deploy Nomain into your own AWS account from your own Terraform pipeline instead of the AWS Marketplace one-click launch. This page is the how-to for that path.

It is a companion to BYOC on AWS; read that first for the architecture, Bedrock model access, and the account prerequisites. For the network placement options see AWS networking; for the subscribe-and-launch path see AWS Marketplace.

Same infrastructure, different front door

The Terraform module is a thin wrapper around the same published Nomain CloudFormation templates. Its core resource is a single aws_cloudformation_stack pointing at the versioned main.yaml. The infrastructure that comes up is byte-identical to a Marketplace or manual CloudFormation deploy of the same version: the module only turns the CloudFormation parameters into typed Terraform variables and pins every version-bearing value from one nomain_version.

When to use this

Choose the Terraform path when your organization already runs a Terraform/GitOps pipeline and wants Nomain managed the same way as the rest of its estate: the deploy reviewed as a Terraform plan, the VPC wired straight from your own network module, and no launch-form step. If you want the guided subscribe-and-launch experience instead, use the AWS Marketplace listing.

Two networking profiles are available, and the choice drives how the webapp is reached:

  • Bring your own VPC: deploy into an existing customer-managed VPC. The stack creates no VPC, subnets, IGW, NAT or route tables, and the webapp load balancer is internal: reach it over your existing private connectivity. This is the usual choice for a self-hosted deployment.
  • Dedicated VPC: leave the existing_* inputs empty and the stack builds its own network. Only this profile can expose the webapp on the internet (web_app_access = "Public"), which requires a publicly trusted certificate.

Prerequisites

Everything in the BYOC on AWS prerequisites, plus:

#ItemNotes
1Terraform (or OpenTofu) and an AWS-authenticated pipelineThe deploy principal needs the same permissions as a manual CloudFormation deploy of the stack (see Deploy permissions).
2Registration identityThe tenant_id and the machine-to-machine ClientId / Secret Nomain issues you at onboarding.
3Container imagesBy default, service images are pulled from the Nomain-managed ECR; Nomain grants your account pull access at onboarding and you host nothing. Alternatively, mirror the images into a registry you control and set container_registry (see the parameter reference).
4An existing VPC (only for the BYO-VPC profile)Two private subnets (ECS + internal ALB) and two isolated subnets (RDS), across two Availability Zones. When you supply these the module creates no VPC, subnets, gateways or route tables. Skip this item to let the stack build its own network.
5An encrypted Terraform state backendSee State contains the M2M secret.

Module usage

Reference the published module archive by a pinned version and pass your identity and VPC inputs:

hcl
module "nomain" {
  source = "s3::https://nomain-shared-config.s3.eu-north-1.amazonaws.com/byoc-terraform/v1.3.2/nomain-byoc-aws.zip"

  # nomain_version defaults to the release this archive was published for
  # (v1.3.2 here). Set it only to pin a different published version.

  # Identity — provided during Nomain registration
  tenant_id         = "your-tenant-guid"
  m2m_client_id     = var.m2m_client_id
  m2m_client_secret = var.m2m_client_secret

  # Networking — BYO-VPC: deploy into your existing VPC, webapp ALB internal.
  # Omit these four to have the stack build its own VPC (see web_app_access).
  existing_vpc_id              = "vpc-0abc123def4567890"
  existing_vpc_cidr            = "10.20.0.0/16"
  existing_private_subnet_ids  = ["subnet-0aaa1111", "subnet-0bbb2222"]
  existing_isolated_subnet_ids = ["subnet-0ccc3333", "subnet-0ddd4444"]
}

The s3:: source fetches the archive with your own AWS credentials; a plain https:: source also works since the bucket is public-read. Then run the usual flow from your pipeline:

bash
terraform init
terraform plan   # your change-set review
terraform apply

Verify the archive before you deploy

Every release is signed. Verify the download against Nomain's public key before the first terraform init; see Verifying the download.

Wiring the VPC from your own Terraform

Because you manage your VPC as code, pass the existing_* inputs straight from your own resources or outputs rather than hardcoding IDs, the idiomatic pattern and a key advantage over the launch form:

hcl
# VPC and Nomain in the same configuration
existing_vpc_id              = module.vpc.vpc_id
existing_vpc_cidr            = module.vpc.vpc_cidr_block
existing_private_subnet_ids  = module.vpc.private_subnets
existing_isolated_subnet_ids = module.vpc.database_subnets # or reuse private_subnets

The reference creates an implicit dependency, so Terraform builds the VPC before the Nomain stack. If a separate network team owns the VPC, read its IDs from a terraform_remote_state data source; with no coupling to state, look them up by tag with aws_vpc / aws_subnets data sources. The module ships examples/byo-vpc (literal IDs) and examples/compose-with-vpc (wired from a VPC module) for this profile, plus examples/dedicated-vpc for a stack-created network with a public webapp.

Parameter reference

Every input the module accepts, grouped by area. Set them in your terraform.tfvars (or as TF_VAR_* from your pipeline's secret store). Only the inputs marked (required) must be provided; everything else has a production-sensible default, so you override only what you need.

Markers: (required) must be set · (sensitive) keep out of version control, inject via TF_VAR_* · (fixed) Nomain-managed, leave at its default. The module covers both networking profiles; the Marketplace image source is the one CloudFormation knob it does not expose, since Terraform deployments pull from the Nomain registry by cross-account IAM.

Identity & tenant

Provisioned when Nomain registers your tenant. Source the two secrets from your CI secret store, never a committed file.

VariableTypeDefaultNotes
tenant_id (required)string(none)Nomain tenant GUID from registration (becomes NOMAIN_TENANT_ID). Not the WorkOS organization id.
m2m_client_id (required, sensitive)string(none)Machine-to-machine client id for service-to-service auth.
m2m_client_secret (required, sensitive)string(none)Machine-to-machine client secret. Inject via TF_VAR_m2m_client_secret.

Deployment target & networking

Supply the four existing_* inputs to deploy into your own VPC, or leave all four empty to have the stack build one. Under BYO-VPC, subnets must span at least two AZs and reach the AWS APIs through your own egress.

The existing_* inputs are all-or-nothing and a partial set is rejected at plan time. This matters because supplying subnets while existing_vpc_id is empty would otherwise deploy successfully into a brand-new VPC and silently discard them.

VariableTypeDefaultNotes
web_app_accessstring"Internal"Internal keeps the webapp load balancer private to the VPC. Public makes it internet-facing and needs certificate_arn; only available with a stack-created VPC. Both constraints are rejected at plan time. Defaults to the safe value, so omitting it cannot publish the webapp.
vpc_cidrstring"10.10.0.0/16"IPv4 CIDR for the VPC the stack creates. Must be a canonical block between /16 and /21 (EC2 requires /16 or longer, and the stack carves eight /24 subnets out of it); all three constraints are rejected at plan time. Ignored when existing_vpc_id is set.
existing_vpc_idstring""An existing VPC to deploy into. Empty = the stack creates the VPC, subnets, IGW, NAT and route tables. When set, the deployment is internal-only.
existing_vpc_cidrstring""Primary IPv4 CIDR of that VPC, used for the internal ALB and security-group ingress. Required with existing_vpc_id.
existing_private_subnet_idslist(string)[]≥2 subnets across 2 AZs for ECS, the internal ALB and db-setup. Required with existing_vpc_id.
existing_isolated_subnet_idslist(string)[]≥2 subnets across 2 AZs for RDS. May equal the private subnets if you have no dedicated DB tier. Required with existing_vpc_id.
vpn_client_cidrstring""Extra CIDR allowed to reach the internal ALB (e.g. a VPN client pool or on-prem range).
create_vpc_endpointsstring"Auto"Interface/gateway endpoints for the AWS APIs. Auto creates them when the stack creates the VPC, and skips them for an existing VPC (where your own egress reaches the APIs). Always always creates them, Never never does. Interface endpoints bill hourly per endpoint per AZ, so Never is the value that keeps a dedicated VPC on plain NAT egress.

Database (RDS PostgreSQL)

Defaults are production-grade. The durability knobs are yours to trade off for cost on non-production stamps.

VariableTypeDefaultNotes
db_instance_classstring"db.r6g.xlarge"RDS instance size. Allowed: db.t4g.micro, db.r6g.large/xlarge/2xlarge, db.r7g.large/xlarge/2xlarge.
db_allocated_storagenumber500Initial storage in GB; autoscales up to the max (20 to 65536).
db_max_allocated_storagenumber2000Storage-autoscaling ceiling in GB (20 to 65536).
multi_azbooltrueRDS standby + second NAT across two AZs. false = single-AZ cost/availability trade-off.
backup_retention_periodnumber14Days to retain automated RDS backups (1 to 35).
enable_deletion_protectionbooltrueRDS deletion protection + snapshot-on-delete. Set false only for disposable environments.

Compute sizing (Fargate)

CPU/memory must form a valid Fargate pair. The recipes worker is sized independently and scales to zero when idle.

VariableTypeDefaultNotes
service_cpunumber1024CPU units for the long-running services. Allowed: 256 / 512 / 1024 / 2048 / 4096.
service_memorynumber2048Memory (MiB) for the long-running services. Allowed: 512 / 1024 / 2048 / 4096 / 8192.
recipes_worker_cpunumber8192CPU units for the recipes worker. Allowed: 2048 / 4096 / 8192 / 16384.
recipes_worker_memorynumber32768Memory (MiB) for the recipes worker. Allowed: 16384 / 24576 / 32768 / 49152 / 61440.
recipes_worker_max_countnumber5Maximum recipes-worker tasks the SQS-backlog autoscaler may run in parallel (1 to 20). Minimum stays 0 (scale-to-zero when idle); higher increases throughput and Fargate cost.

AI / Bedrock models

Model ids are Bedrock model identifiers. Ensure the corresponding models are enabled in your Bedrock account and region.

VariableTypeDefaultNotes
enable_bedrockbooltrueEnable Amazon Bedrock for model access.
chat_modelstring"anthropic.claude-sonnet-4-6"Bedrock model id for chat conversations.
orchestrator_modelstring""High-capacity model for the chat orchestrator. Empty inherits chat_model.
fast_modelstring"anthropic.claude-haiku-4-5-…"Bedrock model id for fast, low-latency tasks.
embedding_modelstring"amazon.titan-embed-text-v2:0"Bedrock model id for text embeddings.
rerank_modelstring"disabled"Model id for reranking search results, or "disabled" to skip reranking.
ai_providerstring"bedrock"AI provider identifier.
ai_regionstring""AWS region for Bedrock API calls. Empty defaults to the stack region.
llm_service_max_requests_per_minutenumber10000LlmService max Bedrock requests/min (default = the AWS Bedrock default request quota).
llm_service_max_tokens_per_minutenumber3000000LlmService max Bedrock tokens/min (default = the AWS Bedrock default, Opus 4.6 floor).
ai_parser_max_requests_per_minutenumber10000AI-fallback parser max Bedrock requests/min.
ai_parser_max_tokens_per_minutenumber3000000AI-fallback parser max Bedrock tokens/min.
unused_code_detection_max_requests_per_minutenumber10000Dead-code / liveness (graphs-api only) max Bedrock requests/min.
unused_code_detection_max_tokens_per_minutenumber3000000Dead-code / liveness (graphs-api only) max Bedrock tokens/min.
llm_service_max_concurrent_analysisnumber250LlmService internal parallelism: max concurrent in-flight Bedrock calls per analysis. Usually the practical throughput lever for a single large analysis (a step runs on one worker). Default aligns with Azure production; size down to your applied quota if lower.
ai_parser_max_concurrent_analysisnumber75AI-fallback parser internal parallelism: max concurrent in-flight Bedrock calls per analysis. Default aligns with the Azure production recipes-worker value.
unused_code_detection_max_concurrent_analysisnumber15Dead-code / liveness (graphs-api only) internal parallelism: max concurrent in-flight Bedrock calls per analysis. Default aligns with Azure production.

The six rate-limit variables are per-service Bedrock ceilings shared by the graphs-api and recipes-worker tasks. Their defaults match the AWS Bedrock default quota: lower them to your account's applied quota to avoid 429 throttling, since a fresh account's granted quota is often well below the published default. The three *_max_concurrent_analysis variables tune internal parallelism (concurrent in-flight Bedrock calls per analysis) rather than a per-minute ceiling; since one analysis step runs on a single worker, this, not worker count, is the throughput lever for a single large analysis. Their defaults are aligned to the Azure production posture; size them to your account's applied Bedrock quota (down if it is below the published default, up if you have headroom).

TLS & WAF

VariableTypeDefaultNotes
certificate_arnstring""ACM certificate ARN (stack region, ISSUED) to terminate HTTPS on the ALB. Empty = HTTP listener, and empty is rejected when web_app_access = "Public". For a public webapp the certificate must be publicly trusted: one issued by a private CA attaches to an internet-facing ALB without complaint and then fails in every browser that has not installed that CA.
enable_wafboolunset → createWAFv2 WebACL (AWS managed rules) on the webapp ALB. false to attach your own WebACL or when WAF is managed centrally; associate it to the alb_arn output. A public ALB must carry a WebACL one way or the other; Public with none is not a supported posture.

Container images & registry

By default images come from the Nomain cross-account ECR. Set container_registry to pull from a mirror you control: that single knob switches the source; the rest tune the layout. An ECR your account can already pull is authenticated by IAM (no credentials secret); you mirror the images yourself, and Nomain can grant your account pull on the source repos at onboarding.

VariableTypeDefaultNotes
container_registrystring""Registry host to pull from instead of the Nomain ECR (e.g. your own <acct>.dkr.ecr.<region>.amazonaws.com, optionally with a /namespace). Setting it derives ImageRegistrySource = CustomRegistry. Empty keeps the Nomain ECR.
ecr_account_idstring"717745462956"Account hosting the Nomain ECR; used only on the default path; ignored when container_registry is set.
ecr_base_namestring"nomainregistry"Per-service repo prefix (nomainregistry-<svc>). Set "" for a path-style mirror (<registry>/<svc>).
registry_credentials_secret_arnstring""Secrets Manager ARN with {username, password} for a basic-auth registry (e.g. artifactory). Leave empty for your own ECR; it authenticates by IAM.
otel_collector_image_uristring""Override for the ADOT collector sidecar image. Empty uses the Nomain-ECR mirror; set for air-gapped installs.

Audit & observability

ISO 27001 controls default to on. The enable_* toggles let you opt out where your AWS org already provides the control centrally.

VariableTypeDefaultNotes
audit_log_retention_in_daysnumber365Retention for audit / security / diagnostic log groups. CloudWatch-supported values only (30 … 3653).
enable_s3_data_eventsbooltrueRecord CloudTrail S3 delete data events on the customer storage bucket.
enable_audit_trailboolunset → createPer-stamp CloudTrail trail (+ object-locked bucket). false when an org-level trail already covers the account.
enable_vpc_flow_logsboolunset → createPer-stamp VPC Flow Log. false when your org centralizes flow logs.
permissions_boundary_arnstring""IAM permissions-boundary ARN stamped on every role the stack creates. Required by orgs whose SCP mandates a boundary.
alarm_notification_emailstring""Email subscribed to the CloudWatch alarms SNS topic. Empty creates the topic with no subscription.
alb_latency_alarm_secondsnumber3ALB p95 target response time (s) that triggers a latency alarm.
alb_target_5xx_alarm_thresholdnumber10Target-level 5xx count per 5-minute window that triggers an alarm.
alb_elb_5xx_alarm_thresholdnumber5ALB-level 5xx count per 5-minute window that triggers an alarm.

The three governed detective controls (enable_waf, enable_audit_trail, enable_vpc_flow_logs) default to the template's on-by-default posture; leave them unset unless your organization owns those controls centrally and its SCP forbids member-account trails / flow-logs / WebACLs.

Egress proxy (optional)

Route all outbound HTTPS through a forward proxy. Off by default. The CA bundle is only needed for a TLS-intercepting (MITM) proxy and requires the URL.

VariableTypeDefaultNotes
proxy_url (sensitive)string""Forward-proxy URL for outbound HTTPS (may carry user:pass@). Stored in Secrets Manager, never the task-def env; a managed NO_PROXY keeps AWS-service traffic direct.
proxy_ca_pemstring""Raw PEM of the proxy's CA (the module base64-encodes it). Only for a TLS-intercepting proxy; requires proxy_url. Tip: proxy_ca_pem = file("proxy-ca.pem").

Advanced (optional)

Rarely changed, but yours to set: naming, release pinning, tagging and a debug switch.

VariableTypeDefaultNotes
nomain_versionstring(archive's release)Release to deploy; pins the template URL, nested-template prefix and image tag in lock-step. Defaults to the release this module archive was published for; override to pin a different one.
stack_namestring"nomain"Name of the CloudFormation stack created in your account.
base_namestring"nomain"Base name prefix for all resources (2 to 21 chars, lowercase).
application_tagstring""AppRegistry application ARN for the awsApplication tag. Empty to skip.
disable_rollbackboolfalseOnFailure=DO_NOTHING: a failed deploy stops with resources intact for debugging instead of rolling back.
tagsmap(string){}Tags applied to the CloudFormation stack; propagate to stack resources.

Nomain-managed (do not change)

These point at Nomain's release hosting and control-plane. Leave them at their defaults; the only reason to override is a specific air-gapped or non-production engagement coordinated with Nomain, which changes other inputs (and credentials) too.

VariableTypeDefaultNotes
environment_name (fixed)string"production"Selects the Nomain control-plane bundle (and naming/tagging). Keep "production" for a production stamp; a different value expects matching Nomain-issued credentials.
shared_config_url (fixed)stringNomain S3Base URL of the Nomain-hosted shared-config bucket. Override only for an air-gapped self-host coordinated with Nomain.
shared_config_region (fixed)string"eu-north-1"Region of the Nomain templates bucket (single-home, independent of your deploy region).
templates_bucket_name (fixed)string"nomain-shared-config"Bucket hosting the nested CloudFormation templates. Override only when self-hosting them (see Air-gapped / self-hosted templates).

Outputs and DNS

The module exposes alb_dns_name, alb_canonical_hosted_zone_id, alb_arn, web_app_scheme, rds_endpoint, s3_bucket_name, vpc_id, ecs_cluster_arn, stack_id, and the full stack_outputs map.

After apply, point your domain at alb_dns_name. The web_app_scheme output tells you which shape you got.

With an internal ALB, add an A-alias record in a private hosted zone (or your own internal DNS), reachable over your private connectivity into the VPC: Transit Gateway, Direct Connect, Site-to-Site VPN, or VPC peering.

With a public ALB, add the record in a public zone instead. The zone does not have to be Route 53: a CNAME from your hostname to alb_dns_name works from any DNS provider, and it must resolve to the same name the certificate was issued for.

Either way the stack creates no Route 53 resources; you own the DNS record. When enable_waf = false, associate your centrally-managed WAFv2 WebACL to alb_arn (same account + region as the ALB). On a public ALB this is not optional: the WebACL is the only filtering in front of the webapp, where an internal ALB already sat behind your network boundary.

Upgrading

The version lives in a single place (the nomain_version variable), which defaults to the release the archive was published for, so you normally don't set it. It derives, in lock-step, the root template URL, the nested-templates prefix, and the container image tag for every service. To upgrade, bump the version in the module source URL and re-apply: the Terraform plan is your change-set review. Terraform cannot interpolate a source, so the version appears literally in that URL; that is the single knob. There are no semver constraint operators: you pin a literal version, the same strict-pinning behavior as the CloudFormation flow. Ask your Nomain contact for the current published version.

Verifying the download (supply chain)

Every published release is cosign-signed. Alongside the archive, Nomain publishes a detached signature (nomain-byoc-aws.zip.sig) under the same versioned prefix and the signing public key at the version-independent path byoc-terraform/cosign.pub. The private key is an asymmetric AWS KMS key (ECC_NIST_P256) held in Nomain's account: you never need KMS access; you verify with the public key alone.

bash
BASE=https://nomain-shared-config.s3.eu-north-1.amazonaws.com
VERSION=v1.3.2   # the version you pinned in the module `source`

# one-time: fetch the signing public key (pin it — see the fingerprint below)
curl -fsSLO "$BASE/byoc-terraform/cosign.pub"

# per download: fetch the archive + its detached signature, then verify
curl -fsSLO "$BASE/byoc-terraform/$VERSION/nomain-byoc-aws.zip"
curl -fsSLO "$BASE/byoc-terraform/$VERSION/nomain-byoc-aws.zip.sig"
cosign verify-blob \
  --key cosign.pub \
  --signature nomain-byoc-aws.zip.sig \
  --insecure-ignore-tlog=true \
  nomain-byoc-aws.zip

--insecure-ignore-tlog=true is expected and correct: the artifact is key-signed without a transparency-log (Rekor) entry, so verification is fully offline against the public key with no call to public Sigstore infrastructure, the right model for air-gapped and egress-restricted networks. The command works with either cosign v2 or v3.

Pin the public key out of band

A cosign.pub fetched from the same bucket as the artifact is only trust-on-first-use. Confirm it matches the fingerprint Nomain publishes out of band (first published with the v1.3.2 release) before you rely on it, then reuse that pinned key for every subsequent download:

SHA-256: 0aec212983e64193fba47b280ed6dd88c482c76f1aeb618d12dae7aaba50a5d7

Compute the fingerprint of a fetched key with:

bash
openssl pkey -pubin -in cosign.pub -outform DER | openssl dgst -sha256

State contains the M2M secret

m2m_client_secret is marked sensitive, which redacts it from CLI and plan output, but not from Terraform state. Because it flows into the aws_cloudformation_stack parameters, the cleartext value is written into every state file and snapshot. Treat state as a secret: use an encrypted, access-restricted backend (for example S3 with SSE-KMS and a locked-down bucket policy); never a plaintext local terraform.tfstate on a shared path.

Sourcing the credentials from a secrets store (AWS Secrets Manager via a data source, SSM Parameter Store, Vault) keeps them out of committed *.tfvars and gives you one place to rotate them. It does not remove the state exposure, so the encrypted-backend requirement stands either way.

Air-gapped / self-hosted templates

Mirror the module archive into your own bucket and repoint source. To also serve the CloudFormation templates from your own bucket, set templates_bucket_name (and shared_config_region) to your mirror and sync the byoc-templates/<version>/ prefix there. This removes the runtime dependency on Nomain's bucket entirely.

Deploy permissions

Because the module's only resource is an aws_cloudformation_stack over the published main.yaml, the deploy principal needs the same permissions as a Marketplace or manual CloudFormation deploy of the same version: CloudFormation creates the identical resources either way. Terraform-specific points:

  • CloudFormation API. terraform apply calls cloudformation:CreateStack / UpdateStack / DeleteStack / DescribeStacks / DescribeStackEvents / GetTemplate with CAPABILITY_NAMED_IAM + CAPABILITY_AUTO_EXPAND. By default CloudFormation then creates the resources with the terraform apply principal's permissions, so that principal needs the full template permission set. Attaching a CloudFormation service role to the stack moves those resource permissions off the Terraform principal (the same option as the CloudFormation flow).
  • State backend. With an S3 + DynamoDB backend, the principal also needs read/write on the state bucket and lock table (Terraform infrastructure, separate from the deploy target). A local backend needs nothing extra.
  • Internal profile needs no route53:* or wafv2:*. The stack creates no Route 53 resources, and with enable_waf = false it creates no WebACL. Leave enable_waf at the default only if the principal can create one.
  • Centralized audit controls. With enable_audit_trail = false the stack needs no cloudtrail:*; with enable_vpc_flow_logs = false it needs no ec2:CreateFlowLogs. Set these only when the org owns those detective controls.
  • Permissions boundary. If your org's SCP denies iam:CreateRole / iam:PutRolePolicy without a boundary, set permissions_boundary_arn; it is stamped on every IAM role the stack creates. It is a ceiling (effective permissions are the intersection with each role's own policy), so it must be permissive enough for the task roles' runtime actions or the services fail at runtime even though the deploy succeeds.

Where to go next