Declarative State¶
services.gradient.state lets you declare users, organizations, projects, caches, and API keys in Nix. Gradient reconciles this state on every startup.
When settings.deleteState = true (default), entities that are removed from state are also deleted from the database. Set it to false to make them editable by users in the frontend instead.
State-Managed Resources¶
Users, organizations, and caches created by the NixOS module configuration carry managed = true. The API rejects mutations and deletions of these records with 403 Forbidden. This allows declarative configuration to be the source of truth without Gradient's UI overwriting it.
How the UI surfaces this¶
Managed-resource and read-only access show up consistently across the dashboard:
- State-managed resources show form fields and write buttons (Save, Delete, etc.) as visible but disabled, with a hover tooltip ("Managed by Nix — edit via declarative config") so you can see what the resource looks like and what actions exist, you just can't trigger them here. Update the Nix config instead.
- Read-only access (your organization role doesn't grant write permission) shows form fields as disabled with a "You have read-only access" tooltip; write buttons are hidden entirely — they don't exist for you. Contact an organization admin to make changes.
The pages themselves are always navigable. A state-managed cache's upstreams subpage, for example, is reachable so you can see what upstreams are configured — only the Add / Edit / Delete controls are gated.
Users¶
services.gradient.state.users = {
alice = {
name = "Alice";
email = "alice@example.com";
password_file = "/run/secrets/alice-password";
email_verified = true;
superuser = false;
};
# OIDC-only account: no local password. The first OIDC login matching
# this email/username claims the account.
bob = {
name = "Bob";
email = "bob@example.com";
# password_file omitted (defaults to null)
};
};
The password file must contain an argon2id PHC hash (a string starting
with $argon2id$…). Generate one with the gradient CLI:
# Prompts for the password twice (without echo) and prints the PHC hash
gradient hash > /run/secrets/alice-password
You can also use the standalone argon2 CLI from libargon2 if you prefer:
nix shell nixpkgs#libargon2 -c \
sh -c 'printf %s "mypassword" | argon2 "$(openssl rand -hex 16)" -id -e -m 15 -t 2 -p 1' \
> /run/secrets/alice-password
Do not feed the password via a bash herestring (
<<< "mypassword") — herestrings append a trailing newline, soargon2would hashmypassword\nand later logins withmypasswordwould fail. Useprintf %s(no\n) orgradient hash.
At server startup, the file content is validated to start with $argon2
and stored verbatim — the server never sees the plaintext password.
Set password_file = null (or omit it) for users that authenticate
exclusively via OIDC. Provisioning a user with a password and then
attempting to sign in as that user via OIDC is rejected by the server
(User already exists with password authentication), so OIDC-only users
must be declared without one.
User options¶
| Option | Default | Description |
|---|---|---|
username |
<attrset key> |
Unique username |
name |
<username> |
Display name |
email |
— | Email address (required) |
password_file |
null |
Argon2id PHC hash file. null for OIDC-only accounts |
email_verified |
true |
Mark the email as verified at provision time |
superuser |
false |
Grant instance-wide admin |
Organizations¶
services.gradient.state.organizations = {
acme = {
display_name = "ACME Corp";
description = "Internal builds for ACME";
private_key_file = "/run/secrets/acme-ssh-key";
public = false;
created_by = "alice";
};
};
The SSH private key is the organization's identity key used to clone Git repositories over SSH. Generate one with:
ssh-keygen -t ed25519 -N "" -f /run/secrets/acme-ssh-key
# Add the public key (.pub) to your Git host as a deploy key
Organization options¶
| Option | Default | Description |
|---|---|---|
name |
<attrset key> |
Unique organization name |
display_name |
<name> |
Display name |
description |
null |
Optional description |
private_key_file |
— | Path to SSH private key (required) |
public |
false |
Visible to all users |
github_installation_id |
null |
GitHub App installation id to bind to this org (look it up on the App's "Install App" page on GitHub). Setting this enables outbound CI status reporting and webhook routing. When null, the field is left untouched on update so a webhook-recorded id survives reconciliation |
created_by |
— | Username of creator (required) |
Projects¶
services.gradient.state.projects = {
web-app = {
organization = "acme";
display_name = "Web App";
description = "Production web application";
repository = "git@github.com:acme/web-app.git";
wildcard = "packages.x86_64-linux.*";
active = true;
concurrency = "hard_abort"; # optional, default "soft_abort"
outbound_integration = "acme-status-reports"; # optional
created_by = "alice";
};
};
Project options¶
| Option | Default | Description |
|---|---|---|
name |
<attrset key> |
Unique project name |
organization |
— | Owning organization (required) |
display_name |
<name> |
Display name |
description |
null |
Optional description |
repository |
— | Git URL (required) |
wildcard |
packages.x86_64-linux.* |
Attr-path pattern picked up by the evaluator. The legacy name evaluation_wildcard is still accepted as an alias |
active |
true |
Disable to pause polling/evaluations without deleting |
keep_evaluations |
30 |
Number of completed evaluations to retain per project. Must be at least 1. Capped at runtime by the global services.gradient.settings.keepEvaluations |
concurrency |
"skip" |
Policy for handling new trigger events while an evaluation is in flight (hard_abort, soft_abort, skip, all). Applies to all triggers on the project |
sign_cache |
true |
When false, build outputs from this project are pushed to the cache but their narinfo signatures are left empty. External Nix clients won't trust them, keeping the project's outputs private even when the cache itself is public. A path co-produced by another sign_cache=true project is still signed |
outbound_integration |
null |
Name of an outbound integration that receives CI status reports |
created_by |
— | Username of creator (required) |
outbound_integration must reference an entry in services.gradient.state.integrations belonging to the same organization. See Integrations below.
To route inbound forge webhooks to a project, declare one or more reporter_push or reporter_pull_request triggers referencing the integration. See the Triggers section below.
Integrations¶
Forge integrations either receive push webhooks from the forge (inbound) or push CI status updates back to it (outbound). They are referenced from projects via inbound_integration / outbound_integration.
services.gradient.state.integrations = {
acme-prod-inbound = {
organization = "acme";
kind = "inbound";
forge_type = "gitea"; # gitea | forgejo | gitlab | github
secret_file = "/run/secrets/acme-inbound-hmac";
created_by = "alice";
};
acme-status-reports = {
organization = "acme";
kind = "outbound";
forge_type = "gitea";
endpoint_url = "https://gitea.example.com";
access_token_file = "/run/secrets/acme-gitea-token";
created_by = "alice";
};
};
Integration options¶
| Option | Default | Description |
|---|---|---|
name |
<attrset key> |
Unique within (organization, kind) |
display_name |
null (= name) |
Human-readable label |
organization |
— | Owning organization (required) |
kind |
— | inbound (forge → Gradient) or outbound (Gradient → forge) |
forge_type |
— | gitea, forgejo, gitlab, or github |
secret_file |
null |
HMAC secret for inbound webhooks. Encrypted into the DB at startup |
endpoint_url |
null |
Base URL of the forge API. Outbound only |
access_token_file |
null |
API token for outbound. Ignored for GitHub outbound (uses the GitHub App credentials) |
created_by |
— | Username of creator (required) |
A single inbound integration row serves Gitea, Forgejo and GitLab simultaneously — the actual forge is selected by the /hooks/{forge}/{org} URL path. The forge_type field is display metadata for inbound entries.
Caches¶
services.gradient.state.caches = {
main = {
display_name = "Main";
description = "Production binary cache";
priority = 10;
public = false;
signing_key_file = "/run/secrets/cache-signing-key";
organizations = [ "acme" ];
upstreams = [
{
type = "external";
display_name = "cache.nixos.org";
url = "https://cache.nixos.org";
public_key = "cache.nixos.org-1:6NCHdD59X431o0gWypbMrAURkbJ16ZPMQFGspcDShjY=";
}
{
type = "internal";
cache_name = "shared";
mode = "ReadOnly";
}
];
created_by = "alice";
};
};
Generate a Nix cache signing key with:
nix-store --generate-binary-cache-key main-cache \
/run/secrets/cache-signing-key \
/run/secrets/cache-signing-key.pub
nix-store writes the keys with a <name>: prefix (e.g.
main-cache:AbCd…). The state provisioner expects the raw base64
payload only — strip the main-cache: prefix before wiring the file
into signing_key_file:
sed -i 's/^[^:]*://' /run/secrets/cache-signing-key
sed -i 's/^[^:]*://' /run/secrets/cache-signing-key.pub
Without this, startup fails with
Signing key for cache '…' is not a valid base64 encoded string.
Cache options¶
| Option | Default | Description |
|---|---|---|
name |
<attrset key> |
Unique cache name |
display_name |
<name> |
Display name |
description |
null |
Optional description |
active |
true |
Set false to disable serving without deleting |
priority |
10 |
Higher wins when multiple caches contain the same path |
signing_key_file |
— | Path to the (de-prefixed) base64 Ed25519 signing key (required) |
organizations |
[] |
Organization names allowed to use this cache |
public |
false |
Available to every organization |
upstreams |
[ cache.nixos.org ] |
Substituters consulted on cache miss. See below |
created_by |
— | Username of creator (required) |
Upstream options¶
Each entry in upstreams is one of:
| Option | Type | Description |
|---|---|---|
type |
"internal" | "external" |
Whether the upstream is another Gradient cache or a plain Nix binary cache URL |
cache_name |
string | (internal) Name of the Gradient cache to subscribe to |
display_name |
string | null | Optional label. Defaults to cache_name for internal, required for external |
mode |
"ReadWrite" | "ReadOnly" | "WriteOnly" |
(internal only) Subscription mode. Defaults to ReadWrite. Ignored for external, which is always ReadOnly |
url |
string | (external) Substituter URL — e.g. https://cache.nixos.org |
public_key |
string | (external) Trusted public key in <name>:<base64> form |
Outbound requests to external substituters (and to every other HTTP target the
server, worker, or CLI talks to) carry the user-agent
Gradient/<version> (+https://github.com/wavelens/gradient), so cache operators
can attribute traffic and build allowlists or per-client metrics around it.
Roles¶
State files can declare custom org-scoped roles via the roles attribute.
Each role targets one organization and grants a fixed permission set:
services.gradient.state.roles = {
releaser = {
organization = "acme";
permissions = [ "viewOrg" "triggerEvaluation" ];
};
};
Managed roles are immutable through the role-management API: PATCH and
DELETE return 403 Forbidden. Removing the entry from the state file
unmarks the role (or deletes it, when settings.deleteState = true).
Role names must not collide with the built-in roles (Admin, Write,
View) or with another state-managed role in the same organization —
startup fails on collision.
Role options¶
| Option | Default | Description |
|---|---|---|
name |
<attrset key> |
Role name. Must be unique within the organization and must not collide with a built-in role |
organization |
— | Owning organization name (required) |
permissions |
— | List of capability identifiers granted by the role (required, see GET /user/keys/permissions for the catalogue) |
API Keys¶
services.gradient.state.api_keys = {
ci-runner = {
key_file = "/run/secrets/ci-api-key";
owned_by = "alice";
permissions = [ "viewOrg" "triggerEvaluation" ];
organization = "acme"; # optional — omit for an unscoped key
};
};
The key file must contain the lowercase 64-char SHA-256 hex digest of the
token (without the GRAD prefix). The server stores keys hashed; only the hash
ends up in the database. Generate one for an existing token (or a new random
one) like so:
TOKEN="$(openssl rand -hex 32)"
printf %s "$TOKEN" | sha256sum | cut -d' ' -f1 > /run/secrets/ci-api-key
Hand GRAD$TOKEN to the user/CI pipeline; the server will hash it on the way
in and compare against the digest in key_file.
The permissions list is required — there is no safe default. When
organization is set, the key is rejected for every other org (404, so org
existence isn't leaked).
API-key options¶
| Option | Default | Description |
|---|---|---|
name |
<attrset key> |
Unique key name |
key_file |
— | Path to a file containing the lowercase 64-char SHA-256 hex digest of the token (required) |
owned_by |
— | Username that owns the key (required) |
permissions |
— | Capability identifiers the key grants (required, non-empty). See GET /user/keys/permissions for the catalogue |
organization |
null |
Organization name to pin the key to. Omit for an unscoped key |
Workers¶
Worker registrations can be declared in state instead of using the API. This is useful for NixOS-managed build machines where you want tokens to be provisioned automatically.
services.gradient.state.workers = {
builder-1 = {
display_name = "Primary Build Server"; # defaults to attrset key
worker_id = "550e8400-e29b-41d4-a716-446655440001";
organization = "acme";
token_file = "/run/secrets/builder-1-token";
created_by = "alice";
# Optional: have the server dial the worker instead of waiting for it.
# url = "wss://builder-1.example.com/proto";
# Per-registration capability gates — clear one to refuse the capability
# for this worker even if the worker advertises it.
enable_fetch = true;
enable_eval = true;
enable_build = true;
};
};
The token file must contain a single plaintext token — the server hashes it and stores the result, the plaintext is never persisted:
openssl rand -base64 48 > /run/secrets/builder-1-token
worker_id is required and must match the GRADIENT_WORKER_ID environment variable (or workerId option) on the worker machine. Unlike API registration, state-managed workers are not restricted to UUID v4 — any stable string is accepted, though using a UUID is conventional.
To ensure the worker uses the same ID that was pre-registered, set workerId in the worker module:
services.gradient.worker.workerId = "550e8400-e29b-41d4-a716-446655440001";
State-managed worker registrations are deleted automatically when removed from state.workers (subject to settings.deleteState).
Worker options¶
| Option | Default | Description |
|---|---|---|
display_name |
<attrset key> |
Display name shown in the workers list |
worker_id |
— | Persistent worker identity. Must match the worker's GRADIENT_WORKER_ID (required) |
organization |
— | Owning organization (required) |
token_file |
— | Plaintext token file. Hashed at provision time (required) |
url |
null |
When set, the server dials the worker at this WebSocket URL instead of waiting for an inbound connection |
enable_fetch |
true |
Server-side gate for the fetch capability |
enable_eval |
true |
Server-side gate for the eval capability |
enable_build |
true |
Server-side gate for the build capability |
created_by |
— | Username of creator (required) |
Triggers¶
Each project can have one or more triggers that decide when an evaluation runs. Triggers are configurable via the API or declaratively in state files.
The concurrency policy — what happens when a new trigger event arrives while an evaluation is already in flight — is a project-level setting, not per-trigger. Set it on the project with concurrency (see Project options above).
services.gradient.state.projects.my-project = {
# ... other project options ...
concurrency = "hard_abort"; # applies to all triggers below
triggers = [
{
type = "polling";
config = { interval_secs = 60; branch = "main"; };
}
{
type = "reporter_push";
integration = "gitea-prod"; # name of an inbound integration in the same org
config = {
branches = [ "main" "release/*" ];
tags = [];
releases_only = false;
};
}
{
type = "reporter_pull_request";
integration = "gitea-prod";
config = {
branches = [ "main" ];
actions = [ "opened" "synchronize" "reopened" ];
};
}
{
type = "time";
config = { cron = "0 0 2 * * *"; }; # 02:00 UTC every day (six-field: sec min hour dom mon dow)
}
];
};
Trigger types¶
- polling — periodically check the git repository for new commits.
interval_secsminimum 10, default 300. branch (optional) — track a specific branch; leave unset to follow the remote HEAD (the repo's default branch). - reporter_push — fires on forge push events. Filters:
branches,tags(glob patterns; empty = match all),releases_only(only fires on explicit forge release events). - reporter_pull_request — fires on PR/MR events. Filters:
branches,actions(default: opened/synchronize/reopened). - time — fires on a six-field cron schedule (UTC). Re-evaluates the project HEAD even if the commit hasn't changed.
Concurrency policies¶
Each project has a single concurrency policy that applies to all of its triggers:
- hard_abort — cancel the in-flight evaluation and its in-flight builds, then start a new evaluation. Workers running affected builds receive cancellation through the existing job lifecycle.
- soft_abort — mark the in-flight evaluation
Abortedso the new one becomes canonical, but let already-running builds finish; their cached outputs flow into the new evaluation. - skip — discard the new trigger event; keep the running evaluation.
- all — run a new evaluation alongside the in-flight one. The new eval is flagged
concurrentso it bypasses the "one active eval per project" guard while leaving that guard intact forhard_abort/soft_abort/skip.
Defaults¶
- New projects automatically get a default
pollingtrigger (interval 300s). Existing projects were backfilled by the same logic during the migration. - Concurrency defaults to
soft_abort— a new trigger event marks the running evaluation Aborted while letting its in-flight builds finish; the new evaluation reuses any cached outputs they produce. Switch tohard_abortto also cancel the running builds,skipto drop the new event, orallto run multiple evaluations concurrently.
The implicit fallback poll for projects with an inbound integration (the legacy WEBHOOK_BACKUP_POLL_SECS behavior) has been removed; webhook-driven projects must declare an explicit reporter_push trigger to receive evaluations from forge pushes.