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¶
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:
Scaleway recreates it and issues a new certificate within about 20 seconds.