HeadHunter System administration

System administration & support

This is the handbook for whoever deploys, runs, and supports a HeadHunter instance — Waves Technology's platform operator. Unlike every other manual in this set, it isn't scoped to an NGO role: you sit outside any organization, on your own host, and your job is to keep the platform healthy for everyone on it without ever reaching into a tenant's data unless you're explicitly, temporarily invited.

It comes in two halves:

The four principals — know which realm you're in

HeadHunter has four kinds of user, and their sessions are deliberately kept apart so a weakness in one can't cross into another:

Principal Signs in on Session cookie Signing secret
Org staff (org_admin, hr_manager, recruiter, compliance_officer) app.<domain> hh_session (8h) SESSION_SECRET
External reviewer (hiring_manager) app.<domain> hh_session (rides the org realm, posting-scoped) SESSION_SECRET
Platform operator (you) ops.<domain> hh_ops_session (2h) OPS_SESSION_SECRET
Candidate (job-seeker) the marketing apex <domain> hh_candidate_session CANDIDATE_SESSION_SECRET

One monolith serves all three hosts and routes by Host header. The ops session is separate by design — a shorter 2-hour life and its own secret isolate the operator console's blast radius from the org and candidate realms. Your account lives in platform_admins, not in any org's users table.


Part A — the platform operator console

Signing in

Go to /ops/login on the ops host (ops.localhost in dev, ops.<domain> in production). You authenticate against the platform-admin realm, which is entirely separate from every org's login — a wrong email and a wrong password return the same generic error, so the page never reveals whether an account exists. On success you get a 2-hour hh_ops_session; each request re-reads your record and re-confirms your account is still active. There is no self-serve signup — operators are provisioned from the command line (see Provisioning in Part B).

Note: the ops login does not yet have MFA or brute-force lockout — that's a planned fast-follow. Keep the ops host access tight at the network layer in the meantime.

The ops console sign-in
The ops console sign-in

The four operator roles

Your capabilities come from your platform role (platform_admins.role), a map kept completely separate from org RBAC so an operator can never inherit a tenant-scoped grant:

Role Can do
superadmin Everything — including managing other platform admins, suspending orgs, and requesting break-glass.
support Layers 1 and 2, and may request break-glass (Layer 3 only opens under a live org grant).
readonly Layer 1 and read-only Layer 2. No break-glass, ever.
ai_agent Layers 1–2 programmatically. Never eligible for break-glass, even structurally.

The rule to internalize: only superadmin and support can ever reach real candidate data, and only under an active grant. readonly and ai_agent are blocked from break-glass not just by the permission map but by a second structural check — loosening the map wouldn't let them in.

The three layers of access

The console is built as three concentric layers. You spend almost all your time in the outer two, which never expose personal data; the innermost is a rare, consent-gated exception.

Layer 1 — the aggregate dashboard (/ops/dashboard)

Your daily heartbeat view, PII-free by construction — its only data source computes counts and groupings and selects no personal columns (a test enforces that no PII column name even appears in it). It shows:

A Layer-1 read is monitoring, not an action, so it writes no audit row.

What Layer 1 does not show: there are no candidate metrics here — no candidate-account count, no verification counts. The only "signups" figure is new orgs per month. Candidate accounts are self-serve on the marketing site and are not surfaced in the operator dashboard.

Layer 1 — the aggregate dashboard
Layer 1 — the aggregate dashboard

Layer 2 — per-org diagnostics (/ops/orgs, /ops/orgs/[id])

When an org needs support, start here. The directory (/ops/orgs) lists one row per tenant — status, subscription tier, whether suspended, and counts of users, open postings, and applications — with an optional ?q= search on the org name. It's PII-free and the directory read isn't audited.

Open an org to drill in. Every drill-in is audited (org.viewed), and what you see is deliberately redacted:

From here a superadmin can suspend or reactivate a tenant (gated on platform.org.suspend); both actions are audited (org.suspended / org.reactivated).

Layer 2 — redacted per-org diagnostics
Layer 2 — redacted per-org diagnostics

Layer 3 — break-glass (/ops/orgs/[id]/breakglass)

This is the only path to real, un-redacted candidate data (name, email, phone, location, whether a CV exists), and it is fenced on every side. Treat it as the rare exception it is.

How a grant is created (the org side). The organization opens Settings → Support access (/settings/support-access on their app. host — an org_admin action). They pick a named, eligible operator, give a reason and an optional support reference, and set a TTL within the 1–72h window. That writes a grant with an expiry and audits it on both sides — the org's own trail (support_access.granted) and the operator mirror (breakglass.granted). The org can revoke instantly at any time, which is likewise mirrored. A grant is "live" only while it is unexpired and un-revoked.

Layer 3 — consent-gated break-glass
Layer 3 — consent-gated break-glass

The shape to remember: the org holds the key. You can ask, but you only ever see a tenant's real data while that tenant is actively, knowingly, and temporarily letting you — and every second of it is on the record for both of you.


Part B — deploy, run, operate

Stack & topology

HeadHunter is a boring monolith run with Docker Compose — four services, one command to bring up:

It's designed to fit a modest Hostinger EU VPS (the EU hosting is also the GDPR trust signal in the product's privacy promise) — a KVM 1 (1 vCPU / 4 GB) suffices for a pilot; KVM 2 (2 vCPU / 8 GB) gives headroom. Persistent state lives in two named volumes: postgres_data (the database) and cv_storage mounted at /app/uploads (raw CV files — PII, kept off the image and out of git). The app and parser run hardened (no-new-privileges, and the app drops all Linux capabilities but the few it needs).

Lifecycle — ./headhunter.sh

Everything routine goes through the lifecycle script at the repo root:

./headhunter.sh setup      # first run: create .env (with strong random secrets) + a self-signed cert
./headhunter.sh up         # build + start the stack (docker compose up -d --build)
./headhunter.sh status     # container state (docker compose ps)
./headhunter.sh logs       # follow the app logs (Ctrl-C to stop)
./headhunter.sh down       # stop everything — the postgres_data volume is preserved
./headhunter.sh restart    # down + up

setup copies .env.example.env and fills DB_PASSWORD, SESSION_SECRET, and OPS_SESSION_SECRET with openssl rand values. In production, set CANDIDATE_SESSION_SECRET explicitly — see Configuration & secrets. Pass -y / --yes to skip confirmation prompts in scripted use.

Provisioning

There is no self-serve signup for orgs or operators — both are created deliberately from the command line. Run these from /app with DATABASE_URL set (or via the headhunter.sh wrappers, which run them against the live db container).

A new organization and its first org_admin:

npm run provision -- \
  --org-name "Mercy NGO" --org-name-ar "منظمة الرحمة" \
  --email admin@mercy.org --password 'StrongPass123' --full-name "Aisha Ali" \
  [--role org_admin] [--locale ar] [--tier pilot] [--pool-entitlement]
# or: ./headhunter.sh provision --org-name "Mercy NGO" --email admin@mercy.org ...

A platform operator (the first superadmin is created this way; further operators are then managed in-console):

npm run provision-admin -- \
  --email ops@waves.tech --password 'StrongPass123' --full-name "Operator Name" \
  [--role superadmin|support|readonly|ai_agent]
# or: ./headhunter.sh provision-admin --email ops@waves.tech ... [--role support]

Role defaults to superadmin if omitted. Re-running with an existing email is rejected (emails are globally unique). Passwords are hashed with Argon2id; a minimum length of 8 is enforced.

Database & migrations

Schema changes are numbered SQL migrations0NNN_name.sql with a matching 0NNN_name_down.sql — under postgres/migrations/. init.sql is the baseline only and is never edited for live schema.

npm run db:migrate         # apply pending migrations (tsx scripts/migrate.ts up) — needs DATABASE_URL
npm run db:rollback        # roll back the most recent migration (down)
# via the stack: ./headhunter.sh migrate | rollback | db-reset

On a fresh database, the compose db container runs apply-migrations.sh after init.sql, applying every migration in order automatically. The /health endpoint reports the applied-migration count, so you can confirm a deploy is fully migrated at a glance. ./headhunter.sh db-reset wipes the DB volume and rebuilds a clean baseline — destructive; it asks for confirmation.

Configuration & secrets

Configuration is environment variables in .env (never committed). The ones that matter most:

Variable Purpose
DATABASE_URL Postgres connection string (compose builds it from DB_PASSWORD).
SESSION_SECRET Signs the org hh_session. The base secret the others fall back to.
OPS_SESSION_SECRET Signs the operator hh_ops_session. Falls back to SESSION_SECRET if unset/empty.
CANDIDATE_SESSION_SECRET Signs the candidate hh_candidate_session. Set it explicitly in production.
APP_HOST / MARKETING_HOST / OPS_HOST The three hosts the monolith routes by (app. / apex / ops.).
PARSER_URL The parser sidecar (http://parser:8000 on the compose network).
STORAGE_DIR Where raw CV files live on disk (/app/uploads, the cv_storage volume).
ANTHROPIC_API_KEY Optional LLM key (JD drafting only; the feature degrades gracefully if unset).

Two footguns worth calling out:

Outbound email / WhatsApp and the LLM are provider-agnostic. For dev and pilot without a live provider, outbound messages are written as JSON lines to a file outbox (COMMS_OUTBOX_FILE) instead of actually being sent — useful, but see the candidate-realm caveat below.

The candidate realm — operating the 4th principal

Phase 5 added the candidate account: an internet-facing, self-serve realm at scale, so it earns its own operational attention.

Health & monitoring

Support runbook

Route every request through the least-privileged path that answers it. Redacted Layer 2 first; break-glass only as a consent-gated last resort.

The ask Where to start
"An org user is locked out / needs a reset" The org's own org_admin manages their users. Only escalate to Layer 2 to confirm the org's state.
"Why didn't this CV parse?" Layer 2 parser-failures for the org (candidate masked to initials) + the L1 parse-queue health.
"Our pool access stopped working" Layer 2 — check the org's subscription tier / entitlement; billing is manual (cash/Bankak), recorded by the org_admin.
"We need you to look at a specific candidate's data" The org grants support access (/settings/support-access); then, and only then, Layer 3 break-glass under that live grant.
Candidate: "my verification email never arrived" Self-serve resend on /me first; check your email deliverability into Sudan. Operator action is a fallback, not the default.
Candidate: "delete my account" / "export my data" The self-serve /me/settings Export and Delete flows. You act only if they truly can't.
Suspected abuse / signup flood L1 volume + the signup rate-limit / honeypot; a superadmin can suspend an org from Layer 2 if needed.

Backups. The durable state is the postgres_data volume plus the cv_storage (/app/uploads) CV files. Back up both together on the VPS — the database alone, without the CV files it references, is an incomplete restore. (./headhunter.sh down preserves the volumes; only db-reset destroys the database.)


Quick reference

I want to… Do this
Sign in to the operator console /ops/login on ops.<domain>
See platform-wide health Layer 1 dashboard (/ops/dashboard) — PII-free
Investigate one org Layer 2 (/ops/orgs/[id]) — redacted, audited
See a candidate's real data Only via Layer 3 break-glass, under a live org grant
Bring the stack up / down ./headhunter.sh up / down
Create an org + first admin npm run provision -- --org-name … --email … --password … --full-name …
Create an operator npm run provision-admin -- --email … --password … --full-name … [--role …]
Apply / roll back schema npm run db:migrate / db:rollback (needs DATABASE_URL)
Purge never-verified candidates npm run candidate-retention -- [--days 30] [--dry-run]
Check liveness GET /health (DB + migrations + parser)
Back up The postgres_data and cv_storage (/app/uploads) volumes, together

You run the platform that other people's careers and hiring decisions rest on. The whole design here points one way: keep it healthy from the aggregate view, help tenants from a redacted distance, and reach into real data only when a tenant is actively, temporarily holding the door open for you.