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 withsslmode=require, so one image runs against any environment. cloud-initinstalls Docker, logs in to the registry, and runs the image with--restart=always, publishing UDP 1812/1813 and passingPG_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.timerruns every 2 minutes (OnUnitActiveSec=120), so a new org CA reachesca_pathwithin ~2 min. The sync is bidirectional: a deleted CA is pruned on the next run. - Reload — FreeRADIUS re-reads
ca_pathevery 10 minutes (ca_path_reload_interval = 600inmods-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-devon a tag, with thedigilan/platformgroup's Cloudflare variables. - Terraform (
modules/dns) creates the Pages project, custom domain, and the proxied CNAME.
Deployment order for a fresh environment¶
- Build + push the two images; pin their digests in
terraform.tfvars(radius_api_image,radius_freeradius_image). tofu apply -target=module.environment.module.radius_database→ createsradius-<env>(empty).- Run the ordered init scripts
db/radius/01…09against it (see Database). tofu apply→ the API container and FreeRADIUS VM come up against the initialized schema.- Register the Zitadel SPA app (
radius_url+ thezitadel-apps.shprovisioning) and setradius_admin_subject. - Set
radius_subdomain+radius_cors_origins, apply the DNS/Pages resources, then deploy the SPA. - 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.