Self-hosted deployment

Getting started with Cerynix on-prem

This is the complete, end-to-end guide for running Cerynix on your own server. It is split into three phases you follow in order — you should be able to go from a bare Linux VM to a fully configured instance without surprises:

  1. Phase 1 — Install the product on your server.
  2. Phase 2 — Activate your license key.
  3. Phase 3 — Configure everything: onboarding, users & roles, connectors and the GRC loop.

Every command below is copy-paste ready and each step states the expected result so you can confirm you are on track before moving on. The product is fully self-hosted — no cloud dependency and no phone-home.

Before you start You need shell access to a Linux VM (Docker + Docker Compose v2), a hostname or IP clients will use, and about 30 minutes. The screenshots show the product you will see once the stack is up.
Phase 1 of 3

Install on your server

Two paths lead to the same running instance. Use the quick install (one command) unless you need the finer control of the manual path (air-gapped images, a custom TLS certificate at bring-up, or a review of every value written to .env).

Recommended

Quick install (one command)

Cerynix ships a clone-free bootstrap from your self-hosted download endpoint. One command fetches and verifies the on-prem bundle, unpacks it, and hands off to the bundled install.sh — which takes a clean host all the way to a running, admin-ready instance. No repository or registry access is required on the server.

Prerequisites

  • A Linux VM (Ubuntu 24.04 LTS tested), x86-64, with ≥ 4 GB RAM and ≥ 20 GB free disk.
  • Docker Engine + the Docker Compose v2 plugin, with the daemon running and reachable by the user running the command.
  • curl, tar, gzip — used by the bootstrap; and openssl, used by the installer to generate secrets.
  • age (optional) — if age-keygen is present the installer also creates a backup-encryption key; otherwise it skips it and you can set up backups later.

One command

Run this on the server, substituting the three angle-bracket values. Anything after bash -s -- is passed straight through to the installer:

install (one line)
curl -fsSL https://dl.cerynix.com/get.sh | bash -s -- \
  --hostname <server-host> \
  --admin-email <email> \
  --org "<Company>"
The download URL The installer bootstrap is served at https://dl.cerynix.com/get.sh; the bundle it fetches is at https://dl.cerynix.com/cerynix-onprem.tar.gz. Opening https://dl.cerynix.com/ in a browser shows this same command. Your Cerynix contact will confirm the exact download host for your account. To run a saved copy of the bootstrap against a specific endpoint, set CERYNIX_DL=https://<host>.

You can pass more of the installer's flags on the same line (e.g. --admin-password, --country). Omit --admin-password and the installer auto-generates a strong one and prints it at the end.

FlagEnv varMeaning
--hostnameCERYNIX_HOSTNAMEHostname or IP clients use (drives TLS SAN + CORS + API URL)
--admin-emailCERYNIX_ADMIN_EMAILFirst admin login email
--orgCERYNIX_ORGOrganization / company name
--admin-passwordCERYNIX_ADMIN_PASSWORDFirst admin password (omit to auto-generate)
--countryCERYNIX_COUNTRYOrganization country, ISO code or EU (default EU)
--no-backup-keySkip creating the age backup key
--forceRegenerate .env even if one exists (rotates all secrets)
--repairRebuild + restart + self-test an existing install, without rotating secrets
-y, --yesNever prompt; fail if a required value is missing

What it does automatically

  1. Downloads the bundle from your endpoint, verifies it is a valid gzip/tar, and extracts it into an install directory (default ./cerynix-onprem; override with CERYNIX_HOME).
  2. Checks prerequisites (docker, compose v2, openssl, a live daemon) and warns if ports 80/443 are already in use.
  3. Generates all secretsSECRET_KEY, both DB passwords, and the object-store password — as shell-safe values (nothing to copy by hand).
  4. Writes a hardened production .env (chmod 600): production mode, RLS on, self-registration off, demo data off.
  5. Builds and starts the whole stack (docker compose … up -d --build).
  6. Waits for the API to report healthy (migrations + first-boot seed of reference data); on timeout it dumps recent api/db logs and exits non-zero.
  7. Bootstraps your first admin + organization (app.scripts.create_admin) and runs a browser-path self-test.
  8. Prints the login URL, admin email, and — if it generated one — the admin password.
Expected result
==> ... build + health + self-test pass ...
Cerynix is ready.
  URL:      https://<server-host>/
  Admin:    <email>
  Password: <printed here if auto-generated>
The one manual follow-up If a backup key was created, move ./cerynix-backup.key off the server (into a vault / password manager) and delete the on-box copy. It is the only way to decrypt backups and must not live on the box it protects. Everything else is done — go to Phase 2.

Manual install (advanced)

The steps below do by hand exactly what the quick install automates. Reach for them only when you need finer control — an air-gapped box, a custom TLS certificate at bring-up, or a review of every value written to .env. The result is identical to the quick install; if you used the one command above, skip straight to Phase 2.

1 · Prerequisites & code

Modern Linux x86-64 host with Docker Engine 24.x and the Docker Compose v2 plugin. The core app needs no external network at runtime.

RequirementMinimumRecommended
Docker Engine24.xlatest stable
Docker Composev2 (plugin form)latest
RAM4 GB8 GB
Disk20 GB free50 GB+
CPU2 vCPU4 vCPU
verify Docker
docker --version
docker compose version          # must print v2.x

Install age on the box for encrypted backups, then get the code:

age + clone
sudo apt-get update && sudo apt-get install -y age
sudo install -d -o "$USER" /opt/cerynix
git clone https://github.com/gruvX/nis2-sentinel.git /opt/cerynix/app
cd /opt/cerynix/app

Everything below runs from /opt/cerynix/app.

Air-gapped? The stack builds its own images from source (--build) — there is no registry to pull from. On an air-gapped box, build on a connected host, docker save the images, copy the tarball across, docker load, then run the bring-up without --build.

2 · Generate secrets

The production overlay and the app refuse to start on placeholder secrets. Generate real ones and keep the output for the matching .env key:

generate secrets
openssl rand -hex 32        # SECRET_KEY        (JWT signing + at-rest key; >= 32 chars)
openssl rand -base64 24     # POSTGRES_PASSWORD (bootstrap superuser)
openssl rand -base64 24     # APP_DB_PASSWORD   (non-superuser cerynix_app role, RLS)
openssl rand -base64 24     # S3_SECRET_KEY == MINIO_ROOT_PASSWORD (use the SAME value for both)

Backup encryption key — generate OFF the server (e.g. on an admin laptop):

backup key (off-box)
age-keygen -o cerynix-backup.key
# prints:  Public key: age1........   <- the RECIPIENT (public) key
Keep the private identity offline The age1... public key goes on the box (BACKUP_AGE_RECIPIENT). The cerynix-backup.key file is the private identity — keep it offline (vault / password manager). It never touches the server. Losing it means backups can never be decrypted.

3 · Configure .env for production

Start from the on-prem template (prod-safe defaults + explicit CHANGE ME markers) and paste in the secrets from step 2:

create .env
cp .env.onprem.example .env
chmod 600 .env
$EDITOR .env
KeyValue
SECRET_KEYthe openssl rand -hex 32 output
POSTGRES_PASSWORDan openssl rand -base64 24 output
APP_DB_PASSWORDa different openssl rand -base64 24 output
S3_SECRET_KEY and MINIO_ROOT_PASSWORDthe same rand -base64 24 value (must match)
DATABASE_URLreplace the password portion to match APP_DB_PASSWORD
BACKUP_AGE_RECIPIENTthe age1... public key from step 2
CORS_ORIGINSyour https:// hostname(s), comma-separated — never *
TLS_CN / TLS_SANhow clients reach the box (hostname and/or IP)

The docker-compose.prod.yml overlay hard-enforces the production-safe flags regardless of .env: ENVIRONMENT=production, SEED_DEMO_DATA=false, REGISTRATION_ENABLED=false and DB_RLS_ENABLED=true (Postgres Row-Level Security on; the app connects as the non-superuser cerynix_app role).

Never commit .env The .env file contains live secrets. The repo's .gitignore already excludes it — keep it that way.

4 · TLS

The bundled nginx reverse proxy terminates TLS on :443 and serves the web app and API under one origin. On first boot it auto-generates a self-signed certificate from TLS_CN / TLS_SAN. Pick one option.

Option A — self-signed (internal network, no public DNS). The proxy already self-signs on boot; to mint a longer-lived cert and install it:

self-signed cert
openssl req -x509 -newkey rsa:4096 -nodes -days 3650 \
  -keyout privkey.pem -out fullchain.pem \
  -subj "/CN=cerynix.internal" \
  -addext "subjectAltName=DNS:cerynix.internal,IP:10.0.0.10"

docker compose cp privkey.pem   proxy:/certs/privkey.pem
docker compose cp fullchain.pem proxy:/certs/fullchain.pem
rm -f privkey.pem fullchain.pem   # keep no plaintext key on disk

Or paste the same PEM cert + key at runtime via Settings → TLS in the web UI (validated, written to the volume — never stored in the database).

Option B — Let's Encrypt (public domain). Point an A record at the server, confirm it resolves, then issue + install:

issue cert
sudo DOMAIN=cerynix.example.com EMAIL=you@example.com \
  scripts/deploy/renew-cert.sh

5 · Bring the stack up

Build and start with the base file plus the production overlay, stamping build provenance:

docker compose up
GIT_COMMIT=$(git rev-parse HEAD) \
BUILD_ID=$(date -u +%Y%m%d%H%M) \
BUILD_DATE=$(date -u +%Y-%m-%dT%H:%M:%SZ) \
  docker compose -f docker-compose.yml -f docker-compose.prod.yml up -d --build

On first boot: Postgres initialises an empty volume and creates the non-superuser cerynix_app role; the api entrypoint runs alembic upgrade head and seeds reference data only (RBAC, control library, country profiles, incident playbooks — no demo org); MinIO creates the evidence bucket.

Expected result
docker compose ps shows api / db / proxy / minio "running";
the api log prints "Starting API" after "alembic ... upgrade" and "seed".

6 · Health & RLS check

health checks
curl -sf http://127.0.0.1:8000/healthz && echo " LIVE"     # process up
curl -sf http://127.0.0.1:8000/readyz  && echo " READY"    # 200 only when DB reachable
curl -skf https://127.0.0.1/healthz    && echo " PROXY-OK" # via the TLS proxy
verify RLS
docker compose exec db psql -U cerynix -d cerynix \
  -c "select rolname, rolsuper from pg_roles where rolname='cerynix_app';"
Expected result
LIVE, READY and PROXY-OK all print; the query returns:
cerynix_app | f   (rolsuper = f  =>  RLS is enforced against the app)
If readyz returns 503 The API cannot reach Postgres — check docker compose logs db api. Do not point DNS/clients at the box until readyz is 200.

7 · First-admin bootstrap

A fresh production instance has reference data but no user accounts and REGISTRATION_ENABLED=false. app.scripts.create_admin creates the org + admin from environment variables and is idempotent (if the admin email already exists it exits 0 without changes):

create_admin
docker compose -f docker-compose.yml -f docker-compose.prod.yml exec \
  -e BOOTSTRAP_ADMIN_EMAIL='admin@example.com' \
  -e BOOTSTRAP_ADMIN_PASSWORD='<a strong password, >= 10 chars>' \
  -e BOOTSTRAP_ADMIN_NAME='Jane Admin' \
  -e BOOTSTRAP_ORG_NAME='Example Company SIA' \
  -e BOOTSTRAP_ORG_COUNTRY='LV' \
  api python -m app.scripts.create_admin
Expected result
[bootstrap] created org 'Example Company SIA' (slug=example-company-sia)
and admin user admin@example.com. Log in at the web UI to continue.

Exit codes: 0 created or already present, 2 missing/invalid config, 1 other failure. After bootstrapping, add team members by invite (Phase 3) — self-registration stays closed.

Phase 2 of 3

Activate your license

Licensing is per deployment and works entirely offline: there is no license server to run and no phone-home. The vendor holds an Ed25519 private signing key; every build embeds the matching public key and verifies your license locally. A tampered or forged key is rejected.

Step 1 · You start in Evaluation

With no key set, the product runs in the Evaluation edition: fully functional — all features on, no hard limits — and clearly labelled as unlicensed. You can install, onboard and use the whole product before a key ever arrives. Confirm the current state:

  • In the web UI: Settings → License shows the status badge Evaluation.
  • Or via the API (any authenticated user): GET /api/v1/license.
Expected result (unlicensed)
{ "edition": "evaluation", "status": "evaluation", "licensed": false, "valid": true,
  "limits": { "max_organizations": null, "max_users": null, "max_assets": null },
  "message": "No license key configured. Running in Evaluation edition." }

Step 2 · Receive your signed key

Cerynix issues you a signed license key from the vendor console (hq.cerynix.com) and sends it to you out-of-band (e.g. by secure email) — you receive it, you never generate it. Its format is NS2L1.<payload>.<signature> — a token prefix, a base64url JSON payload (your edition, who it is licensed to, issue and expiry dates, and any per-contract limit/feature overrides), and an Ed25519 signature over that payload. Treat it as configuration, not a secret to rotate — but do keep it with your deployment record.

Step 3 · Activate it in the web UI

Activation is entirely in-product — no .env edit, no restart, no SSH. Log in to your instance at https://<server-host>/ as the admin, then go to Settings → License. Paste the signed key (it starts with NS2L1.) into the “Activate a license” box and click Activate license.

What happens
The key is verified locally against the embedded public key and applied
immediately — the License page refreshes its status in place, with no restart
and no downtime. A forged or malformed key is rejected and nothing changes.
Advanced fallback Air-gapped or automating a fleet? You can instead set the LICENSE_KEY environment variable in the install directory's .env and restart the API — but the web-UI activation above is the supported path and needs none of that.

Step 4 · Confirm activation

Immediately after activating, Settings → License shows your edition (e.g. Professional), Licensed to, Expires (date + days remaining), the key fingerprint, usage vs limits (organizations / users) and the enabled features for your edition. You can optionally confirm the same from the API:

GET /api/v1/license
curl -sk https://<server-host>/api/v1/license \
  -H "Authorization: Bearer <your-access-token>"
Expected result (licensed)
{
  "edition": "professional",
  "edition_label": "Professional",
  "licensed": true,
  "valid": true,
  "status": "licensed",
  "licensed_to": "Example Company SIA",
  "issued_at": "2026-01-01T00:00:00Z",
  "expires_at": "2027-01-01T00:00:00Z",
  "days_remaining": 180,
  "limits": { "max_organizations": 3, "max_users": 50, "max_assets": 5000 },
  "features": [ "controls", "evidence", "risk", "tasks", "assets", "exposure",
                "suppliers", "incidents", "reports", "audit", "ai_assistant",
                "connectors", "webhooks", "api_tokens", "custom_branding" ],
  "key_fingerprint": "…16 hex chars…",
  "usage": { "organizations": 1, "users": 3 }
}

The status flips from evaluation to licensed. If the key is malformed or forged, the API rejects it and silently falls back to Evaluation with status: "invalid" and an explanatory message — re-check the pasted value if you see that.

Editions at a glance

Enforcement is conservative — only concrete numeric limits are enforced (null = unlimited never trips), and gated premium modules return 402 on lower editions. Evaluation and Enterprise include every feature and are unlimited, so demos and evaluations are never blocked.

EditionIntended useOrgsUsersAssetsNotable features
EvaluationTrials, demos (default when unlicensed)All
CommunitySingle small entity15100Core GRC (controls, evidence, risk, tasks, assets, exposure, incidents, reports, audit)
ProfessionalMid-size entity3505 000Core + suppliers, AI assistant, connectors, webhooks, API tokens, custom branding
EnterpriseLarge entity / MSPAll, incl. SSO and MSP portfolio

(unlimited) is encoded as null. A key may override the default limits/features per contract (e.g. Professional with 100 users).

Fully offline No key is ever sent anywhere for validation — verification is a local signature check against the embedded public key. Your instance never needs outbound network to license itself.
Phase 3 of 3

Configure everything

With the instance running and licensed, this phase gets you to a working GRC programme: sign in, complete the onboarding wizard, add your team with the right roles, connect a data source, and walk the core loop (control → evidence → approval; risk → task; incident; reports).

First login

Open https://<server-host>/ and sign in with the admin credentials from install. On the login page, enter your organization slug — the page renders the available sign-in methods (password always; LDAP/SAML only if configured) — then the admin email + password. On a self-signed cert, clients warn until you trust the cert/CA — or install a real certificate in the UI, see 3b below.

https://cerynix.example.com/login
Cerynix sign-in page with SSO, MFA and password options
Sign-in page — SSO / MFA / password.
Expected result
Login succeeds and the app navigates to /command-center. If onboarding is not
yet complete, it routes to /onboarding instead (that is Phase 3c).
https://cerynix.example.com/command-center
Cerynix Command Center dashboard showing NIS2 readiness, risk and evidence
Command Center — real-time NIS2 readiness, risk & evidence.
Turn on MFA now As the admin, enrol MFA immediately: Settings → Security → Enable MFA (POST /auth/mfa/enroll → scan the otpauth:// URI → confirm the 6-digit code). Save the one-time backup codes — they are shown exactly once.

Install a TLS certificate

Out of the box the built-in reverse proxy serves a self-signed certificate, so your browser warns on the first visit — that is expected on a fresh install and does not mean anything is broken. Replace it with a real certificate whenever you are ready; like everything else in this phase it is done in the web UI — no SSH, no file copying.

  1. Go to Settings → TLS certificate.
  2. Paste the PEM certificate (including the full chain) into the certificate box.
  3. Paste the matching unencrypted private key into the key box.
  4. Click Apply certificate.

The certificate and key are validated, then the built-in reverse proxy hot-reloads within about 15 seconds — no restart and no downtime. Reload the page over HTTPS and the browser warning is gone.

Expected result
Settings → TLS certificate shows the active certificate's subject, issuer and
expiry; reloading https://<server-host>/ presents the trusted certificate with
no browser warning.
Where it is stored The certificate and key are written to the proxy's certificate volume and are never stored in the database — the same mechanism referenced in the manual install's TLS step, done here in the UI instead of at bring-up.

Onboarding wizard

Go to /onboarding. The wizard seeds the control set and dashboards you will work from. It requires the onboarding:manage permission (the admin and security_manager roles have it). No shell commands are needed — follow it to completion.

https://cerynix.example.com/onboarding
Cerynix onboarding wizard for selecting frameworks and scoping the organization
Onboarding wizard — frameworks, scope, baseline, finish.
  1. Step 1 — Frameworks & profile. Fill the company profile (name, sector, employee count) and select your compliance goals — include NIS2 if in scope. Save.
  2. Step 2 — NIS2 scope (shown only if NIS2 was selected). Answer the scoping questions and run it. Review the result: in-scope yes/no, entity type, confidence, reasoning, recommended next steps. If it flags requires_legal_confirmation, note it for your legal owner.
  3. Step 3 — Maturity baseline. Answer the baseline questions and submit. Review the overall maturity % across domains.
  4. Step 4 — Finish. Click finish. This derives the framework libraries from your chosen goals, initializes the control assessments, and marks the org onboarded.
Expected result
GET /onboarding/status returns onboarding_completed: true and
controls_initialized: true. Navigating to /controls now shows a populated
control library (not an empty list).

Invite users & assign roles

Add your team at Settings → Members (/settings, “Members” tab). Managing members requires the members:manage permission — only admin has it (a CISO can read members but not manage them). RBAC is enforced on every request.

The five roles

Role keyNameWhat it can do
adminOrganization AdministratorFull access — members, settings, all modules.
security_managerSecurity Manager / CISOOwns the programme: controls (assess/manage/approve), evidence (write/approve), risk, tasks, suppliers, incidents, connectors, reports, audit read, onboarding. No member/settings/org management.
it_managerIT Infrastructure ManagerExecutes remediation: assess controls, write evidence, own assets/exposure & tasks, manage connectors.
auditorAuditor / ConsultantRead-only across the workspace, plus audit-log visibility and report generation.
executiveBoard / ExecutiveOversight: dashboards, reports, control read + approve, risk read + accept.

Invite each user

  1. Click Invite member, and enter the email, full name, chosen role, and a temporary password (min 10 characters).
  2. If the email is new, a user is created (consuming a license seat — checked against the edition's max_users). If it already exists on the instance, they are added to this org (a duplicate membership is rejected).
  3. Communicate each temporary password over a secure channel; instruct users to change it and enrol MFA.
Expected result
Each invited user appears in the Members table with their role; a
member.invited audit event is recorded. Recommended minimum for a pilot:
1 admin (MFA on), >=1 security_manager (CISO), optionally 1 it_manager, and
1 auditor and/or executive for read/report access.
Guardrails Change a role from the Members table. The system refuses to demote the last remaining admin, and you cannot remove your own membership or the last admin. All changes are audited. Enterprise customers can wire up LDAP / SAML SSO / SCIM under Settings → Security.

Connect your first connector

Populate your inventory and findings from a real source at Integrations (/integrations). Creating a connector requires connector:manage (admin, CISO, or IT manager). The catalog is served from GET /connectors/catalog.

Pick a connector

CategoryKinds
Importcsv_asset_import, json_asset_import, http_json (Generic HTTP/JSON)
Identityentra_id (Microsoft Entra ID)
Endpoint / XDRintune, defender, forticlient_ems, trend_vision_one
Network / Loggingfortigate, fortianalyzer
Vulnerabilitytenable (Tenable / Qualys)
Patch / Monitoring / SIEMaction1, zabbix, splunk
Infrastructure / ITSMvmware (vCenter), jira

Easiest first connector: a CSV/JSON asset import or Generic HTTP/JSON — no third-party credentials needed — to populate the asset inventory quickly. For a live source, Entra ID or Defender demonstrate real value.

Create, test, run

  1. Create the connector with a name, kind, optional schedule, config, and — for live vendors — the secret credential (stored encrypted; unknown kinds are rejected).
  2. Test the credentials without a full sync — validates connectivity and reports latency; no run is recorded.
  3. Run it — for import connectors upload the file; for live vendors it pulls on demand.
Expected result
The latest run shows status "succeeded" with non-zero imported asset/finding
counts. New assets appear under Assets/Exposure (/assets, /exposure) and any
findings under Alerts (/alerts). Every connector action is audited.

The core GRC loop

This is the day-to-day cycle. Walk it once end-to-end to confirm the instance is fully working; each screen renders populated with your reference data.

Control → evidence → approval

  1. Assess a control. Open Controls (/controls), open a control, set an assessment status + maturity level and save (needs control:assess).
  2. Upload evidence. Open Evidence (/evidence), upload a file with a title, confidentiality level and personal-data flag (needs evidence:write). The file is hashed (SHA-256) and stored.
  3. Link the evidence to the assessed control.
  4. Approve the evidence (as admin or CISO, evidence:approve), then approve the control assessment (control:approve).
Expected result
Assessment saved with a computed next_test_date; evidence created (201) and
linked; approving recomputes the control's evidence freshness. Setting a
control flagged "critical" to a failing status auto-creates a remediation Task
and emits a control.failed event. Every step writes an audit event.
https://cerynix.example.com/controls
Cerynix control matrix listing controls with framework mappings
Control Matrix — controls with framework mappings.
https://cerynix.example.com/controls/…
Cerynix control assessment detail view
Control assessment view.
https://cerynix.example.com/evidence
Cerynix evidence vault, tamper-evident and freshness-tracked
Evidence Vault — tamper-evident, freshness-tracked.

Risk → task

  1. Open Risks (/risks) and create a risk with likelihood/impact and a treatment (needs risk:write).
  2. Create a remediation Task (/tasks) linked to that risk (and optionally to a control).
  3. Update the task status as work proceeds; optionally accept a risk (risk:accept).
Expected result
Risk created with a generated reference and computed inherent/residual scores;
a residual score >= 15 emits a "High residual risk" notification. The task gets
a TASK- reference linked to the risk; status changes persist and are auditable.
https://cerynix.example.com/risks
Cerynix risk ledger with residual risk heatmap
Risk Ledger — residual heatmap.

Incident timeline (NIS2 reporting)

  1. Open Incidents (/incidents); optionally review the available playbooks.
  2. Create an incident with a severity (needs incident:write).
  3. Open the incident detail, then add timeline events and update its status as it evolves.
Expected result
Incident created with an INC- reference; a "created" timeline event is
auto-added and a "NIS2 reporting clock started" notification is emitted. The
detail shows a computed NIS2 reporting section (deadlines derived from the
incident), the ordered timeline, and the linked playbook if one is set.
https://cerynix.example.com/incidents
Cerynix incident timeline showing the NIS2 reporting clock
Incident Timeline — NIS2 reporting clock.

Generate SoA & board reports

Open Reports (/reports); GET /reports lists the available types. Generate the Statement of Applicability (PDF, and also CSV / XLSX / DOCX), snapshot a SoA version, and generate the Executive readiness PDF and the audit-evidence / technical-remediation / supplier-risk / country-profile PDFs. Report generation needs report:read / report:generate.

Expected result
Each requested file downloads and opens, reflecting the org's current data
(controls, evidence, risks). SoA versions can be created, listed and exported.
https://cerynix.example.com/reports
Cerynix board reports with SoA and executive readiness exports
Board Reports — SoA + executive readiness (PDF/CSV/XLSX).
Configuration complete You now have a licensed, onboarded instance with your team, a live data source, and the full control → evidence → risk → incident → report loop working. From here it is day-to-day operation. Verify the audit ledger any time under Settings → Security → Verify the audit hash chain.
After setup

Backups

Backups are client-side age-encrypted before touching disk (backup.sh aborts if BACKUP_AGE_RECIPIENT is unset, so it can never write plaintext):

run a backup
cd /opt/cerynix/app
scripts/backup.sh      # -> backups/<timestamp>/{db.sql.gz.age,evidence.tar.age}

Schedule a nightly dump (14-day retention via BACKUP_RETENTION_DAYS):

crontab
30 2 * * * cd /opt/cerynix/app && bash scripts/backup.sh >> /var/log/cerynix-backup.log 2>&1

Restore (needs the off-box private identity you stored in Phase 1):

restore
BACKUP_AGE_IDENTITY=/path/to/cerynix-backup.key \
  scripts/restore.sh backups/<timestamp>
Prove your backups restore Run the drill periodically — it restores the latest backup into a throwaway container and measures RPO/RTO without touching production: BACKUP_AGE_IDENTITY=/path/to/cerynix-backup.key scripts/restore-drill.sh. And copy backups off-box (see scripts/backup-r2.sh) — a backup only on the same VM is not a backup.