Audience: operators using the IRDB web UI day-to-day. Covers first-time setup, every screen and field, how the decay engine works, installation, and backup / disaster recovery.
This document is the operational guide. For architecture see
architecture.md; for API contracts see
api-overview.md; for authentication wiring see
auth-flows.md.
docker compose plugin.README.md § Reverse proxy).git clone <repo-url> irdb && cd irdb
cp .env.example .env
$EDITOR .env # fill in the secrets — see § 1.3
docker compose -f docker-compose.yml -f compose.scheduler.yml up -d
That brings up:
| Container | Port | Role |
|---|---|---|
migrate |
— | Runs Phinx migrations + seeds, exits 0. |
api |
8081 | JSON backend (reporters, consumers, admin endpoints, jobs). |
ui |
8080 | Browser-facing PHP+Twig BFF. |
scheduler |
— | (Overlay) busybox crond posting /internal/jobs/tick once/min. |
mysql |
3306 | (Optional) only if you uncomment the block and set DB_DRIVER=mysql. |
Health checks:
curl http://localhost:8080/healthz → UI status (api_reachable).curl http://localhost:8081/healthz → API status with DB + jobs
freshness.The api validates required env vars on boot, so misconfiguration
crashes docker compose up rather than the first user click.
Every value in .env marked as a secret must be generated. The full
recipe lives in README.md § Generating secrets;
the key ones are:
| Variable | How to generate |
|---|---|
INTERNAL_JOB_TOKEN |
openssl rand -hex 32 — Bearer for /internal/jobs/*. |
UI_SERVICE_TOKEN |
irdb_svc_… — bridge between UI and API (see .env.example for the one-liner). |
LOCAL_ADMIN_PASSWORD_HASH |
php -r "echo password_hash('your-pw', PASSWORD_ARGON2ID);" — double every $ to $$ in the .env file so docker-compose doesn't eat it. |
After docker compose up -d, sign in to the UI at
http://localhost:8080/ with the local admin credentials you set in
.env. The first session lands on the dashboard.
Do these in order — each step depends on the previous one:
Categories. Five seeded categories
(brute_force, spam, scanner, malware_c2, web_attack)
should be present, each with exponential decay and a 14-day
half-life. Open Policies; three seeded policies (strict,
moderate, paranoid) should be present.phishing), use Categories → New.
You can deactivate seeded categories you won't use by opening their
detail page and unchecking "active".Policies → moderate → Edit.
The threshold matrix has one row per category. Empty value = "this
category does not contribute to this policy". The default seed sets
every category to 1.0 for moderate, 2.5 for strict, 0.3
for paranoid. Tune to taste; the live preview at the bottom of
the edit page shows the resulting blocklist size.Reporters → New. Pick a name
(e.g. web-prod-01) and leave Trust weight at 1.0.Tokens → Issue, kind = reporter,
pick the reporter you just made. A modal shows the raw
irdb_rep_… token once. Copy it into your reporter's config
immediately — refreshing the page discards it permanently.Smoke-test ingestion. From any host that can reach the api:
curl -X POST http://<api-host>:8081/api/v1/report \
-H "Authorization: Bearer irdb_rep_..." \
-H "Content-Type: application/json" \
-d '{"ip":"203.0.113.42","category":"brute_force"}'
# → 202 with {"report_id": 1, ...}
Refresh the UI dashboard; counters should go up.
Create your first consumer. Consumers → New. Pick a policy
(moderate is a good default).
Issue a consumer token. Tokens → Issue, kind = consumer,
pick the consumer.
Smoke-test distribution.
curl http://<api-host>:8081/api/v1/blocklist \
-H "Authorization: Bearer irdb_con_..."
# → text/plain, one IP/CIDR per line
Set up scheduling. If you used compose.scheduler.yml, the
sidecar is already firing /internal/jobs/tick once a minute.
Otherwise install the host crontab from examples/scheduler/.
Without a tick driver, scores never decay.
Configure GeoIP (optional). Default provider is DB-IP
(no credential needed). Switch to MaxMind by setting
GEOIP_PROVIDER=maxmind + MAXMIND_LICENSE_KEY, or to IPinfo
with GEOIP_PROVIDER=ipinfo + IPINFO_TOKEN. Restart the api
and trigger refresh-geoip from Settings.
Map OIDC groups to roles (if using Entra). Until the
dedicated admin UI ships, populate oidc_role_mappings directly:
docker compose exec -T api sqlite3 /data/irdb.sqlite \
"INSERT INTO oidc_role_mappings(group_id, role) VALUES('<group-id>', 'admin');"
The default compose deployment serves plain HTTP. Production should
sit behind a reverse proxy that terminates TLS and routes by
hostname. A working Caddy config is in
examples/reverse-proxy/Caddyfile.
When deploying behind a proxy, set APP_ENV=production so HSTS,
Secure-cookie, and CSRF behave correctly.
Three roles are persisted on the user record (viewer, operator,
admin). They are the source of truth — the UI hides buttons the
current user can't use, but security is enforced server-side. If a
viewer crafts a direct URL to a write endpoint, the api returns
403.
| Action | viewer | operator | admin |
|---|---|---|---|
| Browse IPs / scores / history | ✓ | ✓ | ✓ |
| Browse audit log | ✓ | ✓ | ✓ |
| Add / remove manual blocks | ✓ | ✓ | |
| Manage allowlist | ✓ | ✓ | |
| Manage policies / categories | ✓ | ||
| Manage reporters / consumers | ✓ | ||
| Manage tokens | ✓ | ||
| Trigger jobs from Settings | ✓ | ||
| View Settings (config + jobs) | ✓ |
The local admin always has admin role. OIDC users get their role
from oidc_role_mappings; the default for unmapped users is
OIDC_DEFAULT_ROLE (viewer by default; set to none to deny
login outright).
The UI is laid out as a sticky top bar plus a left sidebar. The top
bar carries the IRDB logo (link to /app/me), a theme toggle, and
the user menu (My identity + Sign out). The sidebar lists every
section; the active section is highlighted.
All forms are CSRF-protected. Destructive actions (delete, revoke) open a confirmation modal before submitting.
/login — Sign-in screenShown when no session is active.
| Element | Purpose |
|---|---|
| Sign in with Microsoft | Initiates the OIDC authorization-code-with-PKCE flow against Entra. Visible when OIDC_ENABLED=true. |
| Use local sign-in | Toggle that reveals the username/password form. Visible when LOCAL_ADMIN_ENABLED=true. Hidden entirely otherwise. |
| Username field | Plaintext username; must match LOCAL_ADMIN_USERNAME. |
| Password field | Validated against LOCAL_ADMIN_PASSWORD_HASH (Argon2id) on the UI side. |
| Sign in button | Submits the form. After 5 failures the form is locked for 30 s (per-process; restarting the UI clears the lockout — that is the documented "unlock the admin" path). |
If neither method is enabled, the page shows an error pointing you at the env vars to set.
/no-accessShown when an OIDC sign-in succeeded but the user's groups didn't
match any row in oidc_role_mappings and OIDC_DEFAULT_ROLE=none.
Has one button — Back to sign-in — and a hint to ask an admin
to add a mapping.
/app/dashboard — DashboardDefault landing page after login. Refreshes every 30 s on the api side (cached); reload the page to fetch fresh numbers.
Header — title and a hint reading "Last 24 hours, refreshed every
30 s. reference policy: moderate". The reference policy comes from
the api and is currently fixed to moderate; it appears in the
"Active blocks" caveat.
Counter cards (four):
| Card | Source |
|---|---|
| Active blocks | Distinct IPs in ip_scores with score > 0 plus single-IP manual_blocks. Approximation under the moderate reference policy. |
| Manual blocks | Total rows in manual_blocks across IPs and subnets. |
| Allowlist entries | Total rows in allowlist. |
| Reports (24h) | Rows in reports with received_at in the last 24 h, all categories. |
Reports per hour chart — Chart.js bar chart, 24 buckets keyed by hour (UTC). Hover to read each bucket.
Top reporters (24h) and Top categories (24h) — leader-board tables sorted by report count. Empty if no reports ingested in the last day.
Jobs status — one row per registered job: name, last status
pill (success / failure / skipped_locked / running), an
amber overdue pill if the job hasn't completed within its
declared interval, plus the last_finished_at timestamp. Read-only
here; trigger buttons live on the Settings page.
If the api is unreachable, a yellow banner appears at the top and the cards/charts are skipped — the UI degrades gracefully.
/app/ips — IP searchPaginated table of every IP with a row in ip_scores.
Filter form (collapsed into a card):
| Field | Purpose |
|---|---|
| IP / prefix | Free text (q). Matches via LIKE 'prefix%' on ip_text; 203.0.113. lists everything in that /24. Substring match (%foo%) works but is slower. |
| Category | Limits to IPs scored in the picked category (slug). |
| Min score | Floor on the IP's max score across categories. Decimal. |
| Max score | Ceiling on the IP's max score. |
| Country | Two-letter code. If the api has any enrichment data, becomes a dropdown of seen codes with counts; otherwise a free-text input. |
| ASN | Integer ASN. Filters on the enrichment table. |
| Status | Dropdown — clean, scored, manually_blocked, allowlisted. Combines score row presence with the manual-block / allowlist tables. |
| Reset / Filter | Reset clears the form; Filter submits. |
Results table — IP (clickable, links to detail), Country flag, ASN, top category, max score, last report timestamp, status pill. Pagination underneath when total > page size.
Notes / known limitations
ip_scores. Use
the dedicated Manual blocks / Allowlist pages.Status filter.enrich-pending from Settings to backfill./app/ips/{ip} — IP detailReached by clicking an IP from the list, or by direct URL (e.g.
/app/ips/203.0.113.42). Both IPv4 and IPv6 work; Slim's segment
matcher allows colons via the {ip:.+} route.
Header — IP in monospace + status pill + back link.
Action buttons (operator/admin only):
| Button | Effect |
|---|---|
| Add to allowlist / Remove from allowlist | Toggles whether this single IP is on the allowlist. Allowlist always wins over scoring and manual blocks. |
| Manually block / Remove manual block | Toggles a single-IP manual_blocks row. The "Manually block" modal accepts an optional reason and an optional expires_at (datetime-local). |
Enrichment card — country flag + code, ASN (linked to bgp.he.net
in a new tab), AS organisation, enriched_at timestamp. If no row
exists yet, says "not yet enriched". Footer carries the data-licence
attribution required by DB-IP and IPinfo (MaxMind requires no
attribution).
Override status card — shows whether the IP is currently allowlisted or manually blocked, with the reason text and creation time. Empty otherwise.
Score per category — one bar per category the IP has a non-zero
score in. Bar width = score / max-score-on-this-IP. Suffix shows
<score> with (<n> in 30d).
History — most recent 200 events (reports + manual-block adds + allowlist adds), newest first. Reports show category, reporter, weight, and any metadata (pretty-printed JSON). A footer note indicates if older events exist beyond the 200-row cap.
/app/manual-blocks — Manual blocksLists every row in manual_blocks. The sidebar entry "Subnets" goes
to the same page with ?kind=subnet.
Kind filter chips — All / IPs / Subnets. Each filters the
listing.
Add manual block form (operator/admin only):
| Field | Values |
|---|---|
| Kind | Single IP or Subnet (CIDR). Toggling swaps the visible target field. |
| IP | Visible when Kind = Single IP. Plain v4 or v6 address. |
| CIDR | Visible when Kind = Subnet. e.g. 192.0.2.0/24 or 2001:db8::/48. Non-canonical input is silently normalised; the response echoes a normalized_from note when the input changed. |
| Reason | Free text. Stored verbatim in manual_blocks.reason and shown in audit + UI. |
| Expires (optional) | datetime-local. Empty = never expires. Past expiry, the read-time filter excludes the block from blocklists; the daily cleanup-expired-manual-blocks job hard-deletes it (one audit row per prune). |
Listing table — Kind, target (IP or CIDR), reason, expiry, created-at. Operator/admin sees a Remove action that opens the confirmation modal.
/app/allowlist — AllowlistSame shape as Manual blocks but green-themed. Allowlist entries
suppress an IP from any blocklist regardless of score and regardless
of manual-block presence. The CidrEvaluator logs a WARNING when
allowlist and manual-blocks overlap; allowlist wins.
Add allowlist entry form (operator/admin only):
| Field | Values |
|---|---|
| Kind | Single IP or Subnet (CIDR). |
| IP / CIDR | As per Manual blocks. |
| Reason | Free text. There is no expiry on allowlist entries — remove them manually when no longer needed. |
/app/policies — PoliciesA policy is a named set of (category, threshold) pairs plus a flag
controlling whether manual blocks are merged in. The seeded
strict / moderate / paranoid policies cover the common
spectrum.
Listing — policy name (clickable), description, count of
configured thresholds, "manual blocks: yes/no". Admin sees Edit and
Delete buttons. Delete is refused with 409 if any consumer
references the policy.
New policy form (admin only):
| Field | Values |
|---|---|
| Name | Unique. Freeform, but kebab-ish identifiers work best (used in audit logs). |
| Description | Optional, free text. |
| include manual blocks | Checkbox; checked by default. When checked, every entry in manual_blocks (subject to allowlist) appears in this policy's blocklist. |
Thresholds are configured on the edit page after creation.
/app/policies/{id} — Policy editorMetadata card — Name, Description, "include manual blocks". The form is read-only for viewers.
Threshold matrix — every active category with three columns:
| Column | Meaning |
|---|---|
| Category | <slug> + the human name. |
| Decay | The category's decay function and parameter, for context. |
| Threshold | The minimum score for an IP to land in this policy's blocklist for that category. Empty value = category not in this policy. Saving with empty drops the row from policy_category_thresholds; saving with a number replaces the existing row. |
The PATCH replaces the entire threshold set in one transaction; unchecked rows simply don't appear in the body.
Preview card — calls /app/policies/{id}/preview-proxy (a UI
proxy that adds the service token and forwards to
/api/v1/admin/policies/{id}/preview). Renders the resulting count
of entries plus a sample of the first 50. Refresh re-runs the
query without saving the form. Useful to gauge the impact of a
threshold change before saving.
/app/reporters — ReportersReporters are the upstream sources of abuse signal — one row per
host or service that posts to POST /api/v1/report.
New reporter form (admin only):
| Field | Values |
|---|---|
| Name | Required. Unique. Used in audit logs and dashboard top-list. |
| Trust weight | Decimal 0.00–2.00, default 1.0. Multiplies every report's contribution to score. 0.0 mutes the source; 2.0 doubles its influence. The value is snapshotted into reports.weight_at_report at ingest time, so changing trust later doesn't retroactively rewrite history. |
| Description | Optional free text. |
Listing — name (clickable, edit page), trust weight, description,
status pill (active / inactive). Admin sees Edit and (for active
rows) Deactivate, which is a soft-delete. Deactivated reporters
stop accepting new reports (their tokens get 401); existing
reports rows are preserved for the audit trail.
Edit page — same fields plus an active checkbox.
/app/consumers — ConsumersConsumers are the downstream pull clients — one row per firewall /
proxy / iptables instance that fetches GET /api/v1/blocklist.
New consumer form (admin only):
| Field | Values |
|---|---|
| Name | Required, unique. Used in audit + audit attribution. |
| Policy | Required. Pick from the existing policies; this is what shapes the consumer's blocklist. |
| Description | Optional. |
Listing — name, policy, description, status. Admin sees Edit / Deactivate; Deactivate is a soft-delete (returns 401 on subsequent pulls).
/app/tokens — API tokensBearer tokens for every machine client and admin automation. Service
tokens are deliberately not listed (the UI's own service token is
held only in .env and api_tokens, never exposed in management
views).
Issue token form (admin only):
| Field | Values |
|---|---|
| Kind | admin, reporter, or consumer. Toggling swaps the visible secondary field. |
| Role | (Visible when Kind = admin) viewer, operator, or admin. Persisted in api_tokens.role. Determines what /api/v1/admin/* endpoints the token may call. |
| Reporter | (Visible when Kind = reporter) Pick the reporter the token belongs to. |
| Consumer | (Visible when Kind = consumer) Pick the consumer the token belongs to. |
Token-just-created modal — appears once after submission. Shows:
Listing — Kind, prefix, role-or-target column, last-used
timestamp, status (active / revoked). Admin sees a Revoke
button per active row; revocation is immediate and irreversible
(the row stays for audit; clients start getting 401).
/app/categories — Abuse categoriesEach category is a column in the (IP × category) score matrix. The five seeded categories cover most use cases; add more for niche signal sources.
New category form (admin only):
| Field | Values |
|---|---|
| Slug | Required, unique. Pattern ^[a-z][a-z0-9_]*$. Used in API requests, blocklist filters, and audit logs. |
| Name | Required, human-readable. |
| Decay function | exponential (half-life days) or linear (days to zero). See § 4 below. |
| Decay param | Decimal ≥ 0.1, default 14 (interpreted as half-life when exponential, days-to-zero when linear). |
| Description | Optional. |
Listing — slug, name, decay function + parameter, status pill.
Admin sees Edit and Delete. Delete returns 409 if any policy or
report references the category — soft-delete by editing the
category and unchecking active instead.
Edit page — same fields + an active checkbox + a live
Decay preview SVG plot (x-axis: 0–60 days, y-axis: 1.0 → 0.0).
Toggling the function or changing the parameter redraws the curve
without saving.
/app/audit — Audit logAppend-only record of every successful state-changing call through the admin and auth APIs. Visible to all signed-in roles.
Filter form:
| Field | Values |
|---|---|
| Actor kind | user, admin-token, reporter, consumer, system. Service-token + impersonation always records as user with the impersonated user_id. |
| Actor id | Integer FK into the relevant table for that actor kind. |
| Action | Dropdown of known action strings (reporter.created, policy.updated, manual_block.deleted, job.triggered, etc.). |
| Entity type | Free text matching audit_log.target_type (the API/UI surface them as entity_type). |
| Entity id | Free text — entity primary key as a string. |
| From / To (ISO) | ISO-8601 timestamps to bound the time window (2026-04-01T00:00:00Z). |
Result table — When (timestamp), Actor (kind pill + id), Action
(coloured pill grouped by domain), Entity (type + id), Source IP,
and a View button that expands a JSON pretty-print of the
event's details payload below the row.
Failed (4xx) calls are deliberately not audited — only successful state changes leave a trace. Token-creation events store the prefix, never the raw token. Pagination at the bottom for total > page size.
/app/settings — Settings (admin only)Three sections:
The api's effective config, grouped into sections (database, jobs,
GeoIP, etc.). Secrets are masked: *** for INTERNAL_JOB_TOKEN,
MAXMIND_LICENSE_KEY, IPINFO_TOKEN, DB_MYSQL_PASSWORD.
UI_SERVICE_TOKEN is shown as the first 8 characters + …. Empty
values are rendered as (empty) so misconfiguration is visible.
One row per registered job with its last run status, last
finished-at, items-processed count, and an amber overdue pill
when the job hasn't completed within its default_interval_seconds.
| Job | Default interval | Purpose |
|---|---|---|
recompute-scores |
5 min | Re-applies decay to every (IP, category) pair touched recently or stale beyond freshness window. |
cleanup-audit |
24 h | Prunes audit_log rows older than JOB_AUDIT_RETENTION_DAYS (default 180). |
enrich-pending |
5 min | Looks up GeoIP/ASN for IPs without an ip_enrichment row. |
cleanup-expired-manual-blocks |
24 h | Hard-deletes manual-block rows past their expires_at. Emits one audit row per prune. |
refresh-geoip |
7 days (JOB_GEOIP_REFRESH_INTERVAL_DAYS) |
Downloads fresh MMDB files from the configured provider; atomic-replaces them on disk. |
tick |
external | The dispatcher invoked by host cron / sidecar; iterates the registry and runs whatever is overdue. |
Run now button — admin-only. Posts to
POST /app/settings/jobs/trigger/<name> which proxies to the api's
admin endpoint. Runs synchronously — wait for the response. Does
not appear next to tick (drive that externally). The
refresh-geoip button returns 412 if the configured provider's
credential is unset (DB-IP needs none; MaxMind needs
MAXMIND_LICENSE_KEY; IPinfo needs IPINFO_TOKEN).
Read-only summary of the GeoIP configuration:
| Field | Source |
|---|---|
| Provider | GEOIP_PROVIDER (dbip / maxmind / ipinfo). |
| Country DB / ASN DB | On-disk paths (/data/geoip/country.mmdb and /data/geoip/asn.mmdb). |
| MaxMind key | Status only: shows (unset) when empty. |
| IPinfo token | Status only. |
To rotate provider or credentials, edit .env and restart the api
container.
/app/me — My identityShows what GET /api/v1/admin/me returns for the current request:
User ID, Display name, Email, Role pill, Source (oidc / local /
admin-token). A Sign out button at the bottom posts to
/logout (CSRF-protected). The page also surfaces an amber banner
if the api was unreachable at render time.
Every report contributes to a single (IP, category) score. The
score is recomputed as the sum of every report's weight_at_report
multiplied by a category-specific decay function of the report's
age:
score(IP, category) = Σ over reports r:
r.weight_at_report × decay(now − r.received_at, category)
Reports older than SCORE_REPORT_HARD_CUTOFF_DAYS (default 365) are
excluded outright — the cutoff bounds memory and query time as the
table grows.
Each category picks one of two functions, configured on the
Categories page:
| Function | Formula | decay_param interpretation |
When to pick |
|---|---|---|---|
| Linear | max(0, 1 − age_days / decay_param) |
Days until contribution reaches zero. | Hard cutoff: a report stops counting after exactly N days. Used when "contribution = 1 if recent, 0 if old" is the right model. |
| Exponential | 0.5 ^ (age_days / decay_param) (half-life) |
Days for contribution to halve. | Smooth decay: a report's contribution halves every N days, never reaches zero (until the hard cutoff). The default for all five seeded categories. |
A few worked numbers, with decay_param = 14:
| Age (days) | Linear | Exponential |
|---|---|---|
| 0 | 1.000 | 1.000 |
| 7 | 0.500 | 0.707 |
| 14 | 0.000 | 0.500 |
| 28 | 0.000 | 0.250 |
| 90 | 0.000 | 0.012 |
The Categories edit page renders the same curve live as you
change the parameter.
| Knob | Where | Effect |
|---|---|---|
Per-category decay_function |
Categories → edit |
Switches between linear and exponential. Curve shape changes immediately on save; existing scores recompute on next tick. |
Per-category decay_param |
Categories → edit |
Faster (smaller param) decay = scores fall off quickly = blocklist drops IPs sooner. Slower (larger) = sticky scores. |
Per-reporter trust_weight |
Reporters → edit |
Multiplies the contribution of every new report from this source. Snapshotted into reports.weight_at_report at ingest, so changes don't rewrite history. Range 0.0–2.0. |
Hard cutoff SCORE_REPORT_HARD_CUTOFF_DAYS |
.env (api) |
Floor on memory: reports older than this are excluded entirely. Default 365. |
Recompute interval SCORE_RECOMPUTE_INTERVAL_SECONDS |
.env (api) |
How often the bulk recompute job runs; lower = scores reflect decay sooner; higher = less load. Default 300 s. |
Recompute batch size JOB_RECOMPUTE_MAX_ROWS_PER_TICK |
.env (api) |
Cap on rows refreshed per tick; tune if recompute starts overrunning its window. Default 5000. |
Recompute timeout JOB_RECOMPUTE_MAX_RUNTIME_SECONDS |
.env (api) |
Hard ceiling. The lock auto-expires after this; concurrent calls return skipped_locked. Default 240 s. |
| Per-policy thresholds | Policies → edit |
The score floor for inclusion in a policy's blocklist. Higher = stricter inclusion = smaller list. Lower = looser. |
The job runs every SCORE_RECOMPUTE_INTERVAL_SECONDS:
recompute-scores lock with a max runtime of
JOB_RECOMPUTE_MAX_RUNTIME_SECONDS. Concurrent calls get 409
skipped_locked immediately.(ip, category) pair touched by reports in the
recent interval, plus every row whose recomputed_at is
stale (older than the freshness window) — capped at
JOB_RECOMPUTE_MAX_ROWS_PER_TICK.ip_scores.score < 0.01 AND last_report_at < now − 90
days so the table doesn't grow unbounded.job_runs row and releases the lock.Single-pair updates also happen synchronously at report time
(via PairScorer), so the dashboard and detail page reflect a new
report immediately. The bulk recompute is what ages every score
between reports.
A full-table recompute is exposed as an admin-triggered job
(Settings → recompute-scores → Run now with full=true body, or
the CLI php api/bin/console scores:rebuild). Use after large
policy / decay changes that should reflect everywhere immediately.
manual_blocks and allowlist are not folded into scores. They
are evaluated at distribution time:
include_manual_blocks flag is set. Subnet manual
blocks emit as a single CIDR line; covered single-IP scored entries
are deduplicated.| Resource | Where | Recovery |
|---|---|---|
| Reports + scores | reports, ip_scores |
DB backup |
| Manual blocks + allowlist | manual_blocks, allowlist |
DB backup |
| Policies + thresholds | policies, policy_category_thresholds |
DB backup |
| Categories | categories |
DB backup |
| Reporters / consumers / users | reporters, consumers, users |
DB backup |
| API tokens | api_tokens (hashes only) |
DB backup; raw values gone — re-issue |
| Audit log | audit_log |
DB backup |
| OIDC role mappings | oidc_role_mappings |
DB backup |
| Service token | .env (UI_SERVICE_TOKEN) |
Back up .env or regenerate |
| Internal job token | .env (INTERNAL_JOB_TOKEN) |
Back up .env or regenerate |
| OIDC client secret | .env |
Re-fetch from Entra app registration |
| Local admin password hash | .env |
Regenerate via password_hash() |
| MaxMind / IPinfo credentials | .env |
Re-fetch from provider |
| GeoIP MMDBs | irdb-data volume |
refresh-geoip job re-downloads |
| Browser sessions | UI container's writable layer | Discarded on restart — users re-login |
The SQLite backup API quiesces WAL and produces a consistent snapshot online — no downtime:
docker compose exec api sh -c \
'sqlite3 /data/irdb.sqlite ".backup /data/irdb-backup.sqlite"'
docker compose cp api:/data/irdb-backup.sqlite ./irdb-backup-$(date +%F).sqlite
Schedule this from host cron alongside your existing backup infrastructure. The file is small — under 20 tables; even a year-old install is typically tens of MB.
Whole-volume tarball (alternative, requires the api stopped):
docker compose stop api
docker run --rm -v irdb-data:/data -v "$(pwd):/backup" alpine \
tar czf /backup/irdb-backup.tar.gz -C /data .
docker compose start api
docker compose down
docker run --rm -v irdb-data:/data -v "$(pwd):/backup" alpine \
sh -c 'rm -rf /data/* && tar xzf /backup/irdb-backup.tar.gz -C /data'
docker compose up -d
The migrate container reruns Phinx idempotently — safe to run on a
restored DB that's already at HEAD; it exits immediately with "no
migrations to run" if nothing's pending.
docker compose exec mysql sh -c \
'mysqldump --single-transaction --routines --quick \
-u"$MYSQL_USER" -p"$MYSQL_PASSWORD" "$MYSQL_DATABASE"' \
> irdb-mysql-$(date +%F).sql
Restore (api must be stopped to avoid observing a half-loaded schema):
docker compose stop api migrate
docker compose exec -T mysql sh -c \
'mysql -u"$MYSQL_USER" -p"$MYSQL_PASSWORD" "$MYSQL_DATABASE"' \
< irdb-mysql-2026-04-29.sql
docker compose up -d migrate api
Use this when restoring on a fresh host, e.g. after a hardware failure or migration:
.env from your secret store (or generate fresh — the service
token can be rotated, the local-admin hash regenerated).irdb-data volume. MySQL: pipe the dump
through mysql.docker compose up -d. The migrate
container is idempotent./healthz on both containers; log in to the
UI; visit /app/dashboard and /app/settings.refresh-geoip from Settings (or wait for the
scheduled run) so enrichment is repopulated.401.audit_log is
append-only at the application layer, but a sufficiently
privileged attacker with database access can rewrite history.api topology that exploits the stateless mode..env
separately or regenerate on restore.Things that often go wrong, and where to look:
| Symptom | Likely cause |
|---|---|
Scores never decay; recompute-scores shows "never run" |
Scheduler isn't running. Check compose.scheduler.yml is included or host cron is firing. |
| Dashboard "API unreachable" banner | The api container is down or the UI can't resolve API_BASE_URL. |
All reporter ingest returns 401 |
Token revoked, expired, or wrong kind. Check Tokens and Audit. |
refresh-geoip returns 412 |
Selected provider's credential is unset. Switch provider or set the key. |
| Local-admin login is locked out | Five failed attempts in succession. Restart the ui container to clear the throttle. |
migrate keeps restarting |
Database connection failed. Check DB_DRIVER, DB_MYSQL_*, and the volume mount. |
| Disk filling up | audit_log growing without bounds. Check cleanup-audit is running and JOB_AUDIT_RETENTION_DAYS is set. |
README.md — quickstart, generating secrets, scheduling.architecture.md — container topology and stable surfaces.auth-flows.md — Entra setup, every authentication path.api-overview.md — public API surface with worked examples.security.md — security posture, hardening notes, known limitations./api/docs (live, served by the api container) — interactive OpenAPI viewer.