Terraform
Two modules under modules/
stand up the topology ADR-0005
describes.
| Module | Owns |
|---|---|
rise-aws | IAM: the control-plane role and policy, the ECS task execution role, the ECR push role, KMS |
rise-ecs | Everything 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.
Minimal install
Section titled “Minimal install”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.
Into a cluster you already run
Section titled “Into a cluster you already run”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.
Where Rise itself answers
Section titled “Where Rise itself answers”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:
| Host | Serves |
|---|---|
apps.platform.internal | Rise — the API and web UI |
<project>.apps.platform.internal | that 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.
Client addresses
Section titled “Client addresses”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.
Several installs in one cluster
Section titled “Several installs in one cluster”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.
What it configures
Section titled “What it configures”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’sserverStatuswith no fallback, so without it a project with ahealth_checknever 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:
| Refused | Why |
|---|---|
registry_type of gitlab or jfrog | Both 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’s | Rise 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 role | The CLI could not obtain push credentials. |
A cpu_architecture other than X86_64/ARM64 | ECS’s runtimePlatform has exactly two. |
An ecr_repo_prefix with no trailing slash | It 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 kind | oidc_issuer or deploy_dex is required. |
Verifying without applying
Section titled “Verifying without applying”mise run terraform:checkFormat-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.
Cost and caveats
Section titled “Cost and caveats”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.