Administrator guide
First run
- Sign in with the bootstrap token (
api.bootstrap.*in the chart). Everything you do is audited under the actorbootstrap-admin. The token stops working once SSO is configured and the API restarts, which is the intended end of its life. - Licence: paste the key from Nezal. It is verified locally, with no call to us, so it
works air-gapped. State
validshows customer, seats, expiry and features. After expiry there are 30 days ofgrace(7 for a trial) during which everything still works and the dashboard says so. After grace, no new environments or users are admitted. Nothing is ever deleted because of the licence, and nothing existing stops running. - SMTP, before you invite anyone. Host, port, security (
starttls,tlsor none), credentials and the From address, under Security & email.Send testproves it before an invitation depends on it. The password is write-only: it comes back redacted and the dashboard sends the redaction back untouched, so saving the form cannot overwrite a working password with an empty one. - SSO, or local accounts, or both. OIDC (issuer, client id and secret, groups claim) or SAML (IdP metadata XML, SP key pair). Map IdP groups to the admin role and to workspaces as developer or team lead.
- MFA policy under Security & email. With
enforcedon, every local account must enrol a TOTP authenticator before it can do anything. SSO accounts are unaffected: the second factor there belongs to your identity provider. - Workspaces: one per team. A workspace maps to an application namespace; the Baselines, Environments and Policy of that team live there.
- Policy per workspace: environments per user and per team, CPU and memory quota,
default and maximum TTL, idle sleep, grace period after expiry, allowed registries,
terminal on or off, terminal idle and maximum session length, and how long session
recordings are kept. The operator's admission webhook enforces it on every write, so it
holds for
kubectltoo, not only for the dashboard. - Trusted proxies, if the API sits behind an ingress controller or load balancer. Set
api.trustedProxiesto that proxy's pod network. Until you do,X-Forwarded-Foris ignored entirely and the audit log records the proxy's address rather than the user's. That default is deliberate: believing the header unconditionally would let any caller write an arbitrary address into the tamper-evident record an investigator relies on. - Check the install answered:
helm test nezal -n nezal-system.
People: local accounts, invitations and second factors
Users come from your identity provider, or are invited directly, or both.
Inviting: Users & teams > Invite, with an email, an optional workspace and role, and whether they are an admin. They receive a link to set a password. An invited account holds a seat from the moment it is created, not from first sign-in, because otherwise an administrator could invite an unlimited number of people and have them all working before the seat count noticed.
Second factor: with MFA enforced, a new local account is walked through enrolment on
first sign-in and shown ten single-use recovery codes, once. Reset MFA on a user clears
their enrolment so they can enrol again; it is the answer to a lost phone, and it is
audited.
Rate limiting is on and needs no configuration: ten failed sign-ins for one account within fifteen minutes locks that account for fifteen minutes, and sixty failures from one address in the same window blocks the address. An account that has been invited but has not yet set a password is rate-limited too.
Sessions and tokens: each person can see their own active sessions under Account, with the device and the last time each was used, and revoke any of them. "Sign out everywhere" spares the session doing the asking, so nobody signs themselves out by accident. An administrator can revoke every session of another user, which takes effect on that user's next request rather than whenever their token happens to expire. CLI tokens are minted from the dashboard and appear in the same list.
Removing someone: deleting a user revokes their sessions and tokens and frees the seat. A user who still owns environments is refused, with the count, rather than deleted: removing them silently would leave environments nobody can reach and quota nobody is accountable for. Delete or hand over their environments first, then delete the account.
Roles
| Developer | Team lead | Admin | |
|---|---|---|---|
| Own environments: create, delete, sync, extend, logs, terminal | yes | yes | yes |
| Team environments | read, logs | manage, extend, delete | yes |
| Quota, members, reports of the team | no | yes | yes |
| Applications, baselines, policies | read | connect, promote | yes |
| Users, licence, SSO, SIEM, audit export | no | no | yes |
Terminal sessions are always owner-only, never on the baseline, and can be switched off per team by an admin or by the Policy.
Scanning built images
If your policy says every image in the cluster is scanned, set imageScan in the team's
Policy:
| Value | What happens |
|---|---|
off (default) |
No scan runs and the build is recorded as unscanned |
warn |
The image is scanned, findings are recorded, the build succeeds |
block |
A finding at or above imageScanSeverity fails the build |
imageScanSeverity defaults to HIGH,CRITICAL. A preview environment full of MEDIUM
findings nobody will act on is how a scan gets switched off again.
This needs two things at install: imageScan.image (a scanner — Trivy — mirrored into your
registry) and imageScan.dbClaim (a PersistentVolumeClaim in the build namespace holding its
vulnerability database). Nothing is downloaded. The scan runs with every "do not update"
flag the scanner has and mounts the database read-only, so a build can never alter the
database it is judged against. With either value unset, no scan runs whatever a Policy asks
for — an air-gapped cluster that has not mirrored a scanner should not have every build start
failing.
Refresh the database on your own schedule, the same way you refresh any other offline feed. A stale database is a scan that misses recent findings, and nothing here will tell you it has gone stale.
The result appears on the environment's Builds tab against each service.
Terminal limits and recording retention
A web shell is the highest-privilege thing a developer can do here, and the only action that would otherwise leave no detailed trace: every other action is an audit entry naming what was done, while a shell is one entry saying it was opened. That is why the recording is not optional — terminal on means recorded — and why the Policy bounds the session rather than the recording:
| Policy field | Default | What it does |
|---|---|---|
terminalIdleTimeout |
15m |
Closes a session with no input or output for that long. "0" disables it |
terminalMaxDuration |
4h |
Closes a session that long after it started, however busy. "0" disables it |
terminalRecordingRetentionDays |
90 |
Deletes recordings older than this. 0 keeps them forever |
A session closed by a limit says so: the sessions list and the audit entry both carry the
reason (idle, max, cap or user), because "the shell exited" and "we closed it after
an hour idle" are different facts to whoever reads the log later.
A recording is also capped at 20 MiB. Reaching the cap ends the session rather than silently stopping the recording — an unrecorded shell is worse than a closed one — and a runaway process reaches it in seconds, which is the case the cap exists for.
Retention deletes the recording, never the audit entry. What happened stays in the log
permanently; only the replayable file ages out, and the sessions list then says
"Recording expired" rather than offering a download that cannot work. Each expiry is itself
audited, as actor system.
Setting retention to 0 is supported and is sometimes what a regulator asks for, but
recordings share a volume with the database: a team that keeps them forever eventually fills
the volume, and when it fills the database becomes read-only and the audit log stops with it.
Watch nezal_data_volume_free_bytes, and see Monitoring below.
Repositories and applications
Connect the repository first, under Repositories: its URL, the default ref, and how to
authenticate (an HTTPS token, an SSH key, or none for a public mirror). Test clones it
before anything depends on it. Credentials go into a Kubernetes Secret in the application
namespace labelled nezal.dev/git-credentials=true, and are never readable back through
the API. The operator is granted access to that one Secret by name and holds no cluster-wide
Secret permission at all (see ADR 0006).
Optionally set a webhook secret on the repository. That is what GitHub's
X-Hub-Signature-256 or GitLab's X-Gitlab-Token is checked against on
POST /webhooks/{workspace}/{repository}, which is how a pull request gets its own
environment. Like the git credential, it is write-only.
Then connect the application, pointing at that repository and a path (Helm, Kustomize,
plain manifests or docker-compose). The operator renders it, lists the services, and
reports in status.warnings everything it could not carry over. Read those warnings on the
application page: they are where "this service has no health check" and "this bind mount
cannot exist in a cluster" are said out loud. Mark databases and queues as shared so
environments never copy them.
Promoting a release means changing the source ref. Every promotion is recorded in the application's release history with the commit, the time and who did it, and rolling back is promoting an older ref from that list. Environments keep sharing the baseline; only their overridden services are their own.
Per-PR environments (licensed feature per-pr): set prEnvironments to all, or to
label with a label name, and a pull request opening creates pr-<number>-<application>,
a push syncs it, and closing the pull request deletes it. They are owned by webhook, not
by a person, so they do not consume anyone's personal environment quota.
Posting the preview URL back. Tick Post the preview URL to the pull request on the
repository and Nezal sets a commit status (nezal/preview) and writes one comment, editing
that same comment on every push rather than repeating it — a dozen identical bot comments is
why people mute these integrations.
Off by default, because writing to your pull requests is not something to assume. It needs a token that can write to the repository: a read-only deploy key connects and clones perfectly well but cannot comment, and Nezal skips posting quietly rather than filling the log. GitHub (including Enterprise) and GitLab are recognised from the repository URL; any other git server has no pull requests to post to and nothing is attempted.
Each post is in the audit log as forge.post. Nothing new is contacted: this is the same git
server the repository already points at.
Preview URLs
Every environment publishes <environment>--<application>.<domain>. With
previewAuth.enabled (the default) a visitor must have signed in to Nezal and have read
access to that environment; the cookie is scoped to one environment and lasts eight hours.
Leave it on. With it off, a preview host is reachable by anyone who can reach the ingress, which on a bank's internal network is every employee, every contractor's laptop and every other workload in the cluster. A developer testing against production-shaped data would be publishing it to all of them without being told. See ADR 0003.
Audit and evidence
Every action of every user is one entry with a sha256 chained to the previous entry.
Audit > Verify walks the chain; Export downloads a package of JSON lines plus a
manifest signed with the installation's key (public key at /api/v1/audit/public-key).
Forward entries live to your SIEM over syslog (TCP or UDP, RFC 5424) or HTTP JSON under
Settings > SIEM. Terminal recordings (asciicast) are listed under Terminal sessions.
Retention
The audit table refuses UPDATE and DELETE at the database level, which is the property that makes it worth anything to an auditor. Nezal therefore has no way to delete an old entry, and will not grow one.
What it has instead is archiving: export everything older than a cut into a signed evidence package you keep wherever you keep evidence, and record that the range is accounted for.
curl -X POST -H "Authorization: Bearer $TOKEN" \
"https://nezal.bank.internal/api/v1/audit/archive?olderThanDays=365&location=vault://audit/2025" \
-o nezal-audit-archive.multipart
olderThanDays defaults to 365. location is recorded exactly as you give it and is never
read back — an archive Nezal could reach is an archive Nezal could alter. Archiving a
range that holds nothing is not an error and records nothing, so a scheduled call can run on
a quiet week.
GET /api/v1/audit/archives lists what has been archived: the range each one covers, its
head hash, and the head hash of the archive before it. Consecutive archives join up with no
gap, so an auditor can show the sequence is complete — a gap would be indistinguishable from
a deletion.
Nothing is deleted by any of this. After archiving, the live log still holds every entry
and Audit > Verify still walks the whole chain. Reclaiming space is a separate decision,
taken with the evidence already in hand rather than instead of it; the supported way is to
restore an older backup into a separate installation for reference and start the live one
fresh, which is deliberately more effort than deleting rows.
Each archive carries a manifest with a sha256 of the entries and an ed25519 signature over the manifest. An auditor can check both years later without this installation being available: the manifest carries the public key it was signed with.
Lifecycle
Environments expire at their TTL: copies scale to zero, the preview URL shows an "expired" page, and after the grace period the environment is deleted. Idle environments sleep; the first request wakes them. Each transition is a Kubernetes Event and an audit entry.
Licence and seats
Licence shows the customer, the term, the features and the seat count against active users. A seat is someone who has signed in or holds a pending invitation.
Seats are soft. Passing the count raises a warning, a 10% burst is tolerated outright, and beyond that new invitations are refused while everyone already working carries on. A bank does not want its developers stopped mid-sprint by a counter; over-deployment is a renewal conversation, not an outage.
Usage report downloads a signed report of monthly active seats over the term, which is
what a renewal is negotiated against. It needs no network and reveals nothing outside the
cluster.
Being warned before it expires
Nezal warns about expiry through four channels at once, because in a regulated site at least one of them is usually unavailable. It starts 30 days ahead, then again at 14, 7, 3 and 1 days, on the expiry day itself, weekly through the grace period, and once when the grace period ends. A trial warns at 7, 3 and 1 days instead — its whole term is shorter than a paid licence's first notice.
Each notice is sent once. Restarting the API does not resend one, and installing a renewed key starts the sequence again for the new term.
| Channel | Needs configuring | Where it appears |
|---|---|---|
| An SMTP relay | Every enabled administrator, plus the addresses in Settings → Licence notices |
|
| Prometheus | A scrape of /metrics |
nezal_licence_seconds_until_expiry, negative once expired |
| Kubernetes Event | Nothing | kubectl -n nezal-system get events, on the nezal-licence ConfigMap |
| Dashboard banner | Nothing | Every page, for everyone, from 30 days out |
| CLI | Nothing | One line on stderr per command run, from 30 days out |
The email recipients are every enabled administrator, plus whatever is set at
PUT /settings/licence-notify. That list exists because the people who approve a renewal
are usually in procurement and rarely hold an admin account. A disabled administrator is not
notified.
If there is no SMTP relay the other four channels still work, and the audit log records
licence.notice with smtp: false — so "we were never told" has an answer either way.
Nothing about any of this leaves the cluster. The mail goes through the customer's own relay; Nezal does not contact its vendor at any point.
Monitoring
The API and the operator both serve Prometheus metrics on /metrics, unauthenticated on the
pod network and not exposed through the ingress. Nothing is sent anywhere: your Prometheus
scrapes them, the same as anything else in the cluster.
No metric carries a user, a customer, an environment or a workspace name. Labels end up in dashboards and in long-term storage, so a label is a place data goes and stays — and one series per environment would be unbounded cardinality besides.
| Series | What it tells you |
|---|---|
nezal_api_requests_total{route,method,status} |
Request and error rate. route is the pattern, never the path |
nezal_api_request_duration_seconds{route,method} |
Latency, as a histogram |
nezal_api_requests_in_flight |
Concurrency now |
nezal_environments{phase} |
Environments by phase, as the API sees them |
nezal_builds_running |
Builds in flight |
nezal_terminal_sessions_active |
Shells open right now |
nezal_sessions_active |
Sign-ins that have not expired |
nezal_audit_head_seq |
Newest audit entry. Stops rising if the log stops |
nezal_data_volume_free_bytes / _total_bytes |
Space on the volume holding the database and recordings |
nezal_licence_seconds_until_expiry |
Negative once expired |
nezal_evidence_pending_uploads |
Evidence files not yet in your object storage |
Alert rules worth having
These are the five that matter. Everything else is a dashboard.
groups:
- name: nezal
rules:
# The licence stops new work after its grace period. Thirty days is enough for a
# purchase order to go through; the product also emails, but not every site has SMTP.
- alert: NezalLicenceExpiringSoon
expr: nezal_licence_seconds_until_expiry < 30 * 86400
for: 1h
annotations: {summary: "Nezal licence expires in under 30 days"}
# The volume holds the database and every recording. When it fills, the database
# becomes read-only and the audit log stops with it.
- alert: NezalDataVolumeLow
expr: nezal_data_volume_free_bytes / nezal_data_volume_total_bytes < 0.15
for: 15m
annotations: {summary: "Nezal data volume below 15% free"}
- alert: NezalAPIErrorRate
expr: |
sum(rate(nezal_api_requests_total{status=~"5.."}[5m]))
/ sum(rate(nezal_api_requests_total[5m])) > 0.05
for: 10m
annotations: {summary: "Over 5% of Nezal API requests are failing"}
# A stalled audit head is either a very quiet install or a broken one, and you need to
# know which. Pair it with the error-rate alert: quiet plus errors is not quiet.
- alert: NezalAuditLogStalled
expr: increase(nezal_audit_head_seq[6h]) == 0
for: 30m
annotations: {summary: "No Nezal audit entries in six hours"}
# Evidence that never reaches your object storage is evidence you do not have.
- alert: NezalEvidenceUploadsPending
expr: nezal_evidence_pending_uploads > 0
for: 1h
annotations: {summary: "Nezal evidence files are not reaching object storage"}
The operator publishes its own series (nezal_environments_by_phase, render counts and
durations). A disagreement between the operator's view and the API's is itself a signal:
they read the same objects by different paths.
Getting support
Nezal sends no telemetry, so there is nothing for us to look at when something goes wrong. The support bundle is the substitute: one file with what a support engineer would otherwise spend a day asking for.
Admin → Installation → Download support bundle, or from the command line when the dashboard itself is the problem:
kubectl exec -n nezal-system deploy/nezal-api -- \
/nezal-api support-bundle > nezal-support.json
It holds: component versions; the licence state, plan and seat counts (never the key); every setting with credentials redacted; the last known result of each git, registry, SMTP and evidence-store check; pod status and recent warning Events in the release namespace; the audit head and whether the chain verifies; and the last 2,000 API log lines.
Nothing is sent anywhere by producing it. The first line of the file says so. Read it, then attach it to your support request yourself — that is the whole point of the format being one readable JSON file rather than an archive.
Credentials, tokens, keys and Authorization headers are redacted wherever they appear, including in log lines, which are redacted as they are recorded rather than as the bundle is written. A test plants realistic credentials through the API and fails the build if any of them reaches a bundle. Even so: read the file before you send it. It describes your installation in detail, and only you can judge whether that detail is something you are happy to share.
The command-line version reads the database directly, so it works when the API is not serving. It cannot include the running process's recent logs; the dashboard download can.
Backup and restore
The API's PVC holds the SQLite database, the session and audit-export keys, and terminal
recordings. It carries helm.sh/resource-policy: keep, so helm uninstall leaves it
alone; removing it is a deliberate kubectl delete pvc.
Back up with the command, not a file copy. Copying the database file while it is open can produce an unusable backup:
kubectl exec -n nezal-system deploy/nezal-api -- \
/nezal-api backup --out - > "nezal-$(date +%F).db.enc"
The backup is encrypted. It holds every password hash, every stored second-factor secret
and the whole audit log, and once it reaches object storage its custodian is not necessarily
the person administering Nezal. AES-256-GCM, with a 32-byte key in the nezal-api-keys
Secret alongside the signing keys.
Lose that Secret and every backup becomes unreadable. There is no recovery path and there is deliberately no escrow. Back the Secret up separately, somewhere your database backup tooling does not reach, before you need it.
--plaintext writes an unencrypted backup for a customer whose own key management requires
it. The command says plainly what that file contains.
--out - streams the backup to stdout. That is the retrieval path, not a convenience:
kubectl cp shells out to tar inside the container, and the API image is distroless — no
shell, no tar — so kubectl cp cannot work against it at all. Everything the command says
about itself goes to stderr, so the redirected file holds nothing but the database.
Check it before trusting it. verify-backup opens the file with SQLite, so it needs a real
file rather than a pipe — run it wherever the backup now lives:
nezal-api verify-backup --in "nezal-$(date +%F).db.enc" --key-file ./backup.key
Inside the cluster the key is found automatically on the data volume, so
kubectl exec -n nezal-system deploy/nezal-api -- /nezal-api verify-backup --in … needs no
--key-file. verify-backup names the key each file was made with, so an unlabelled backup
found later can be matched to the right one.
The API image is distroless — /nezal-api is the only executable in it — so the check runs
outside the cluster, on the copy you have just taken. The scheduled CronJob verifies each
backup it writes, which is the path to prefer.
A backup nobody has opened is a hope rather than a backup.
backup uses SQLite's VACUUM INTO, which is safe while the database is being written to.
The chart ships a CronJob that does all of this on a schedule, writing to its own claim; see
backup in values.yaml. That is the path to prefer — the manual commands above are for a
backup taken before a deliberate change.
Restoring is deliberately a documented sequence rather than a subcommand, because a restore command that can be run against a live database is a way to destroy one:
Scale the API to zero:
kubectl scale -n nezal-system deploy/nezal-api --replicas=0.Copy the backup over
nezal.dbin the data directory, from a pod mounting the same claim. With the API scaled to zero nothing else holds the claim, so a short-lived pod mountingnezal-api-datais enough:kubectl cp ./nezal-2026-09-11.db nezal-system/<restore-pod>:/var/lib/nezal/nezal.dbRemove any
nezal.db-walandnezal.db-shmbeside it first: they belong to the database you are replacing, and SQLite will otherwise try to replay them over the restored file.Scale back to one and check the audit chain verifies in the dashboard.
Baselines, Environments and Policies are Kubernetes objects; back them up with whatever your cluster already uses. They are also rebuilt from the repository, so the database is the part that matters.