> ## Documentation Index
> Fetch the complete documentation index at: https://support.lilt.com/llms.txt
> Use this file to discover all available pages before exploring further.

# Provision AWS Infrastructure with Terraform

The EKS install package ships a Terraform module, `lilt-aws-env`, that provisions the AWS infrastructure LILT runs on. This article covers what the module creates, how to configure it, and how to map its outputs into the installer's configuration.

You do not need access to any LILT source repository. The module is a plain local module inside the tarball.

## The Two Layers

An EKS deployment has two layers, and they run in order:

1. **`lilt-aws-env`** provisions the AWS resources: subnets, the EKS cluster and its node groups, the RDS database, the S3 bucket, the SQS queue, and the IAM roles the workloads assume.

2. **`install-lilt-eks.sh`** deploys LILT onto that cluster with Helm. See [Install System (AWS EKS)](/kb/install-system-aws-eks).

If you already have a cluster, or you provision AWS resources with your own tooling, skip this article and see [Install on an existing EKS cluster](/kb/install-aws-existing-cluster) instead.

## Prerequisites

### Tools

| Tool      | Minimum version                         | Notes                                                                                                                                           |
| --------- | --------------------------------------- | ----------------------------------------------------------------------------------------------------------------------------------------------- |
| Terraform | 1.10                                    | The module declares `required_version = ">= 1.10"`. `terraform init` fails on an earlier version. OpenTofu of an equivalent version also works. |
| AWS CLI   | 2.x                                     | Must resolve credentials for the target account. Version 1 is not sufficient.                                                                   |
| `kubectl` | Matching the cluster Kubernetes version |                                                                                                                                                 |
| Helm      | 4.0                                     | The installer requires Helm 4.                                                                                                                  |

### AWS

* An IAM role or user that can create EKS, RDS, S3, SQS, IAM, KMS, and Route53 resources in the target account.

* A VPC with at least one NAT gateway. The module creates subnets inside your VPC. It does not create the VPC.

* Two non-overlapping CIDR blocks available in that VPC for the module's private subnets. A `/22` in each of two availability zones is a reasonable starting point.

* A Route53 hosted zone for your base domain.

## What the Module Creates

| Area                 | Resources                                                                                                                                                                         |
| -------------------- | --------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
| Networking           | Private subnets in two availability zones, a route table pointing at your NAT gateway, and a network ACL that isolates east-west traffic.                                         |
| EKS                  | The cluster with a private API endpoint, the VPC CNI, CoreDNS, kube-proxy, EBS CSI driver and Pod Identity agent add-ons, and the `system` and `application` managed node groups. |
| GPU                  | Four GPU node groups by default. See [GPU node groups](#gpu-node-groups).                                                                                                         |
| Database             | An RDS MySQL instance, KMS-encrypted, with an AWS-managed master password held in Secrets Manager. The password is never written to Terraform state.                              |
| Storage and queueing | A versioned, KMS-encrypted S3 bucket and an SQS queue subscribed to its events.                                                                                                   |
| IAM                  | Roles for the application workloads, Cluster Autoscaler, ExternalDNS, cert-manager, and the AWS Load Balancer Controller.                                                         |
| DNS                  | A Route53 ownership record for ExternalDNS.                                                                                                                                       |
| Optional             | A bastion host, VPC endpoints, an ECR repository, Karpenter prerequisites, and an ACME wildcard certificate.                                                                      |

## Instantiate the Module

Create a `terraform/` directory next to the extracted tarball, so that the relative module path resolves:

```
/opt/lilt/
├── install-lilt-eks.sh
├── terraform-modules/
│   └── lilt-aws-env/
└── terraform/
    └── main.tf          # you create this
```

Every variable without a default is required. The following example sets the required variables plus the ones most deployments tune:

```hcl theme={null}
module "lilt_env" {
  source = "../terraform-modules/lilt-aws-env"

  # Identity. Every resource name derives from prefix.
  prefix      = "acme-prod"
  subdomain   = "prod"
  base_domain = "example.com"

  # Networking, from your own VPC layer.
  vpc_id         = "vpc-0abc123def456789a"
  vpc_cidr       = "10.20.0.0/16"
  nat_gateway_id = "nat-0abc123def456789a"
  az_a           = "us-east-1a"
  az_b           = "us-east-1b"
  module_cidr_a  = "10.20.80.0/22"
  module_cidr_b  = "10.20.84.0/22"
  shared_cidrs   = ["10.20.0.0/24"]

  # Who administers the cluster. Omitting yourself locks you out.
  eks_cluster_admin_arns = {
    "operators" = "arn:aws:iam::111122223333:role/lilt-operators"
  }

  # DNS.
  r53_hosted_zone_id = "Z0EXAMPLE1234567890"

  # Database. Set rds_multi_az to true for production.
  rds_instance_class    = "db.r6g.large"
  rds_allocated_storage = 100
  rds_multi_az          = true

  # Node sizing.
  eks_application_instance_types = ["r6i.2xlarge", "r5.2xlarge"]
  eks_application_min_size       = 2
  eks_application_max_size       = 8
  eks_application_volume_size    = 300

  # Run the installer from inside the VPC. The EKS endpoint is private.
  enable_bastion = true
}
```

Then run:

```bash theme={null}
cd terraform
terraform init
terraform plan
terraform apply
```

<Warning>
  `eks_bootstrap_creator_admin` has no default, and AWS treats an unset value as `false`. The cluster is then reachable only by the principals listed in `eks_cluster_admin_arns` and by the bastion. If you leave both empty, every `kubectl` call returns a 401 and there is no way back in. Confirm that `eks_cluster_admin_arns` contains a role you can assume before you apply.
</Warning>

## Required Variables

| Variable                 | Type         | Purpose                                                                          |
| ------------------------ | ------------ | -------------------------------------------------------------------------------- |
| `prefix`                 | string       | Prefix for every resource name. The installer's `PREFIX` variable must match it. |
| `vpc_id`                 | string       | The VPC to create subnets in.                                                    |
| `vpc_cidr`               | string       | That VPC's CIDR block.                                                           |
| `nat_gateway_id`         | string       | The NAT gateway the private route table points at.                               |
| `shared_cidrs`           | list(string) | CIDRs the network ACL allows through.                                            |
| `eks_cluster_admin_arns` | map(string)  | Name to IAM role ARN. Each role gets cluster-admin.                              |
| `r53_hosted_zone_id`     | string       | The Route53 zone for `base_domain`.                                              |

## Commonly Tuned Variables

| Variable                         | Default                        | Notes                                                                                 |
| -------------------------------- | ------------------------------ | ------------------------------------------------------------------------------------- |
| `subdomain`                      | `prefix`                       | Hostnames become `<name>-<subdomain>.<base_domain>`.                                  |
| `base_domain`                    | `""`                           | Your base domain, for example `example.com`.                                          |
| `az_a`, `az_b`                   | The region's first two zones   | Set these explicitly if you care which zones you land in.                             |
| `module_cidr_a`, `module_cidr_b` | `""`                           | Required when `create_subnets` is `true`, which is the default.                       |
| `create_subnets`                 | `true`                         | Set to `false` and supply `existing_subnet_ids` to bring your own subnets.            |
| `eks_version`                    | `"1.35"`                       |                                                                                       |
| `eks_system_instance_types`      | `["m6i.large","m6i.xlarge"]`   | Runs kube-system, Istio, and monitoring.                                              |
| `eks_application_instance_types` | `["r6i.2xlarge","r5.2xlarge"]` | Qdrant alone requests 4 CPU, which does not fit a 4-vCPU node's allocatable capacity. |
| `eks_application_volume_size`    | `100`                          | Raise this. See the warning that follows.                                             |
| `rds_instance_class`             | `"db.r6g.large"`               |                                                                                       |
| `rds_engine_version`             | `"8.0"`                        | MySQL 8.0 reaches the end of RDS standard support on 31 July 2026. Consider `"8.4"`.  |
| `rds_multi_az`                   | `false`                        | Set to `true` for production.                                                         |
| `iam_auth_method`                | `"pod_identity"`               | See [Workload identity](#workload-identity).                                          |
| `enable_bastion`                 | `false`                        |                                                                                       |
| `enable_cluster_autoscaler`      | `true`                         |                                                                                       |
| `enable_cert_manager`            | `false`                        | The IAM role only. The controller is installed by `install-lilt-eks.sh`.              |
| `enable_external_dns`            | `false`                        |                                                                                       |
| `enable_ecr`                     | `false`                        | Creates one ECR repository named after `prefix`.                                      |
| `enable_vpc_endpoints`           | `false`                        | Set to `true` for a deployment with no route to the public internet.                  |

<Warning>
  Raise `eks_application_volume_size` above its `100` GB default. The neural services pull trained-data images that are tens of gigabytes compressed and roughly double that unpacked, so 100 GB cannot hold one. The pull fails part way through with `no space left on device`. A 300 GB volume is a safe starting point.
</Warning>

## GPU Node Groups

`eks_extra_node_groups` defaults to the four GPU pools LILT needs. LILT spreads its GPU workloads across all four, and a partial set fails in a way that does not name itself: `batch-worker-gpuv4` stays `Pending`, and the neural services block on the artifacts endpoint it serves rather than reporting a GPU shortage.

| Pool           | Instance type   | GPUs     | Serves                                  |
| -------------- | --------------- | -------- | --------------------------------------- |
| `gpu-multi`    | `g4dn.12xlarge` | 4 x T4   | AI Review and LILT Create               |
| `gpu-vram`     | `g6e.4xlarge`   | 1 x L40S | OCR and the larger single-GPU models    |
| `gpu-standard` | `g6.12xlarge`   | 4 x L4   | Translation, VMF, and the batch workers |
| `gpu-whisper`  | `g4dn.2xlarge`  | 1 x T4   | Speech recognition                      |

Each pool carries the `nvidia.com/gpu` taint and the `capability: gpu` label that the LILT charts select on.

Four warm GPU nodes are expensive. To let the autoscaler bring each pool up on demand, set `min_size` to `0` for the pools you want cold. Cluster Autoscaler is enabled by default, which is what makes a group at zero discoverable. For an infrastructure-only deployment with no GPU workloads, set `eks_extra_node_groups = {}`.

<Warning>
  Widen `availability_zones` on each GPU pool. The default is empty, which places every pool in the cluster's own two zones. GPU instance types are frequently capacity-constrained per zone: a `g6.12xlarge` request can return `InsufficientInstanceCapacity` in both zones, and the group has nowhere else to go. Listing every zone in the region for each pool avoids this. The field is replace-forcing, so set it before your first apply.
</Warning>

Setting `eks_extra_node_groups` replaces the default map wholesale rather than merging into it, so list every pool you want, not only the one you are changing. The block below is the shipped default with `availability_zones` added to each pool. Substitute the zones your region offers.

```hcl theme={null}
locals {
  # Every zone in the region, so a GPU pool has somewhere else to go when one
  # zone is out of capacity.
  gpu_azs = ["us-east-1a", "us-east-1b", "us-east-1c", "us-east-1d", "us-east-1f"]

  gpu_labels = {
    "node-type"              = "gpu"
    "capability"             = "gpu"
    "nvidia.com/gpu.present" = "true"
  }

  gpu_taints = [{ key = "nvidia.com/gpu", value = "true", effect = "NO_SCHEDULE" }]
}

eks_extra_node_groups = {
  # 4x T4, 48 vCPU, 192 GiB - AI Review and LILT Create.
  "gpu-multi" = {
    instance_types     = ["g4dn.12xlarge"]
    min_size           = 1
    max_size           = 2
    volume_size        = 2000
    gpu                = true
    cluster_autoscaler = true
    availability_zones = local.gpu_azs
    labels             = merge(local.gpu_labels, { "gpu-class" = "multi" })
    taints             = local.gpu_taints
  }

  # 1x L40S, 48 GiB VRAM - OCR and the larger single-GPU models.
  "gpu-vram" = {
    instance_types     = ["g6e.4xlarge"]
    min_size           = 1
    max_size           = 2
    volume_size        = 2000
    gpu                = true
    cluster_autoscaler = true
    availability_zones = local.gpu_azs
    labels             = merge(local.gpu_labels, { "gpu-class" = "vram" })
    taints             = local.gpu_taints
  }

  # 4x L4, 48 vCPU, 192 GiB - translation, vmf and the batch workers.
  "gpu-standard" = {
    instance_types     = ["g6.12xlarge"]
    min_size           = 1
    max_size           = 2
    volume_size        = 2000
    gpu                = true
    cluster_autoscaler = true
    availability_zones = local.gpu_azs
    labels             = merge(local.gpu_labels, { "gpu-class" = "standard" })
    taints             = local.gpu_taints
  }

  # 1x T4, 8 vCPU, 32 GiB - speech recognition.
  "gpu-whisper" = {
    instance_types     = ["g4dn.2xlarge"]
    min_size           = 1
    max_size           = 2
    volume_size        = 1000
    gpu                = true
    cluster_autoscaler = true
    availability_zones = local.gpu_azs
    labels             = merge(local.gpu_labels, { "gpu-class" = "whisper" })
    taints             = local.gpu_taints
  }
}
```

The `gpu-class` label is what the LILT charts select on, so each pool needs its own value. The root volumes are large because the neural services pull trained-data images of tens of gigabytes.

To let the autoscaler bring a pool up only when something needs it, set that pool's `min_size` to `0`. `cluster_autoscaler = true` is what makes a group at zero discoverable.

## Workload Identity

`iam_auth_method` selects how pods obtain AWS credentials.

| Value                    | Mechanism                                                                                                                 |
| ------------------------ | ------------------------------------------------------------------------------------------------------------------------- |
| `pod_identity` (default) | EKS Pod Identity. The module creates a Pod Identity association for each service account and no OIDC provider.            |
| `irsa`                   | IAM Roles for Service Accounts. The module creates an OIDC provider and roles that trust `sts:AssumeRoleWithWebIdentity`. |

Use `pod_identity` for a new environment. Use `irsa` only when something in your environment already depends on OIDC federation.

<Warning>
  **The module and the installer default to different values. Set both.** The module's `iam_auth_method` defaults to `pod_identity`, but the installer's `IAM_AUTH_METHOD` defaults to `irsa`. Left at their defaults, the module creates Pod Identity associations and no OIDC provider, and the installer then annotates the service accounts with `eks.amazonaws.com/role-arn` anyway. The annotation wins over the association, the SDK looks for a web identity provider that the account does not have, and every S3 call fails with a credentials error that mentions none of this.

  Set `IAM_AUTH_METHOD` in `install.env` to the same value as the module's `iam_auth_method`:

  ```bash theme={null}
  # install.env - must match the module's iam_auth_method
  IAM_AUTH_METHOD=pod_identity
  ```
</Warning>

Under `pod_identity`, leave the service accounts unannotated. The installer does that for you once `IAM_AUTH_METHOD` is set correctly.

## Read the Outputs

```bash theme={null}
terraform output -json
```

| Output                        | Maps to                                                     |
| ----------------------------- | ----------------------------------------------------------- |
| `cluster_name`                | `CLUSTER_NAME`                                              |
| `bucket_name`                 | `S3_BUCKET`                                                 |
| `queue_url`                   | `SQS_QUEUE_URL`                                             |
| `subdomain`                   | `SUBDOMAIN` is the label before the first dot.              |
| `app_workload_role_arn`       | `APP_WORKLOAD_ROLE_NAME`, the role name from the ARN.       |
| `cluster_autoscaler_role_arn` | `CLUSTER_AUTOSCALER_ROLE_NAME`, the role name from the ARN. |
| `cert_manager_role_arn`       | `CERT_MANAGER_ROLE_NAME`, the role name from the ARN.       |
| `rds_master_secret_arn`       | Read by `install-rds-secrets.sh`. No configuration needed.  |
| `rds_endpoint`, `rds_port`    | Informational.                                              |
| `ecr_repository_url`          | The registry to seed, when `enable_ecr` is `true`.          |
| `bastion_instance_id`         | The instance to run the installer from.                     |

<Note>
  The install scripts find IAM roles by a substring match on the role **name**, not the ARN. Take the part after the last slash: `arn:aws:iam::111122223333:role/acme-prod-app-workload` becomes `acme-prod-app-workload`.
</Note>

### The PREFIX Shortcut

If you set `PREFIX` in `install.env` to the same value as the module's `prefix`, the installer derives these names and you can leave them unset:

* `CLUSTER_NAME` becomes `<PREFIX>-eks`
* `RDS_IDENTIFIER` becomes `<PREFIX>-rds`
* `S3_BUCKET` becomes `<PREFIX>-s3`
* `SQS_QUEUE_URL` becomes `https://sqs.<AWS_REGION>.amazonaws.com/<account>/<PREFIX>-sqs`
* `APP_WORKLOAD_ROLE_NAME` becomes `<PREFIX>-app-workload`
* `CLUSTER_AUTOSCALER_ROLE_NAME` becomes `<PREFIX>-cluster-autoscaler`
* `CERT_MANAGER_ROLE_NAME` becomes `<PREFIX>-cert-manager-role`

## Certificate Ownership

Both the module and the installer can obtain a TLS certificate, and both write the same `lilt-com-tls` secret. Pick one.

* Set `enable_tls_certificate = true` in the module to run an ACME DNS-01 challenge against your Route53 zone and store the certificate in Vault. Leave `ENABLE_CERT_MANAGER=false` in `install.env`.

* Or leave the module's flag at `false` and set `ENABLE_CERT_MANAGER=true` in `install.env`, which makes cert-manager the permanent owner of the secret.

Enabling both makes the two owners compete for the same Let's Encrypt identifier set, which is rate-limited to five certificates per 168 hours. You lose the week.

<Warning>
  Whichever path you choose, the certificate must cover every LILT hostname. The hostnames are siblings, not subdomains, so a `*.<subdomain>.<base_domain>` wildcard does not cover them. The full list is in `install.env.example`, and it includes `core-api-`, `connectors-api-`, and `wso2-` prefixed names. If you miss one, browsers look correct while API clients fail hostname verification, which reads as a TLS bug rather than as a missing name.
</Warning>

## Version Pinning

The module is vendored into the tarball at a single commit. Read `terraform-modules/lilt-aws-env/PROVENANCE.md` in the extracted tarball to see which commit shipped with your release.

To source the module from a location of your own, copy the `terraform-modules/lilt-aws-env` directory wherever suits your layout and point `source` at it. Pin by commit rather than by branch so that a given apply is reproducible.

## Known Constraints

* `eks_service_cidr` defaults to empty, which lets AWS pick `10.100.0.0/16`. Changing this value later replaces the cluster. Pin it before your first apply if you might ever need a different range.

* `enable_karpenter = true` creates the AWS prerequisites, the IAM role and the SQS interruption queue. It does not install the Karpenter controller.

* Let's Encrypt limits you to five certificates per identifier set per 168 hours. Repeated rebuilds of the same environment exhaust that quota.

## Next Steps

* [Install from a bastion](/kb/install-from-a-bastion) — the module gives the cluster a private API endpoint, so the installer runs from inside the VPC.
* [Install System (AWS EKS)](/kb/install-system-aws-eks) — configure `install.env` and run the install.
