Skip to content

OpenTofu

Infrastructure for every environment is in infra/terraform (GitLab project digilan/infra/terraform). Providers: Scaleway ~> 2.63.0, Cloudflare ~> 5.11.0. State: GitLab's HTTP backend, one state per environment.

Layout

infra/terraform/
├── environments/
│   ├── shared/   # registry, shared RDB, Zitadel (+ its RDB), mail,
│   │             # OTA signing key, nexus service user, login policy
│   ├── dev/      # one stack per environment, plus relay fleet
│   ├── stg/
│   └── prod/     # plus relay fleet and relay zone
├── modules/
│   ├── environment/  # namespace, nexus, authz, database, OTA, Zitadel apps,
│   │                 # optional radius-ctrl and Monsieur Wifi
│   ├── container/    # generic Scaleway serverless container
│   ├── database/     # one database on the shared RDB instance
│   ├── dns/          # Cloudflare Pages projects + DNS records
│   ├── relay/        # relay instances, IPs, host records, bootstrap tokens
│   └── relay-zone/   # optional zone of its own for per-gateway names
└── scripts/          # zitadel-apps.sh, zitadel-nexus-service.sh,
                      # relay-token.sh, relay-replace.sh

shared is applied before any environment. Each environment then gets its own database on the shared RDB, container namespace, nexus and authz containers, OTA bucket and keys, Pages project, DNS records, Zitadel project and internal API keys.

Initialising a state

cd infra/terraform/environments/<env>
glab opentofu init -R 'digilan/infra/terraform' <env> -- -reconfigure

<env> is the state name: shared, dev, stg or prod. Re-run the same command after backend errors; -reconfigure renegotiates the HTTP backend without touching state.

Applying

cd infra/terraform/environments/<env>
tofu plan -out /tmp/<env>.tfplan
tofu apply /tmp/<env>.tfplan

Read the plan before applying prod. Targeted applies are the norm for relays; see Replacing a Relay.

Configuration

Each environment reads terraform.tfvars in its directory (template: environments/tfvars.example). Never commit it. Notable inputs:

Variable Purpose
scaleway_project_id, scaleway_organization_id, scaleway_access_key, scaleway_secret_key Scaleway credentials; the organization is pinned on the provider
cloudflare_api_token, cloudflare_zone_id, cloudflare_account_id Cloudflare
shared_rdb_instance_id, shared_rdb_host, shared_rdb_port, shared_rdb_admin_user, shared_rdb_admin_password The shared RDB, copied from the shared outputs
db_name This environment's database name
nexus_image, authz_image Image references (:dev, :stg, :prd)
zitadel_url, zitadel_pat, nexus_zitadel_service_token Zitadel and the nexus service user's PAT
relay_count, relay_zone_suffix, relayd_package_url Relay fleet (dev and prod). relayd_package_token is supplied as TF_VAR_relayd_package_token, not in tfvars
radius_api_image, radius_freeradius_image, radius_subdomain radius-ctrl; empty means not deployed

Defaults for subdomains live in each environment's variables.tf. In prod, nexus_subdomain is prod.nexus while portal, authz and realtime have no prefix. If you change it, change VITE_NEXUS_URL in the portal CI and NEXUS_URL in the realtime CI to match.

Secrets

Secret Source Injected as
internal_api_key (per env) random_password in modules/environment NEXUS_INTERNAL_API_KEY; Worker secret INTERNAL_API_KEY
authz_api_key (per env) random_password NEXUS_AUTHZ_API_KEY; AUTHZ_API_KEY on authz
Release publisher key (per env) random_password, kept in Secret Manager NEXUS_RELEASE_PUBLISHER_KEY; gateway CI RELEASE_PUBLISHER_KEY[_PROD]
OTA reader / publisher keys (per env) Scoped IAM applications, one-year expiry NEXUS_OTA_S3_*; gateway CI OTA_PUBLISHER_*
OTA signing key (shared) tls_private_key in environments/shared/ota-signing.tf, kept in Secret Manager Gateway CI OTA_SIGNING_PRIVATE_KEY / OTA_SIGNING_PUBLIC_KEY
Relay tunnel key, ACME account key (per env) Generated, kept in state NEXUS_RELAY_TUNNEL_KEY, NEXUS_ACME_ACCOUNT_KEY
JWT keypair (per env) Secret Manager digilan-jwt-*-<env> NEXUS_JWT_PRIVATE_KEY
CLOUDFLARE_API_TOKEN, CLOUDFLARE_ACCOUNT_ID GitLab group variables on digilan/platform Portal and realtime deploys

The signing key, the JWT keys and the publisher key have prevent_destroy and ignore changes to their value: rotating one is a deliberate operation, never a plan diff.

Set the realtime Worker secret without a trailing newline, using the Worker environment name (dev, stg or production):

tofu -chdir=infra/terraform/environments/<env> output -raw internal_api_key \
  | npx wrangler secret put INTERNAL_API_KEY --env <dev|stg|production>

tofu output -raw prints no newline; do not pipe it through echo.

Recovering a container_domain in error

If a scaleway_container_domain latches into error (usually because the container was in error when the domain was linked), taint it and apply once the container is ready:

tofu taint 'module.environment.module.<svc>.scaleway_container_domain.this[0]'
tofu apply

Scaleway recreates it and issues a new certificate within about 20 seconds.