Every table, column, foreign key and unique index (checked against a database built from the migrations), the JSON stored in text columns, the settings keys, delete behaviour, retention and backups, and how to change the schema. Linked from the README alongside the other documents. Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
27 KiB
Database schema
Homelab Manager keeps its data in one SQLite file, accessed through
drizzle-orm and the libSQL client. The schema is
defined in one place — server/src/db/schema.ts —
and this document describes it. It was checked against a fresh database built
from the migrations, so the tables, columns, foreign keys and indexes below are
what the app actually creates.
- The basics
- How the tables relate
- Tables
- What's stored inside the JSON columns
- Settings keys
- What happens on delete
- The Proxmox link's foreign key
- Retention and backups
- Changing the schema
The basics
| File | DATABASE_PATH, default ../data/homelab-manager.sqlite (relative to server/; /app/data/... in Docker). The folder is created if missing. |
| Foreign keys | Switched on for every connection (PRAGMA foreign_keys = ON), so the ON DELETE rules below are enforced. |
| Migrations | SQL files in server/drizzle/ (0000 … 0014), applied automatically when the server starts. Which ones have run is recorded in the table __drizzle_migrations. |
| Tables | 21 application tables, plus drizzle's own __drizzle_migrations. |
Not in the database:
- Login sessions are files in
SESSION_DIR(default../data/sessions), not rows. - The encryption key for integration credentials (
CREDENTIALS_ENCRYPTION_KEY) and the session secret live in the environment, never in the file. - Agent tokens are never stored: only a SHA-256 hash and a short prefix of each (
servers.api_token_hash,api_token_prefix). The token itself is shown once, when the server is registered.
Conventions
- Primary keys are
INTEGER PRIMARY KEY AUTOINCREMENTnamedid— exceptsettings, which uses its textkey. - Booleans are
INTEGER0/1 (shown asboolbelow). - Timestamps are text, in two formats, so read them with care:
- Columns the database fills in itself (
created_at, and mostupdated_at) use SQLite'scurrent_timestamp:2026-10-03 14:05:09, UTC, with no zone marker. Treat it as UTC, not local time. - Columns the app fills in (
last_seen_at,last_login_at,synced_at,last_checked_at, …) use ISO 8601:2026-10-03T14:05:09.123Z. - Dates without a time (
secrets.expiry_date,domains.expires_at) areYYYY-MM-DD.
- Columns the database fills in itself (
- JSON columns are
TEXTholding JSON; see what's inside. - Enumerations (roles, types) are plain text — SQLite doesn't enforce the allowed values; the app does.
- Indexes: besides primary keys, the only indexes are the unique ones listed per table. There are no secondary indexes; the data sets here (hundreds to a few thousand rows) don't need them.
How the tables relate
erDiagram
users ||--o{ audit_log : "actor (set null)"
integration_credentials ||--o{ integrations : "credential (set null)"
integration_credentials ||--o{ dns_providers : "credential (set null)"
dns_providers ||--o{ dns_zones_cache : "cascade"
dns_providers ||--o{ dns_records_cache : "cascade"
servers ||--o{ scheduled_tasks : "cascade"
servers ||--o{ server_links : "cascade"
servers ||--o{ server_ports : "cascade"
servers ||--o{ port_forwards : "optional (set null)"
integrations ||--o{ servers : "proxmox link (app clears it)"
Eight tables stand alone, with no foreign keys: settings, secrets,
ipam_entries, domains, diag_log, notification_queue,
consistency_ignores, tag_definitions. maintenance_windows also has no
foreign key — see soft references.
Tables
Grouped by what they're for. "Null" in the notes means the column allows NULL; everything not marked NOT NULL may be empty.
People and activity
users
Everyone who has signed in through Authentik. The first one becomes admin.
| Column | Type | Notes |
|---|---|---|
id |
integer PK | |
oidc_sub |
text NOT NULL, unique | The user's stable ID from Authentik (sub claim). This is what a session is matched against. |
email, name |
text | From the sign-in; may be empty. |
role |
text NOT NULL, default viewer |
admin | operator | viewer. |
created_at |
text NOT NULL | SQLite UTC. |
last_login_at |
text | ISO. |
audit_log
Who changed what. Written by the app on every change (see ROLES.md for who can read it).
| Column | Type | Notes |
|---|---|---|
id |
integer PK | |
actor_user_id |
integer → users.id, set null on delete |
Null for automatic actions, and for entries whose user was deleted. |
actor_label |
text | The user's name/email as it was at the time (or system), so an entry still reads correctly after the account is gone. |
category |
text NOT NULL | Free text. In use: server, integration, dns, settings, ipam, secret, domain, tag, task, session, network, maintenance, consistency, user, privacy, diag_log. |
action |
text NOT NULL | create, update, delete, start, stop, … — free text. |
target_type, target_id |
text | What it happened to. target_id is text so it can hold any kind of ID; there's no foreign key. |
detail |
text | JSON, free-form context (what changed, names, counts). Secrets are never put here. |
created_at |
text NOT NULL | SQLite UTC. |
diag_log
One row per outbound call to an integration or DNS provider — the source of the Diagnostic Log page and of the "integration down" alert.
| Column | Type | Notes |
|---|---|---|
id |
integer PK | |
source |
text NOT NULL | The integration/provider type, e.g. proxmox, cloudflare. |
operation |
text NOT NULL | The adapter method, e.g. listZones. |
ok |
bool NOT NULL | |
latency_ms |
integer NOT NULL | |
error |
text | Message when ok is false. |
created_at |
text NOT NULL | SQLite UTC. |
notification_queue
Notifications held back during quiet hours, delivered as one digest and then cleared.
| Column | Type | Notes |
|---|---|---|
id |
integer PK | |
title, message |
text NOT NULL | |
created_at |
text NOT NULL | SQLite UTC. |
maintenance_windows
While a window is open, alerts about its target are silenced. An end time is required, so a forgotten window can't silence real problems forever.
| Column | Type | Notes |
|---|---|---|
id |
integer PK | |
target_type |
text NOT NULL | server | integration | dns_provider. |
target_id |
integer NOT NULL | The ID in the table target_type names. Not a foreign key; a window whose target was deleted is simply ignored. |
reason |
text | |
started_at, ends_at |
text NOT NULL | ISO. |
created_by |
text | Name of the person who opened it. |
Servers
servers
A machine that reports in through the agent (or is registered by hand), plus what it last reported.
| Column | Type | Notes |
|---|---|---|
id |
integer PK | |
name |
text NOT NULL | Not unique. |
hostname |
text | |
os_type |
text NOT NULL, default linux |
|
description |
text | |
api_token_hash |
text NOT NULL | SHA-256 of the agent's token. |
api_token_prefix |
text NOT NULL | First characters of the token, to tell tokens apart in the UI. |
created_at |
text NOT NULL | SQLite UTC. |
last_seen_at |
text | ISO; when the agent last reported. Drives "server offline". |
ip_addresses |
text | JSON string[]. Agent-reported. |
cpu_model |
text | Agent-reported. |
cpu_cores |
integer | |
cpu_load_percent |
real | Load average ÷ cores × 100 — an approximation, not instantaneous usage. |
mem_total_bytes, mem_used_bytes |
integer | |
disks |
text | JSON, see below. Agent-reported. |
listening_ports |
text | JSON, see below. What the agent sees bound on the host. |
last_port_scan |
text | JSON summary of the latest network scan from this app. |
tags |
text | JSON string[] of normalised tag names. |
proxmox_integration_id |
integer → integrations.id |
The database has no ON DELETE rule here; the app clears the link itself — see below. |
proxmox_node |
text | |
proxmox_guest_type |
text | qemu | lxc. |
proxmox_vmid |
integer | The four proxmox_* columns are set together or cleared together (enforced by the API), by an admin — never by the agent. |
hide_proxmox_link |
bool NOT NULL, default 0 | Hides the "Proxmox link" card for servers that aren't Proxmox guests. Ignored while the server is actually linked. |
scheduled_tasks
Cron jobs, systemd timers and Windows tasks the agent found on a server, plus tasks added by hand.
| Column | Type | Notes |
|---|---|---|
id |
integer PK | |
server_id |
integer NOT NULL → servers.id, cascade |
|
schedule_type |
text NOT NULL | cron | systemd_timer | windows_task from agents; manual tasks may use others (docker, backup, update, n8n_workflow, manual). |
origin |
text NOT NULL, default agent |
agent | manual. Manual rows are never touched by agent sync. |
name |
text NOT NULL | |
command, schedule_expression, source |
text | |
enabled |
bool NOT NULL, default 1 | |
next_run_at |
text | |
raw_metadata |
text | JSON as the agent reported it. |
is_stale |
bool NOT NULL, default 0 | Set when the agent stops reporting the task. |
first_seen_at, last_seen_at |
text NOT NULL | SQLite UTC at first insert; later updates are written by the app. |
server_links
Admin-page bookmarks for a server (Dockge, Webmin, Cockpit, …). Shown on the server's page and summarised under Operations → Admin Links.
| Column | Type | Notes |
|---|---|---|
id |
integer PK | |
server_id |
integer NOT NULL → servers.id, cascade |
|
label, url |
text NOT NULL | |
created_at |
text NOT NULL | SQLite UTC. |
server_ports
A port on one server that's been seen open by a scan, or that someone wrote a
note about. Rows exist only while they carry information. What the agent
sees is stored on the server row instead (servers.listening_ports).
| Column | Type | Notes |
|---|---|---|
id |
integer PK | |
server_id |
integer NOT NULL → servers.id, cascade |
|
port |
integer NOT NULL | |
protocol |
text NOT NULL, default tcp |
tcp | udp. |
label, comment |
text | |
open |
bool NOT NULL, default 0 | True when the last scan connected to it. |
last_seen_open_at |
text | |
updated_at |
text NOT NULL |
Unique index server_ports_unique on (server_id, port, protocol).
port_forwards
Manually recorded port openings on something this app doesn't monitor — a router's port forward, an edge firewall rule, a cloud security group. It records them; it can't check or change them.
| Column | Type | Notes |
|---|---|---|
id |
integer PK | |
label |
text NOT NULL | |
external_port |
integer NOT NULL | |
protocol |
text NOT NULL, default tcp |
tcp | udp. |
server_id |
integer → servers.id, set null |
Optional: the tracked server it points at. |
destination |
text | Anything else — a bare IP, an untracked device — or detail alongside server_id. |
internal_port |
integer | When NAT changes the port. |
source |
text | Free text: where the rule lives ("Home router", "OPNsense WAN rule"). |
comment |
text | |
created_at, updated_at |
text NOT NULL |
Integrations and credentials
integrations
A connected system: Proxmox, Synology, Semaphore, Tailscale, Gitea, Dockhand, Uptime Kuma, phpIPAM, Proxmox Backup Server, osTicket.
| Column | Type | Notes |
|---|---|---|
id |
integer PK | |
type |
text NOT NULL | proxmox | synology | semaphore | tailscale | gitea | dockhand | uptimekuma | phpipam | pbs | osticket. |
name |
text NOT NULL | |
base_url |
text NOT NULL | For osTicket (a direct database connection) this holds the database host. |
credential_id |
integer → integration_credentials.id, set null |
|
config |
text | JSON of the non-secret settings, see below. |
enabled |
bool NOT NULL, default 1 | |
created_at |
text NOT NULL |
integration_credentials
The secret half of an integration or DNS provider, encrypted at rest.
| Column | Type | Notes |
|---|---|---|
id |
integer PK | |
name |
text NOT NULL | "<type>:<name>", for recognising a row when looking at the file. |
encrypted_secret |
text NOT NULL | iv:authTag:ciphertext, each in hex — AES-256-GCM with CREDENTIALS_ENCRYPTION_KEY. The plaintext is a JSON object of the secret fields (an API token, a password). Without the same key it can't be read. |
created_at |
text NOT NULL |
The notification channels' credentials aren't in this table; they're encrypted in place in settings — see Retention and backups.
DNS
dns_providers
| Column | Type | Notes |
|---|---|---|
id |
integer PK | |
provider_type |
text NOT NULL | cloudflare | loopia | pihole | azure | cpanel | technitium. |
name |
text NOT NULL | |
credential_id |
integer → integration_credentials.id, set null |
|
config |
text | JSON, non-secret provider config (base URL, zone list, …). |
enabled |
bool NOT NULL, default 1 | |
created_at |
text NOT NULL |
dns_zones_cache and dns_records_cache
Local copies of what the providers returned at the last sync, so pages load without calling every provider. The providers remain the source of truth.
dns_zones_cache
| Column | Type | Notes |
|---|---|---|
id |
integer PK | |
provider_id |
integer NOT NULL → dns_providers.id, cascade |
|
zone_id |
text NOT NULL | The provider's own identifier for the zone. |
zone_name |
text NOT NULL | |
synced_at |
text | ISO. |
Unique index dns_zones_cache_provider_zone_idx on (provider_id, zone_id).
dns_records_cache
| Column | Type | Notes |
|---|---|---|
id |
integer PK | |
provider_id |
integer NOT NULL → dns_providers.id, cascade |
|
zone_id |
text NOT NULL | The provider's zone identifier — matches dns_zones_cache.zone_id for the same provider, but is not a foreign key. |
record_id |
text NOT NULL | The provider's own record identifier. |
type |
text NOT NULL | A, AAAA, CNAME, TXT, MX, … |
name, content |
text NOT NULL | |
ttl, priority |
integer | |
proxied |
bool | Cloudflare only. |
Address and name tracking
ipam_entries
IP addresses with a label and notes, entered by hand or synced.
| Column | Type | Notes |
|---|---|---|
id |
integer PK | |
ip_address |
text NOT NULL, unique | |
label, vendor, location, notes |
text | |
source |
text | Null = typed in by hand; otherwise the sync that created it: tailscale, proxmox or phpipam. A sync only updates rows it created itself and never overwrites a manual one. |
created_at, updated_at |
text NOT NULL |
domains
Registered domains whose expiry is tracked.
| Column | Type | Notes |
|---|---|---|
id |
integer PK | |
name |
text NOT NULL, unique | The registrable domain, lowercase ASCII. |
origin |
text NOT NULL, default manual |
manual (typed in) or zone (created from a synced DNS zone, removed again when the zone goes away). |
expires_at |
text | YYYY-MM-DD, as the registry reports it. Null for registries that don't publish one. |
registrar |
text | |
lookup_source |
text | rdap | whois. |
last_checked_at |
text | Last attempt. |
last_checked_ok_at |
text | Last successful attempt. |
last_check_error |
text | |
created_at |
text NOT NULL |
secrets
The expiry tracker for API tokens, certificates, passwords and the like. It tracks when something expires; it does not store the secret itself.
| Column | Type | Notes |
|---|---|---|
id |
integer PK | |
name |
text NOT NULL | |
type |
text NOT NULL, default generic |
api_token | ssl_certificate | password | generic. |
description, notes |
text | |
expiry_date |
text NOT NULL | YYYY-MM-DD. For a certificate with a host to check, overwritten from the live certificate. |
warn_days |
integer NOT NULL, default 30 | How long before expiry to start reminding. |
check_host, check_port |
text / integer | Certificates only: read the expiry from the live certificate at this host:port. |
last_checked_at, last_check_error |
text | Result of the last live check. |
created_at, updated_at |
text NOT NULL |
Housekeeping
settings
Key/value store for app settings. One row per settings section (the value is JSON) plus a few internal bookkeeping rows. Details under Settings keys.
| Column | Type | Notes |
|---|---|---|
key |
text PK | |
value |
text NOT NULL | JSON (or a plain date/time for internal flags). |
updated_at |
text NOT NULL |
tag_definitions
Tags themselves live on the servers that carry them (servers.tags). A row
here adds what a server can't: a tag that exists before anything uses it, and a
chosen colour. A tag with no row is simply one in use with an automatic colour.
| Column | Type | Notes |
|---|---|---|
id |
integer PK | |
name |
text NOT NULL, unique | Normalised. |
color |
text | #rrggbb, or null for automatic. |
created_at |
text NOT NULL |
consistency_ignores
Consistency findings someone looked at and decided are fine.
| Column | Type | Notes |
|---|---|---|
id |
integer PK | |
key |
text NOT NULL, unique | The finding's stable key, so it stays ignored across runs. |
title |
text NOT NULL | What the finding said when it was ignored, so the list still reads sensibly after it's gone. |
reason, created_by |
text | |
created_at |
text NOT NULL |
What's stored inside the JSON columns
| Column | Shape |
|---|---|
servers.ip_addresses |
["10.0.0.5", "fd00::5"] |
servers.disks |
[{ "mount": "/", "sizeBytes": 32000000000, "usedBytes": 9000000000 }] |
servers.listening_ports |
[{ "protocol": "tcp", "port": 22, "address": "0.0.0.0", "process": "sshd" }] |
servers.last_port_scan |
{ "at": "<ISO>", "address": "10.0.0.5", "from": 1, "to": 1024, "open": 3, "refused": 1010, "filtered": 11, "responded": true } |
servers.tags |
["prod", "media"] |
audit_log.detail |
Free-form per action — e.g. { "name": "pve1" }, or for settings { "sections": [...], "changes": { "<section>": { "<field>": { "from": …, "to": … } } } }. For the notification channels (Gotify, ntfy, SMTP, webhook) only the field names that changed are recorded, never values. |
integrations.config |
The non-secret fields for that integration type (table below). |
dns_providers.config |
Non-secret provider settings (base URL, zone list, …). |
integrations.config by type (the secret fields go to integration_credentials instead):
| Type | In config |
Encrypted separately |
|---|---|---|
proxmox |
url, tokenId, insecure |
tokenSecret |
pbs |
url, tokenId, insecure |
tokenSecret |
synology |
url, username, insecure |
password |
semaphore, gitea, dockhand |
url |
token |
tailscale |
tailnet |
apiKey |
uptimekuma |
url, username |
password (an API key, or the password on old installs) |
phpipam |
url, appId, insecure |
token |
osticket |
host, port, database, username, tablePrefix |
password |
Settings keys
Each section is one row in settings, with a JSON object as its value. Fields
that were never changed aren't stored; the app fills in defaults when reading.
| Key | Holds |
|---|---|
gotify, ntfy, smtp, webhook |
The four notification channels: enabled, address, and credentials (token / password / secret). The credential fields are stored encrypted (prefix enc:v1:) — see Retention and backups. |
notifications |
Which events notify (dnsAdd, healthAlerts, …), the daily-reminder time (secretCheckTime, default 08:00) and timezone (default UTC), and the integration-failure threshold (default 3). |
quietHours |
enabled, start, end. |
healthChecks |
serverOfflineMinutes (60), diskUsagePercent (90), domainWarnDays (30). |
logRetention |
enabled, retentionDays (90), intervalHours (24). |
display |
dateFormat, timeFormat, pageSize. |
providerColors, integrationColors |
Badge colours, by provider / integration type. |
consistency |
excludedRanges — addresses the Consistency and IP Addresses pages ignore. Default ["172.16.0.0/12"] (Docker's networks). |
nameGenerator |
themes — the Generator's server-name lists: [{ "id", "label", "names": [...], "lastImport"? }]. Edited under Settings → Names. |
Rows whose key starts with _internal: are scheduler bookkeeping, not
settings, and aren't part of the app's settings object:
| Key | Value |
|---|---|
_internal:secretCheckLastRunDate, tailscaleKeyCheckLastRunDate, dockerUpdateCheckLastRunDate, proxmoxBackupCheckLastRunDate, pbsVerificationCheckLastRunDate |
The date a daily check last ran, so a restart doesn't repeat it (or skip it). |
_internal:logRetentionLastRunAt |
When the log purge last ran. |
_internal:healthActiveConditions, _internal:automationActiveConditions |
JSON: the problems currently active, so each is announced once and again when it clears, and survives a restart. |
What happens on delete
| Deleting… | Effect |
|---|---|
| a server | Its scheduled_tasks, server_links and server_ports are deleted with it. Port forwards that pointed at it stay, with server_id cleared. |
| a DNS provider | Its cached zones and records are deleted. |
| an integration or DNS provider | The credential row it used is deleted by the app (not by the database). |
| an integration credential | The integration / provider using it keeps existing, with credential_id cleared. |
| a user | Their audit entries stay; actor_user_id is cleared and actor_label keeps the name. |
| a Proxmox integration that servers are linked to | Those servers keep existing and lose their Proxmox link (all four proxmox_* columns). The app does this before deleting; the database alone would refuse — see the next section. |
Soft references (not foreign keys)
These point at other rows by ID or name without the database enforcing it, so a stale value is possible and the app treats it as "nothing there":
maintenance_windows.target_id(→ a server, integration or DNS provider, bytarget_type)audit_log.target_iddns_records_cache.zone_id(→dns_zones_cache.zone_id, same provider)servers.tags(→tag_definitions.name)consistency_ignores.key
The Proxmox link's foreign key
servers.proxmox_integration_id is meant to clear itself when its integration
is deleted: schema.ts says onDelete: "set null". The database doesn't do
that. Migration 0001 added the column as a plain
REFERENCES integrations(id), and SQLite can't change a foreign key afterwards
without rebuilding the table, so the rule in the database is no action:
with foreign keys on, deleting an integration that a server points at is refused
with FOREIGN KEY constraint failed.
The app works around it rather than rebuilding the table. The delete route in
server/src/routes/integrations.ts first
clears the four proxmox_* columns on every server linked to that integration,
then deletes it, and the audit entry records how many servers were unlinked
(unlinkedServers). Deleting an integration therefore works whether or not
servers are linked to it. Anything that deletes integrations some other way —
a hand-written SQL statement, say — has to do the same first.
Retention and backups
Retention. Only the two logs are trimmed: audit_log and diag_log entries
older than logRetention.retentionDays are deleted by the purge job (off by
default), and notification_queue is emptied each time the quiet-hours digest is
sent. Everything else stays until someone deletes it.
Credentials at rest. Integration and DNS provider credentials are
encrypted in integration_credentials (above). The notification channels'
credentials — the Gotify and ntfy tokens, the SMTP password and the webhook
secret — are in the settings rows, and are encrypted in place with the same key:
each is stored as enc:v1: followed by the same iv:authTag:ciphertext form, and the
rest of the row (URLs, topics, priorities) stays readable. The app decrypts them when
settings are read and encrypts them when they're written, so nothing else sees the
difference.
- A value saved by an older version (plain text) still works, and is encrypted the next time the server starts.
- Without
CREDENTIALS_ENCRYPTION_KEY, new notification credentials can only be stored as plain text, and the server warns about it at startup. They are encrypted at the next start once the key is set. - If the key is changed or lost, the stored credentials can't be read: they show as empty, and the server logs which ones. Enter them again under Settings → Notifications. Editing a different field of the same channel meanwhile doesn't overwrite the old ciphertext, so putting the right key back restores them.
Everything else in the file — IP addresses, hostnames, secret names and expiry dates, the audit log — is readable by anyone who can read the file, so treat the file and its backups as sensitive anyway.
Backups.
- Settings → Backup exports the settings, the integrations and the DNS providers (with their credentials decrypted, then wrapped in a file encrypted with a passphrase you choose). Importing merges the settings over the current ones, and adds integrations and providers that don't exist yet (matched by type and name) — it never overwrites an existing integration. It does not include servers, tasks, secrets, IP addresses, domains, ports, tags, maintenance windows or logs.
- A full backup is a copy of the SQLite file, together with the value of
CREDENTIALS_ENCRYPTION_KEY— without that key the stored integration credentials can't be decrypted. Copy the file while the app is stopped, or use SQLite's.backupcommand, so you don't capture it mid-write.
Changing the schema
- Edit
server/src/db/schema.ts. - From the repo root run
npm run db:generate; it writes a new numbered SQL file toserver/drizzle/(and updatesserver/drizzle/meta/). - Read the generated SQL. SQLite can add a column but can't change or drop a foreign key, so some changes become a table rebuild — which is also why the Proxmox link described above is the way it is.
- Start the server (or run
npm run db:migrate); the migration is applied and recorded in__drizzle_migrations. - Commit the schema, the SQL file and the
meta/changes together, and update this document.