Self-hosted deployment

On-prem installation guide

A single-VM, self-hosted install of Cerynix using Docker Compose. Follow it end-to-end and your company will be running its own instance on its own hardware — no cloud dependency, no phone-home. This is the generic on-prem procedure; every command below is copy-paste ready.

Before you start You need shell access to a Linux VM and about 30 minutes. All commands run from the checkout directory /opt/cerynix/app unless noted. The screenshots show the product you'll see once the stack is up.
Recommended

Quick install (one command)

The install.sh script at the repo root takes a clean Linux host to a running, admin-ready instance with one command. It generates every secret for you (nothing to copy by hand), writes a production .env, builds and starts the stack, waits until it is healthy, creates your first admin, and prints how to log in. Use this unless you need the manual control below (air-gapped images, a custom TLS cert at install time).

Prerequisites

  • Docker Engine + the Docker Compose v2 plugin, with the daemon running and reachable by the user running the script.
  • openssl — used to generate the secrets.
  • age (optional) — if age-keygen is present the script also creates a backup-encryption key; otherwise it skips it and you can set up backups later (step 11).

One command

clone & install
git clone https://github.com/gruvX/nis2-sentinel.git nis2-sentinel
cd nis2-sentinel
./install.sh

Then answer the 3–4 prompts: hostname or IP clients will use, first admin email, organization name, and organization country (ISO code or EU, defaults to EU). The admin password is optional — leave it off and the script auto-generates a strong one and prints it at the end.

Non-interactive (automation)

Pass everything up front so nothing is prompted (add -y/--yes to hard-fail if a required value is still missing):

non-interactive install
./install.sh \
  --hostname cerynix.acme.internal \
  --admin-email admin@acme.eu \
  --admin-password 'S0me-Strong-Passphrase' \
  --org "ACME SIA" --country LV
FlagEnv varMeaning
--hostnameCERYNIX_HOSTNAMEHostname or IP clients use (drives TLS SAN + CORS + API URL)
--admin-emailCERYNIX_ADMIN_EMAILFirst admin login email
--admin-passwordCERYNIX_ADMIN_PASSWORDFirst admin password (omit to auto-generate)
--orgCERYNIX_ORGOrganization / company name
--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)
-y, --yesNever prompt; fail if a required value is missing

What it does automatically

  1. Checks prerequisites (docker, compose v2, openssl, a live daemon) and warns if ports 80/443 are in use.
  2. Generates all secrets — SECRET_KEY, both DB passwords and the object-store password — as URL/shell-safe values.
  3. Writes a hardened production .env (chmod 600) from .env.onprem.example: production mode, RLS on, self-registration off, demo data off.
  4. Builds and starts the stack (docker compose … up -d --build).
  5. Waits for the API to report healthy (migrations + first-boot); on timeout it dumps recent api/db logs and exits non-zero.
  6. Creates your first admin (app.scripts.create_admin).
  7. Prints the login URL, admin email, and — if it generated one — the password.
One manual follow-up If a backup key was created, move ./cerynix-backup.key off the server (vault / password manager) and delete it here. It is the only way to decrypt backups and must not live on the box it protects. Everything else is done.

Manual install (advanced)

The 11 steps below do by hand exactly what install.sh automates. Reach for them when you need finer control than the one-command flow — 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.

Prerequisites & code

Cerynix runs on a modern Linux x86-64 host (Ubuntu 24.04 LTS tested) 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 the toolchain:

verify Docker
docker --version
docker compose version          # must print v2.x

Install age on the box for encrypted backups (step 11):

install age
sudo apt-get update && sudo apt-get install -y age

Get the code

Clone the repository; everything below runs from /opt/cerynix/app.

clone
sudo install -d -o "$USER" /opt/cerynix
git clone https://github.com/gruvX/nis2-sentinel.git /opt/cerynix/app
cd /opt/cerynix/app
Air-gapped? The stack builds its own images from source (--build) — there's 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 (step 5) without --build.

Generate secrets

The production overlay and the app refuse to start on placeholder secrets, so generate real ones now. Run each command and keep the output for the matching .env key in step 3:

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.

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

Set at minimum (each is marked CHANGE ME in the template):

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.

TLS

The bundled nginx reverse proxy terminates TLS on :443 and serves the web app and API under one origin (same-origin calls, no mixed content). 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, so you can skip ahead. To mint a longer-lived cert (e.g. a 10-year internal cert covering a hostname and an IP) and install it yourself:

self-signed cert
# 1) Generate a private key + self-signed cert (SANs must match how clients connect).
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"

# 2) Install into the running proxy's shared volume; it hot-reloads within ~15s.
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 lying around on disk

Alternatively 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). Clients warn until you trust the cert/CA.

Option B — Let's Encrypt (public domain)

Point an A record at the server, confirm it resolves, then issue + install (briefly stops the proxy to free port 80 for the ACME challenge):

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

Automate renewal in root's crontab (certbot renews only when near expiry):

crontab
0 3 * * 1 DOMAIN=cerynix.example.com EMAIL=you@example.com \
  /opt/cerynix/app/scripts/deploy/renew-cert.sh >> /var/log/cerynix-cert.log 2>&1

Bring the stack up

Build and start with the base file plus the production overlay, stamping build provenance so the running containers can prove their commit:

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. Watch it settle:

watch
docker compose -f docker-compose.yml -f docker-compose.prod.yml ps
docker compose logs -f api | grep -iE "alembic|seed|Starting API"
Two gotchas
  • Port 5432 conflict — only if the host already runs its own PostgreSQL. Cerynix's db is loopback-only, but a host Postgres on 5432 will collide; stop it or remap before bring-up.
  • The api looks briefly unhealthy until the database is up and migrations finish — this is expected and converges in about 30 seconds. Give it a moment, then re-check health in step 6.

Health & RLS check

Confirm the process is up, the DB is reachable, and the TLS proxy answers:

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

Confirm RLS is actually engaged (the app must be a non-superuser):

verify RLS
docker compose exec db psql -U cerynix -d cerynix \
  -c "select rolname, rolsuper from pg_roles where rolname='cerynix_app';"
Expected
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.

First-admin bootstrap

A fresh production instance has reference data but no user accounts and REGISTRATION_ENABLED=false. There are two supported ways to create the first organization + admin — use one.

7a. Bootstrap script (recommended — idempotent, scriptable)

app.scripts.create_admin creates the org + admin from environment variables and is idempotent: if the admin email already exists it exits 0 without changing anything.

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 output
[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. You can instead put BOOTSTRAP_* in .env and drop the -e flags.

7b. Register endpoint (no shell into the container)

POST /auth/register is allowed for the very first account only even when registration is disabled (once one user exists it returns 403). Through the TLS proxy:

register (first account only)
curl -sk https://cerynix.example.com/api/v1/auth/register \
  -H 'Content-Type: application/json' \
  -d '{"email":"admin@example.com","password":"<strong password>",
       "full_name":"Jane Admin","organization_name":"Example Company SIA"}'

A 201 with an access/refresh token pair means the admin org + user exist.

After bootstrapping Add further team members by invite from the web UI — self-registration stays closed.

First login

Open https://<your-host>/ in a browser and sign in with the admin credentials from step 7. (On a self-signed cert, clients warn until you trust the cert/CA — import it into the client trust store to silence that.)

https://cerynix.example.com/login
Cerynix sign-in page with SSO, MFA and password options
Sign-in page — SSO / MFA / password.

On successful login you land on the Command Center — a real-time view of NIS2 readiness, risk and evidence across your organization.

https://cerynix.example.com/dashboard
Cerynix Command Center dashboard showing NIS2 readiness, risk and evidence
Command Center — real-time NIS2 readiness, risk & evidence.

Onboarding

First-run onboarding walks you through selecting frameworks (NIS2, ISO/IEC 27001, GDPR) and scoping your organization, which seeds the control set and dashboards you'll work from. Follow the wizard to completion — no shell commands are needed here.

https://cerynix.example.com/onboarding
Cerynix onboarding wizard for selecting frameworks and scoping the organization
Onboarding wizard — frameworks & scoping.

Smoke test

With the org bootstrapped and onboarding done, click through the core modules to confirm the instance is fully functional. Each should render populated with your reference data. (You can re-run the readiness probe any time:)

re-check readiness
curl -sf http://127.0.0.1:8000/readyz && echo " READY"

Control Matrix — the 135 controls with framework mappings should list and filter.

https://cerynix.example.com/controls
Cerynix control matrix listing 135 controls with framework mappings
Control Matrix — 135 controls with framework mappings.

Control assessment — open a control to see its assessment view.

https://cerynix.example.com/controls/…
Cerynix control assessment detail view
Control assessment view.

Evidence Vault — tamper-evident, freshness-tracked evidence.

https://cerynix.example.com/evidence
Cerynix evidence vault, tamper-evident and freshness-tracked
Evidence Vault — tamper-evident, freshness-tracked.

Risk Ledger — the residual risk heatmap.

https://cerynix.example.com/risks
Cerynix risk ledger with residual risk heatmap
Risk Ledger — residual heatmap.

Incident Timeline — the NIS2 reporting clock.

https://cerynix.example.com/incidents
Cerynix incident timeline showing the NIS2 reporting clock
Incident Timeline — NIS2 reporting clock.

Board Reports — SoA + executive readiness export (PDF/CSV/XLSX).

https://cerynix.example.com/reports
Cerynix board reports with SoA and executive readiness exports
Board Reports — SoA + executive readiness (PDF/CSV/XLSX).

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 from step 2):

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:

restore drill
BACKUP_AGE_IDENTITY=/path/to/cerynix-backup.key scripts/restore-drill.sh
Copy backups off-box A backup only on the same VM is not a backup. Ship them to separate storage (see scripts/backup-r2.sh).