Skip to content

Deploying Radius

radius-ctrl has three deployable pieces plus a database, all provisioned from infra/terraform and gated on per-piece variables (empty = not deployed):

Runtime What Gate (tfvars)
Scaleway Serverless Container radius API (.NET, port 8080) radius_api_image
Scaleway Instance (VM, DEV1-S) FreeRADIUS 3.2.10, UDP 1812/1813 radius_freeradius_image
Cloudflare Pages radius SPA (digilan-radius-<env>) at <env>.radius.digilan-edge.com radius_subdomain
PostgreSQL radius-<env> on the shared RDB created with radius_api_image

Images live in the shared registry (rg.fr-par.scw.cloud/digilan/{radius-api,radius-freeradius}) and are referenced from environments/<env>/terraform.tfvars.

radius-ctrl is deployed to dev only. Its CI runs on a tag (or a merge request, checks only): a tag builds both images as :dev, deploys the API container radius-api-dev and the SPA to digilan-radius-dev. There are no stg or prod jobs.

API — environment variables

Set on the serverless container by modules/environment/main.tf:

Variable Required Description
ConnectionStrings__Default yes Npgsql keyword DSN to radius-<env> (SSL Mode=Require;Trust Server Certificate=true). Secret.
Pki__MasterKey yes Base64 AES-256 key encrypting CA private keys at rest. Terraform random_id. Secret. The app throws on boot if unset.
Zitadel__Authority for prod auth Zitadel issuer URL. When set, tokens are validated via JWKS; when empty, the local HS256 dev key is used instead.
Zitadel__Audience with Authority The shared Zitadel project id (token aud).
Zitadel__ServiceToken no PAT of the nexus-service Zitadel user. Used to resolve member names and to create invited accounts; without it inviting is refused. Secret.
Bootstrap__AdminSubject no A Zitadel sub seeded as PLATFORM_ADMIN on startup (the initial operator), from radius_admin_subject.
Pki__ServerTrustCaPem no The platform CA that signs the FreeRADIUS server certificate, installed by enrollment profiles. Empty when there is no FreeRADIUS VM.
Provisioning__Token no Shared secret the FreeRADIUS host presents to pull the trust bundle. Empty disables the endpoint. Secret.
Cors__Origins no Comma-separated origins allowed to call the API (the SPA origin). Empty = no cross-origin.
Auth__DevAuth no Must be false outside local dev.
ASPNETCORE_ENVIRONMENT yes Production.

Health: /health/live (no dependencies) and /health/ready (checks the database). min_scale = 0, so the first request after idle pays a cold start (container spin-up + one-time JWKS fetch).

FreeRADIUS — the VM

FreeRADIUS can't be serverless (UDP, always-resident), so it runs on a small Scaleway instance created by modules/environment/main.tf:

  • A custom image (freeradius/Dockerfile) overlays the config (SQL module, dynamic clients, EAP, dev certs) onto the pinned FreeRADIUS base. DB credentials are not baked in — the SQL module reads them from the environment ($ENV{PG_*}) via a libpq conninfo string with sslmode=require, so one image runs against any environment.
  • cloud-init installs Docker, logs in to the registry, and runs the image with --restart=always, publishing UDP 1812/1813 and passing PG_HOST/PG_PORT/PG_DB/PG_USER/PG_PASSWORD.
  • A reserved flexible IP survives instance replacement; a security group opens UDP 1812/1813 and TCP 22.

Re-provisioning the VM

Changing radius_freeradius_image updates the instance user_data in place — cloud-init does not re-run, so the box keeps the old container. To pull a new image: ssh root@<ip> 'docker rm -f freeradius; docker rmi <image>; cloud-init clean --logs; reboot' (removing the image forces a fresh pull of the mutable :dev tag). The reboot + cloud-init takes several minutes, during which SSH is unavailable.

EAP-TLS trust: server cert + ca_path sync

FreeRADIUS presents a server certificate signed by the platform CA (provisioned by terraform); devices are told to trust that CA at enrollment. Client certificates are validated against the org CAs, which a systemd timer (freeradius-ca-sync.timer running ca-sync.sh) pulls from the API's provisioning/trust-bundle into the host ca_path volume (/opt/freeradius/capath → container /certs/capath). The Provisioning:Token on the API and the token in /etc/freeradius-sync.env on the VM must match. FreeRADIUS refuses to start if any config file is group/world-writable — the image chmods the config tree to guard against loose CI-checkout permissions.

Propagation delay for a new CA

A brand-new CA is not usable for EAP-TLS instantly. Two timers gate it:

  • Sync — freeradius-ca-sync.timer runs every 2 minutes (OnUnitActiveSec=120), so a new org CA reaches ca_path within ~2 min. The sync is bidirectional: a deleted CA is pruned on the next run.
  • Reload — FreeRADIUS re-reads ca_path every 10 minutes (ca_path_reload_interval = 600 in mods-enabled/eap), which also governs how fast a revocation (updated CRL) takes effect.

So a certificate issued from a just-created CA may take up to ~12 minutes before the server accepts it. Certificates from CAs that already existed work immediately. To make new CAs usable sooner, lower ca_path_reload_interval (and/or OnUnitActiveSec) — at the cost of more frequent reloads — or have ca-sync.sh signal FreeRADIUS to reload when it writes a new CA.

To force it immediately (e.g. right after creating a CA for a demo): ssh root@<ip> 'systemctl start freeradius-ca-sync.service && docker restart freeradius'.

SPA — Cloudflare Pages

  • Built with Vite; config is baked in at build time: VITE_API_URL (the API), VITE_ZITADEL_AUTHORITY, VITE_ZITADEL_CLIENT_ID, VITE_ZITADEL_PROJECT_ID.
  • public/_redirects (/* /index.html 200) provides the SPA fallback for deep links and /callback.
  • Deploy: CI runs wrangler pages deploy dist --project-name=digilan-radius-dev on a tag, with the digilan/platform group's Cloudflare variables.
  • Terraform (modules/dns) creates the Pages project, custom domain, and the proxied CNAME.

Deployment order for a fresh environment

  1. Build + push the two images; pin their digests in terraform.tfvars (radius_api_image, radius_freeradius_image).
  2. tofu apply -target=module.environment.module.radius_database → creates radius-<env> (empty).
  3. Run the ordered init scripts db/radius/01…09 against it (see Database).
  4. tofu apply → the API container and FreeRADIUS VM come up against the initialized schema.
  5. Register the Zitadel SPA app (radius_url + the zitadel-apps.sh provisioning) and set radius_admin_subject.
  6. Set radius_subdomain + radius_cors_origins, apply the DNS/Pages resources, then deploy the SPA.
  7. Smoke-test: radtest <user> <pw> <nas-ip> 0 <secret> against the VM → Access-Accept; the SPA login → Zitadel → API.

Portal integration

The portal dashboard shows a Radius card when VITE_RADIUS_URL is set (per-env in platform/portal/.gitlab-ci.yml), linking to the SPA.