Skip to content
rise.Operator docs
GitHub

Terraform

Two modules under modules/ stand up the topology ADR-0005 describes.

ModuleOwns
rise-awsIAM: the control-plane role and policy, the ECS task execution role, the ECR push role, KMS
rise-ecsEverything that runs: VPC, cluster, Cloud Map, RDS, Secrets Manager, the NLB, Traefik, and the Rise service

They are separate because they have different lifetimes. rise-aws is account-global — its role and policy are named after name, and an install that already uses it for ECR or the RDS extension keeps the one it has. rise-ecs is per-environment, so staging and production in one account are two instances of it against a single rise-aws.

rise-ecs consumes rise-aws’s outputs rather than nesting it. That direction matters: rise-aws scopes every policy by interpolating ARNs from names, never by referencing a resource, which is what keeps the edge one-directional. Both modules must therefore be given the same name, ecs_cluster_name and ssm_parameter_prefix.

module "rise_aws" {
source = "./modules/rise-aws"
name = "rise"
enable_ecr = true
enable_ecs = true
ecs_cluster_name = "rise"
ssm_parameter_prefix = "rise"
ecs_secret_arns = module.rise_ecs.secret_arns_for_execution_role
}
module "rise_ecs" {
source = "./modules/rise-ecs"
name = "rise"
ingress_domain = "rise.example.com"
rise_image_tag = "0.23.0"
admin_email = "ops@example.com"
# The rise-cli image the workload-identity sidecar runs. Required, like the
# control plane's image; normally the same release.
rise_cli_image_tag = "0.23.0"
acme_email = "ops@example.com"
controller_role_arn = module.rise_aws.role_arn
execution_role_arn = module.rise_aws.ecs_execution_role_arn
ecr_push_role_arn = module.rise_aws.push_role_arn
oidc_issuer = "https://id.example.com"
oidc_client_id = "rise"
oidc_client_secret = var.oidc_client_secret
}

Point a wildcard DNS record at the load balancer — dns_records_required says exactly what to create, or pass route53_zone (the zone resource or data source, or { zone_id = "Z..." }) and the module makes them. *.<domain> is required, not optional: projects are served at <project>.<domain> and groups and environments add another label.

Try it without an identity provider by setting deploy_dex = true and dex_admin_password_bcrypt. That runs Dex as an ECS service — a demo, with in-memory storage that loses sessions whenever the task is replaced.

Pass vpc and cluster and the module creates neither:

module "rise_ecs" {
# …
vpc = {
id = "vpc-0123456789abcdef0"
public_subnet_ids = ["subnet-aaa", "subnet-bbb"]
private_subnet_ids = ["subnet-ccc", "subnet-ddd"]
}
cluster = { name = "my-existing-cluster" }
}

Either can be brought independently. Brought subnets are checked against the VPC you named and a brought cluster is checked for ACTIVE, so a mismatch surfaces in terraform plan rather than several minutes into an apply.

A brought cluster keeps whatever capacity providers it already has — the module attaches none, the same way it leaves enable_container_insights alone on a cluster it does not own. If that cluster runs EC2 container instances, set capacity: ec2 on the backend (via RISE_ECS_CAPACITY) so workload services are placed on them, and enable ENI trunking on the instances: workloads run on awsvpc regardless of capacity, so each task takes an ENI and the per-instance attachment limit is what caps tasks per host. The module provisions no Auto Scaling group, launch template, or capacity provider; the instances are yours. Rise’s own control plane, Traefik and Dex always run on Fargate.

By default Rise answers on rise.<ingress_domain> and each project on <project>.<ingress_domain>. control_plane_host moves the control plane, most usefully onto the domain itself:

module "rise_ecs" {
# …
ingress_domain = "apps.platform.internal"
control_plane_host = "apps.platform.internal"
}

which gives:

HostServes
apps.platform.internalRise — the API and web UI
<project>.apps.platform.internalthat project

No extra DNS or certificate work: the module already points both the apex and the wildcard at the load balancer, and dns_records_required lists what to create when you manage DNS yourself. control_plane_host must be ingress_domain or a name under it — anything else is covered by neither of those records, and the plan refuses it rather than producing an install whose console is unreachable.

Two things worth knowing before choosing the apex. A project can never collide with Rise, since projects only ever occupy a label below the domain. And this is unrelated to the Cloud Map namespace: that is internal service discovery between the control plane, Traefik and the database, and nothing addresses a project through it — so an existing namespace like platform.internal is passed as cloud_map_namespace_id and left alone.

The module hands Traefik the real client address, so the load balancer’s view of the client reaches Rise. With alb-acm the ALB appends the client to X-Forwarded-For and Traefik’s web entrypoint trusts it from the public subnets only. With nlb-traefik-acme the NLB’s IP targets see the NLB’s own address, so the target groups enable PROXY protocol v2 and Traefik reads it on web, websecure and ping (the NLB prefixes its health check with the header too), again from the public subnets only. No security group changes: Traefik still admits only the edge group.

The client is the rightmost untrusted entry of X-Forwarded-For, not the first. Behind the ALB the leftmost entries are whatever the client sent, so reading the first hop gives a spoofable value. Rise resolves the real client by walking the header from the right and skipping its trusted proxies; that needs its trusted-proxy list to cover the VPC’s private ranges, and this change must be deployed together with that resolution.

ALB only: Traefik now also keeps X-Forwarded-Host and X-Real-Ip as received. Trusting a peer makes Traefik stop discarding the X-Forwarded-* headers (and X-Real-Ip) it sends. The ALB sets only X-Forwarded-For, -Proto and -Port, so on this path X-Forwarded-Host and X-Real-Ip reach the app exactly as the client wrote them. Kubernetes (nginx) and Docker overwrite both, and so does the NLB mode, where no forwarded header is trusted. An app on ECS behind the ALB must therefore not build URLs from X-Forwarded-Host or read the client from X-Real-Ip: use the Host header (preserved by Traefik) and the rightmost untrusted X-Forwarded-For entry. Rise itself is unaffected. Traefik’s headers middleware could strip them (customRequestHeaders with an empty value), but Traefik can only attach it through dynamic configuration on every router Rise renders, which the module does not do. The difference is recorded in the feature matrix.

Supported, and it takes two settings rather than one. Give each install its own controller class, and confine each Traefik to it:

module "rise_ecs" {
# …
controller_class_name = "team-a"
traefik_constraints = "Label(`rise.dev/controller-class`, `team-a`)"
}

Both are load-bearing, for different reasons. The class scopes Rise’s orphan collector, which deletes any managed service whose deployment it cannot find in its database — so two installs sharing a class each treat the other’s services as orphans and delete them. The constraint scopes Traefik, which otherwise discovers every Rise-labelled container in the cluster and answers for another install’s hosts.

Rise stamps the class into each container’s dockerLabels so the constraint has something to match; ECS resource tags are invisible to Traefik’s ECS provider, and traefik.* is reserved by Traefik and rejected as a constraint key. Anything else you expect that Traefik to route needs the same label.

Leave both at their defaults for the ordinary case of one install per cluster.

tests/e2e/bootstrap is a worked example: every end-to-end run stands a whole install up in a shared cluster under a scope like pr-457, and several runs coexist.

The Rise container runs the config/ecs.yaml that ships in the image (RISE_CONFIG_RUN_MODE=ecs), so the module’s job is filling that file’s environment interpolations. Two of them are the difference between a working install and a puzzling one, and the module derives both from Cloud Map:

  • RISE_AUTH_BACKEND_URL — http://<name>-control-plane.<namespace>:3000. Traefik calls it for every forwardAuth subrequest, so it must be reachable from inside the cluster and must never be the public URL.
  • RISE_TRAEFIK_API_URL — http://<name>-traefik.<namespace>:8080. Readiness comes from Traefik’s serverStatus with no fallback, so without it a project with a health_check never becomes Healthy.

Subnets and security groups are passed as comma-separated strings. The settings loader accepts a YAML list or a comma-separated string precisely so a Terraform output can travel through one environment variable.

Running the control plane somewhere else — EKS, EC2, on-premises — is supported by the rise_task_environment output, which is the same map the module sets on its own task definition, so the two cannot drift.

Configurations the modules will not produce

Section titled “Configurations the modules will not produce”

Each of these is rejected by the backend at startup. The modules fail in terraform plan instead:

RefusedWhy
registry_type of gitlab or jfrogBoth issue short-lived scoped pull tokens that Rise refreshes on the puller’s behalf. ECS re-authenticates at every task start with no refresh hook, so a deploy would succeed and break hours later.
An ECR account other than the caller’sRise writes no ECR repository policy, so cross-account pulls cannot work. The account is taken from aws_caller_identity and is not a variable.
ECR without a push roleThe CLI could not obtain push credentials.
A cpu_architecture other than X86_64/ARM64ECS’s runtimePlatform has exactly two.
An ecr_repo_prefix with no trailing slashIt is concatenated onto the project name literally — "rise" yields risemyapp.
ACME with nat_gateway_mode = "none"VPC endpoints reach AWS services only; an HTTP-01 challenge needs Let’s Encrypt, so the certificate would silently never arrive.
No identity provider of either kindoidc_issuer or deploy_dex is required.
Terminal window
mise run terraform:check

Format-checks, validates and runs the terraform test suites for both modules. The tests plan against the real provider schema with only the identity data sources overridden, so they catch wiring errors terraform validate cannot see; rise-aws’s suite additionally asserts the control-plane policy stays scoped — that iam:PassRole names exactly two roles and carries its service condition, and that no permission the backend never calls creeps back in.

See the module README for the cost table, the single-replica Traefik constraint, and the note about the generated database password living in Terraform state — and how to avoid it with database_url_secret_arn.