Setting Up a New Environment¶
The full procedure, in order: shared resources, the environment, its deployments, the first admin and the first device. The architecture is described in How It Works.
Everything here is named by variable, output or CI variable. Never paste a value from terraform.tfvars, tofu output, .zitadel-apps.json or .zitadel-service-user.json into a ticket, a chat or these docs.
Before you start¶
- Tools:
glab(signed in to the work account),tofu,jq,curl,node/npx(forwrangler). - A Scaleway API key for the Digilan project. An apply that creates IAM applications needs a key with
IAMManager. Both the shared root (the SMTP identity) and every environment root (the OTA reader and publisher identities) create them. Each root's provider pinsorganization_idtoscaleway_organization_id. - A Cloudflare API token that can edit DNS in the main zone and manage Pages projects on the account.
- Maintainer access to the GitLab projects, to set CI variables and run manual jobs.
All commands run from the infra/terraform checkout.
State backend¶
State lives in GitLab's HTTP backend on digilan/infra/terraform. One state per root:
<state> is shared, dev, stg or prod. The token that init writes is short-lived: run init again before every plan or apply session. A plan that fails on the backend with an authentication error means the token has expired, not that the state is broken.
1. Shared resources (once for all environments)¶
environments/shared creates:
- the container registry
- the Zitadel container with its own RDB instance and DNS (
auth.digilan-edge.com) - the shared Postgres instance
digilan-shared - transactional email (TEM) with its SPF, DKIM and DMARC records
-
the OTA ed25519 signing key
-
Create
environments/shared/terraform.tfvarsfromenvironments/tfvars.example. It needs these keys:scaleway_region,scaleway_project_id,scaleway_organization_id,scaleway_access_key,scaleway_secret_key,registry_name,cloudflare_api_token,cloudflare_zone_id, andzitadel_pat(empty at first). init,tofu plan, read the plan,tofu apply.- Sign in to the Zitadel console at
https://auth.digilan-edge.com/ui/consoleas the admin named by the outputzitadel_admin_login. Its password is the outputzitadel_admin_password. - Create a service account with
IAM_OWNERat instance level, generate a personal access token for it, and set it aszitadel_pat. - Apply again. With
zitadel_patset, the shared root:- configures SMTP through TEM
- sets the login policy, with self-registration and external-IdP registration closed on the instance and on every org
- applies the branding
- creates the
nexus-servicemachine user (ORG_USER_MANAGER), writing its id and token toenvironments/shared/.zitadel-service-user.json(git-ignored). The outputs arenexus_service_user_idandnexus_service_pat.
Upload logos by hand in the Zitadel console under Default Settings > Branding. The REST API cannot upload them.
2. Code changes for a new environment name¶
dev, stg and prod are already wired everywhere except where noted. For any other name, make these changes first and merge them:
| Repo | Change |
|---|---|
infra/terraform |
Copy environments/<existing> to environments/<env>, with the defaults in variables.tf: environment, the subdomains, db_name. For relays, also copy relay.tf, relay-zone.tf and relay-variables.tf, from prod (stg has none). |
platform/nexus |
Add build_<env> and deploy_<env> jobs to .gitlab-ci.yml (CONTAINER_NAME: nexus-<env>). Add the portal origin to portalOrigins in internal/app/app.go: CORS is a fixed list, and a portal not in it gets no answers. |
platform/authz |
Add build_<env> and deploy_<env> jobs (CONTAINER_NAME: authz-<env>). |
platform/realtime |
Add [env.<env>] to wrangler.toml (Worker name, custom-domain route, DO binding and migration), and a deploy_<env> job that passes NEXUS_URL. |
platform/portal |
Add build_<env> (its VITE_* values) and deploy_<env> (PAGES_PROJECT_NAME: digilan-portal-<env>) jobs. |
edge/gateway |
Add a .target:<env> block and publish:<env>:<arch> / promote:stable:<env>:<arch> jobs. Only dev and prod are targets today; stg receives no agent releases. |
3. Images¶
The environment root points nexus and authz at registry images (nexus_image, authz_image). Push a tag on platform/nexus and on platform/authz before the first apply, so that their build_<env> jobs publish rg.fr-par.scw.cloud/digilan/nexus:<tag> and authz:<tag>. The tags are dev, stg and prd. The deploy jobs fail or wait until the containers exist; that is expected.
4. Environment root¶
- Create
environments/<env>/terraform.tfvars. It needs these keys:- Scaleway:
scaleway_project_id,scaleway_access_key,scaleway_secret_key(plusscaleway_zoneandscaleway_regionif not the defaults). - Cloudflare:
cloudflare_api_token,cloudflare_zone_id,cloudflare_account_id. - Images:
nexus_image,authz_image. - Shared database, copied from the shared outputs
rdb_shared_*:shared_rdb_instance_id,shared_rdb_host,shared_rdb_port,shared_rdb_admin_user,shared_rdb_admin_password. - Zitadel:
zitadel_pat(the shared PAT, so the root can create the environment's project and apps) andnexus_zitadel_service_token(the shared outputnexus_service_pat, which nexus needs to invite users). - Optional:
nexus_min_scale,nexus_max_scale,nexus_log_level,authz_log_level, and the relay keys in step 8.
- Scaleway:
init, thentofu planandtofu apply. This creates:- the database
<db_name>on the shared instance - the container namespace
digilan-<env>withnexus-<env>andauthz-<env>and their DNS - the JWT keypair in Secret Manager
- the shared keys (
authz_api_key,internal_api_key) - the Pages project
digilan-portal-<env>and its domain - the OTA bucket
digilan-agent-releases-<env>with its reader and publisher identities - the release publisher key
- the database
- During that apply,
null_resource.zitadel_appsrunsscripts/zitadel-apps.sh. The script creates or reconciles the Zitadel projectdigilan-<env>and the portal app (plus the Monsieur Wifi and radius apps when their URLs are set), and writesenvironments/<env>/.zitadel-apps.json. - Plan and apply once more.
NEXUS_ZITADEL_PROJECT_IDis read from.zitadel-apps.jsonat plan time. On the first apply the file did not exist yet, so nexus got an empty project id and rejects every portal token.
To run the script by hand instead, set its inputs (ZITADEL_URL = the shared output zitadel_container_url, ZITADEL_PAT, ZITADEL_HOST, ENVIRONMENT, PORTAL_URL, MRWIFI_URL, RADIUS_URL, OUTPUT_FILE, DEV_MODE) and run scripts/zitadel-apps.sh. The script is idempotent.
Pages project settings (build_config, deployment_configs, source) belong to the portal's deploys. Terraform manages only the project and its name, so a portal deploy does not show up as drift.
5. Realtime¶
-
Store the internal key as the Worker secret. Run this from
environments/<env>:Run it in a
platform/realtimecheckout so wrangler findswrangler.toml. The wrangler env for prod isproduction. -
Push a tag on
platform/realtimeand rundeploy_<env>. It is automatic for dev and manual otherwise.CLOUDFLARE_API_TOKENandCLOUDFLARE_ACCOUNT_IDcome from thedigilan/platformgroup variables. The custom domain inwrangler.tomlcreates<realtime_subdomain>.digilan-edge.com.
6. Nexus, authz, portal¶
- Nexus and authz: run
deploy_<env>in each project's tag pipeline. It is automatic for nexus dev and manual everywhere else. The job looks the container up by name and redeploys it, so it pulls the image again. On start, nexus runs its migrations and seeds the roles into authz. Checkhttps://<nexus>/healthz. - Portal: set the CI variables
<ENV>_ZITADEL_CLIENT_IDand<ENV>_ZITADEL_PROJECT_IDonplatform/portalfrom the outputsportal_client_idandzitadel_project_id. The prefixes areDEV,STGandPRD. Push a tag and rundeploy_<env>. It is automatic for dev and manual for stg and prod.
A change that terraform makes to nexus's environment, such as the relay suffix or a new key, reaches the running container only when the container is redeployed.
7. Agent releases (OTA)¶
Set these on edge/gateway (Settings > CI/CD > Variables). Mask the secrets.
| Scope | Variables | Source |
|---|---|---|
| All targets | OTA_S3_ENDPOINT, OTA_S3_REGION |
s3.<region>.scw.cloud, the region |
| All targets | OTA_SIGNING_PRIVATE_KEY, OTA_SIGNING_PUBLIC_KEY (file type) |
shared outputs ota_signing_private_key, ota_signing_public_key |
| dev | OTA_S3_BUCKET, OTA_PUBLISHER_ACCESS_KEY, OTA_PUBLISHER_SECRET_KEY, NEXUS_URL, RELEASE_PUBLISHER_KEY |
dev outputs ota_bucket_name, ota_publisher_access_key, ota_publisher_secret_key, release_publisher_key, and dev's nexus URL |
| prod | OTA_S3_BUCKET_PROD, OTA_PUBLISHER_ACCESS_KEY_PROD, OTA_PUBLISHER_SECRET_KEY_PROD, NEXUS_URL_PROD, RELEASE_PUBLISHER_KEY_PROD |
the same outputs from prod |
Dev uses the unsuffixed names; every other target uses _<ENV>, as declared in its .target:<env> block. A publish job fails loudly if any of its variables is missing.
The next tag publishes to beta in each target. Promote with the manual promote:stable:<env>:<arch> jobs (Agent Releases). A device takes updates only once it is assigned.
8. Relays¶
Relays and per-gateway names are optional. Without them, gateways run, but there are no published services and no relay link.
- Turn on the nexus side first. Set
relay_zone_suffixin tfvars (for exampler.<env>.digilan-edge.com). Prod's variable refuses dev's suffixr.digilan-edge.comand anything inside it. Apply, then redeploy nexus. Nexus now hasNEXUS_RELAY_ZONE_SUFFIX, the zone id, a DNS token and the ACME account.- For a zone of its own, set
relay_zone_name(needs a Cloudflare token that can create zones), orrelay_dns_zone_idandrelay_dns_api_tokenfor a zone made by hand. - Otherwise the names live in the main zone.
- For a zone of its own, set
-
Then create the relays. Export the package token, then set
relay_countandrelay_subdomain:relay_subdomainmust not berelay, which is dev's.relayd_package_urlpins the relayd version.relay_countabove 0 is refused without the package token.
Apply. For each relay, terraform registers its bootstrap token with nexus through
scripts/relay-token.sh, so nexus must be up and reachable. Terraform then creates the instance, whose cloud-init installs relayd, which enrols. 3. Check Admin > Relays in the portal: each relay is fresh, healthy, and has a tunnel URL.
9. First admin¶
Sign-up is closed, so the first account is made in Zitadel and the first grant in SQL. Nobody exists yet who could grant it through the API.
- In the Zitadel console, create a human user in the organization the portal signs into, with a verified email. Copy its user id: that is the token's
sub. -
Connect to the environment database (DSN from
tofu output -raw database_connection_stringinenvironments/<env>; it is a live secret) and grant platform admin:INSERT INTO acl (subject_id, role_id, target, target_id) VALUES ('<zitadel user id>', 3, 'portal:system', '*') ON CONFLICT DO NOTHING;target_idmust be*: platform admin is the wildcard action onportal:system. -
Sign in to the portal. A second factor can be enrolled; it is not required. Admin appears in the navigation.
10. Organizations and accounts¶
- Organizations: Admin > Organizations > Create. Only a platform admin can create one. Signing in never creates a personal organization.
- Accounts: Account > Team > Invite. Nexus creates the Zitadel user, which sends the invitation email, and the role grant follows on the same screen.
ORG_OWNERmanages the org and its members.ORG_MEMBERoperates its gateways without the console or member management. Inviting requires grant authority: a platform admin, anORG_OWNER, or write/transfer on a device.
A user with no grant who signs in sees the "no organization" screen. That almost always means a missing grant, not a Zitadel problem. See Authz.
11. First device¶
- Admin > Enrollment: create a bootstrap token. Name it, and set a use cap and an expiry if you want. The token is shown once.
- Install the gateway:
- with the provisioning USB, which bakes the token in (Provisioning);
- or on an installed OpenBSD box, with
edge-agent setup --addr https://<nexus> --realtime wss://<realtime> --token <token>.
- The device appears in Admin > Devices in the holding zone. Assign it to an organization, and from then on it is managed and receives agent updates.
Gotchas¶
- Re-run
glab opentofu initbefore each plan or apply session. The backend token it stores expires. - Creating IAM applications needs
IAMManageron the Scaleway key. Otherwise the apply fails with "insufficient permissions". - Apply twice on a new environment. The first apply writes
.zitadel-apps.json; the second gives nexus the project id. .zitadel-apps.jsonand.zitadel-service-user.jsonare local files, not state. They are never committed. Keep a copy outside the repo (~/.config/digilan/<env>-zitadel-apps.json, mode 0600) and copy it in before planning. Without them a plan sets the Zitadel ids to empty strings. Re-running the script recovers the ids but not the Monsieur Wifi client secret, which Zitadel shows only once: if it is lost, regenerate the secret ofmrwifi-<env>in the Zitadel console.- A relay booted before nexus offers certificates reports no tunnel URL, so gateways get no rung 3 from it. relayd checks again every 60 minutes.
systemctl restart relaydon the relay (serial console), or rebooting the instance (scw instance server reboot <id>), gets it at once. Avoid this by settingrelay_zone_suffixand redeploying nexus before raisingrelay_count. relay_countdefaults to 0. Any plan or apply run against a tfvars that does not set it plans to destroy every relay. Read the plan.- Nexus CORS is in code. A new portal hostname must be added to
internal/app/app.go. - Serverless containers scale to zero. The first request after idle is slow, and nexus's hourly sweep (log pruning, relay reaping) runs only while an instance is up.