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.
/opt/cerynix/app unless noted. The
screenshots show the product you'll see once the stack is up.
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) — ifage-keygenis present the script also creates a backup-encryption key; otherwise it skips it and you can set up backups later (step 11).
One command
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):
./install.sh \
--hostname cerynix.acme.internal \
--admin-email [email protected] \
--admin-password 'S0me-Strong-Passphrase' \
--org "ACME SIA" --country LV
| Flag | Env var | Meaning |
|---|---|---|
--hostname | CERYNIX_HOSTNAME | Hostname or IP clients use (drives TLS SAN + CORS + API URL) |
--admin-email | CERYNIX_ADMIN_EMAIL | First admin login email |
--admin-password | CERYNIX_ADMIN_PASSWORD | First admin password (omit to auto-generate) |
--org | CERYNIX_ORG | Organization / company name |
--country | CERYNIX_COUNTRY | Organization country, ISO code or EU (default EU) |
--no-backup-key | — | Skip creating the age backup key |
--force | — | Regenerate .env even if one exists (rotates all secrets) |
-y, --yes | — | Never prompt; fail if a required value is missing |
What it does automatically
- Checks prerequisites (docker, compose v2, openssl, a live daemon) and warns if ports 80/443 are in use.
- Generates all secrets —
SECRET_KEY, both DB passwords and the object-store password — as URL/shell-safe values. - Writes a hardened production
.env(chmod 600) from.env.onprem.example: production mode, RLS on, self-registration off, demo data off. - Builds and starts the stack (
docker compose … up -d --build). - Waits for the API to report healthy (migrations + first-boot); on timeout it dumps recent
api/dblogs and exits non-zero. - Creates your first admin (
app.scripts.create_admin). - Prints the login URL, admin email, and — if it generated one — the password.
./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.
| Requirement | Minimum | Recommended |
|---|---|---|
| Docker Engine | 24.x | latest stable |
| Docker Compose | v2 (plugin form) | latest |
| RAM | 4 GB | 8 GB |
| Disk | 20 GB free | 50 GB+ |
| CPU | 2 vCPU | 4 vCPU |
Verify the toolchain:
docker --version
docker compose version # must print v2.x
Install age on the box for encrypted backups (step 11):
sudo apt-get update && sudo apt-get install -y age
Get the code
Clone the repository; everything below runs from /opt/cerynix/app.
sudo install -d -o "$USER" /opt/cerynix
git clone https://github.com/gruvX/nis2-sentinel.git /opt/cerynix/app
cd /opt/cerynix/app
--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:
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):
age-keygen -o cerynix-backup.key
# prints: Public key: age1........ <- the RECIPIENT (public) key
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:
cp .env.onprem.example .env
chmod 600 .env
$EDITOR .env
Set at minimum (each is marked CHANGE ME in the template):
| Key | Value |
|---|---|
SECRET_KEY | the openssl rand -hex 32 output |
POSTGRES_PASSWORD | an openssl rand -base64 24 output |
APP_DB_PASSWORD | a different openssl rand -base64 24 output |
S3_SECRET_KEY and MINIO_ROOT_PASSWORD | the same rand -base64 24 value (must match) |
DATABASE_URL | replace the password portion to match APP_DB_PASSWORD |
BACKUP_AGE_RECIPIENT | the age1... public key from step 2 |
CORS_ORIGINS | your https:// hostname(s), comma-separated — never * |
TLS_CN / TLS_SAN | how 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).
.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:
# 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):
sudo DOMAIN=cerynix.example.com [email protected] \
scripts/deploy/renew-cert.sh
Automate renewal in root's crontab (certbot renews only when near expiry):
0 3 * * 1 DOMAIN=cerynix.example.com [email protected] \
/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:
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:
docker compose -f docker-compose.yml -f docker-compose.prod.yml ps
docker compose logs -f api | grep -iE "alembic|seed|Starting API"
- Port 5432 conflict — only if the host already runs its own
PostgreSQL. Cerynix's
dbis loopback-only, but a host Postgres on5432will collide; stop it or remap before bring-up. - The
apilooks 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:
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):
docker compose exec db psql -U cerynix -d cerynix \
-c "select rolname, rolsuper from pg_roles where rolname='cerynix_app';"
cerynix_app | f (rolsuper = f => RLS is enforced against the app)
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.
docker compose -f docker-compose.yml -f docker-compose.prod.yml exec \
-e BOOTSTRAP_ADMIN_EMAIL='[email protected]' \
-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
[bootstrap] created org 'Example Company SIA' (slug=example-company-sia) and admin user [email protected]. 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:
curl -sk https://cerynix.example.com/api/v1/auth/register \
-H 'Content-Type: application/json' \
-d '{"email":"[email protected]","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.
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.)
On successful login you land on the Command Center — a real-time view of NIS2 readiness, risk and evidence across your organization.
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.
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:)
curl -sf http://127.0.0.1:8000/readyz && echo " READY"
Control Matrix — the 135 controls with framework mappings should list and filter.
Control assessment — open a control to see its assessment view.
Evidence Vault — tamper-evident, freshness-tracked evidence.
Risk Ledger — the residual risk heatmap.
Incident Timeline — the NIS2 reporting clock.
Board Reports — SoA + executive readiness export (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):
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):
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):
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
scripts/backup-r2.sh).