Agentless infrastructure inventory, monitoring, automation, and access control β for bare metal, cloud, and everything in between.
Track every server, service, and certificate you own. Watch live CPU, memory, disk, network, and GPU metrics over plain SSH. Edit crontabs, run runbooks across the fleet, manage Linux accounts and systemd units, catch configuration drift, and keep a complete audit trail β without installing a single agent on a target host.
Quick Start Β· What's New Β· Features Β· User Guide Β· Configuration Β· Deployment Β· API Β· Upgrading Β· Contributing
Most infrastructure tools want an agent on every box. That means a rollout, a package to maintain, a daemon to patch, and a security review before you can see a single CPU graph.
RackMap takes the opposite approach. It connects over SSH, runs what it needs, parses the output, and disconnects. Nothing is installed on the machines you manage. That makes it a good fit for:
- Mixed fleets β bare metal, VMs, and cloud instances side by side, on standard or non-standard SSH ports
- GPU and AI infrastructure β NVIDIA, AMD, and Intel accelerators, plus vLLM / Ollama / llama.cpp endpoints as first-class inventory
- Environments you don't fully control β customer hardware, colo racks, or hosts where installing an agent is not an option
- Teams that need an audit trail β inventory changes, access approvals, credential reveals, root actions and auth events are recorded with before/after state
| No agents | Plain SSH. Nothing to install, patch, or roll back on target hosts. |
| Credentials encrypted at rest | AES-256-GCM, plus an optional envelope-encryption vault (PBKDF2 β KEK β DEK) whose passphrase is never stored in the database. |
| RBAC + approvals | admin / editor / viewer, request-and-approve for SSH and password reveal, and a second admin to approve sensitive runbook runs. |
| Self-hosted, AGPL-3.0 | Your inventory stays on your infrastructure. One docker compose up, PostgreSQL 18 included. |
RackMap 1.0 turns the inventory into an operations console and settles on one database.
- PostgreSQL 18 only. SQLite support is gone. Docker Compose runs PostgreSQL for you (set
POSTGRES_PASSWORD), and scheduledpg_dumpbackups are built in. Upgrading from 0.8.x has breaking changes β read MIGRATION.md first. - Agentless automation: a per-server cron editor, cron heartbeat monitoring, runbooks with fleet execution and two-person approval, a systemd Services tab, patch management, drift detection, and time-boxed access grants.
- Alert channels: Slack, Microsoft Teams, Discord, PagerDuty, Telegram, email, and HMAC-signed webhooks, with retries and a delivery log. SSL certificates are scanned daily and alert 30, 14, 7, and 1 days before expiry.
- Prometheus service discovery (
/api/v1/prometheus/sd), more exporter series, and a Grafana dashboard incontrib/. - Fixes and hardening: creating, editing, and deleting OS users no longer hangs. Editors can no longer grant root-equivalent access. Any root action asks for the sudo password when the stored one is stale, and can save it. There is a new per-account sign-in limit. Status history is lighter and admins can clean it under Settings β Maintenance. The v0.8.0 nginx bug that left the Servers page empty is fixed.
The full list is in the Changelog.
Requirements: Docker and Docker Compose. Nothing else.
# 1. Clone
git clone https://github.com/deziss/rackmap.git
cd rackmap
# 2. Create a Docker .env with generated secrets (never commit this file)
cat > .env <<EOF
NODE_ENV=production
PORT=8080
WEB_ORIGIN=http://localhost:8080
POSTGRES_PASSWORD=$(openssl rand -hex 24)
APP_ENCRYPTION_KEY=$(openssl rand -base64 32)
BETTER_AUTH_SECRET=$(openssl rand -hex 32)
SEED_ADMIN_EMAIL=admin@example.com
SEED_ADMIN_PASSWORD=$(openssl rand -hex 12)
EOF
chmod 600 .env
# 3. Start (build on first run and after pulling updates)
docker compose up -d --build
# 4. Open http://localhost:8080Sign in as admin@example.com with the generated password (grep SEED_ADMIN_PASSWORD .env). In production mode
the first start refuses to create the admin with a published default password or one shorter than 12 characters.
The first start also runs the database migrations, so give it a minute.
Don't copy
.env.examplewholesale for Docker. ItsPORT,NODE_ENV, andWEB_ORIGINare local-development values (API on 3000, Vite on 5173). Compose reads.envtoo, so copying them would publish the UI on port 3000, run in development mode with demo accounts, and reject sign-ins from the wrong origin. Copy only the settings you need; every one is documented in Configuration.
- Port 5432 already in use? The bundled PostgreSQL is published on
127.0.0.1:5432. AddPOSTGRES_HOST_PORT=5433(or any free port) to.env. - Reaching RackMap by another name or port? Set
WEB_ORIGINto that URL. If you use several, list them all inTRUSTED_ORIGINS. - Using cron heartbeats? Set
PUBLIC_BASE_URLto an address your managed hosts can reach.
Next steps: add your first server, enable the SSH terminal, or set up alert channels.
| Document | What's in it |
|---|---|
| User Guide | Day-to-day usage: every page, every feature, roles, troubleshooting |
| Upgrading & migration | Upgrading 0.8.x β 1.0.0 (breaking changes), SQLite β PostgreSQL, moving to a new server, backups & restore |
| Configuration | Every environment variable, with defaults |
| Encryption Guide | At-rest encryption, the credential vault, passphrase recovery |
| Deployment | Docker Compose, external PostgreSQL, bare metal / PM2 / systemd, backups |
| API Overview | REST endpoints and required roles |
| Integrations | Ansible dynamic inventory, Prometheus scrape config and service discovery, Grafana dashboard |
| Contributing | Dev setup, tests, migrations, branch and commit conventions, PR checklist |
| Security Policy | Supported versions and how to report a vulnerability |
| Changelog | What changed in each release |
Inventory & topology
- Server CRUD & specifications β hostname, IP, SSH port (standard and non-standard, e.g. 7722), credentials (AES-256-GCM encrypted at rest), tags, and metadata with dedicated columns for CPU, RAM, storage, OS, and hardware accelerators (H100, H200, RTX PRO 6000, RTX 4090)
- Service-first multi-hosting model β track microservices and applications by runtime environment:
server(host-native systemd services such as Mattermost, Jenkins, GitLab, Zabbix),docker(containerized workloads), andk8s(Kubernetes deployments with NodePort tracking) - AI model & inference topology β first-class tracking for vLLM, Ollama, and llama.cpp deployments with designated ports, host bindings, and AES-256-GCM encrypted API bearer tokens
- Backup policy tracking β backup script paths, destination storage (NVMe, central NFS), cron schedules, retention windows, and data categories
- Lookup tables β Cloud Provider, GPU Type, Allocated To, Location, Server Type, and Network Type dropdowns, all admin-managed
- Hardware auto-discovery β detect and persist CPU, RAM, storage, and OS over SSH in one click
- Import / export β Excel import with column mapping and dry run; Excel and JSON export that respects the active search filter; PDF and JSON reports
Monitoring & observability
- Live status monitoring β TCP probe on a configurable interval, with alerts to every configured channel. History is sampled (a row per status change, otherwise one per 15 minutes) and capped, and admins can review and clean it under Settings β Maintenance
- Agentless live metrics β CPU load, memory, disk, network I/O, and per-process tables, collected via a single SSH exec
- Multi-vendor GPU metrics β NVIDIA (
nvidia-smi), AMD sysfs (amdgpu), AMD ROCm (rocm-smi), and Intel (xpu-smi) - Forensic logs & storage footprint β query
journalctl, syslog,auth.log, and kerneldmesgwith live/var/logsize and journal disk usage badges, evidence search, auto-refresh (5s / 10s / 30s / 60s / manual), and.logexport - ATOP historical replay β browse historical activity dates and interval snapshots, and extract top CPU / memory / disk processes per interval
- SSL certificate monitoring β auto-discovered and manual domains, wildcard support, expiry badges, and a daily scan that alerts 30, 14, 7, and 1 days before expiry
- Prometheus β a fleet exporter at
/api/v1/metrics,http_sd_configsservice discovery at/api/v1/prometheus/sd, and a ready-made scrape config and Grafana dashboard
Automation
- Cron job editor β view and edit every crontab on a host (user crontabs,
/etc/crontab,/etc/cron.d) from the server page, with a schedule builder, plain-English descriptions, next run times in the host's time zone, a raw editor, diff preview before saving, and "run now". Saves are refused if the file changed on the host since you loaded it, and the previous version is backed up on the host. systemd timers are listed read-only - Cron heartbeat monitoring β one switch wraps a cron job so it checks in with RackMap after every run; a missed, late, or failing run raises an alert. Heartbeats also work for anything that can call a URL (systemd
OnFailure=, Kubernetes CronJobs, scripts) - Runbooks β saved, parameterised scripts run across a set of servers chosen by tag, environment, location, or name, with a target preview, dry run, per-host live output, cancel/rerun, schedules, and a two-person approval step for root or sensitive runs
- Alert channels β Slack, Microsoft Teams, Discord, PagerDuty, Telegram, email, and signed webhooks, each subscribing to the events it cares about, with retries, a delivery log, and a test button. PagerDuty incidents open and resolve automatically
- systemd services β a Services tab per server lists units with their state and boot setting, shows details and the journal, and can start/stop/restart/reload/enable/disable them. Protected units (SSH, networking, D-Bus, Docker, systemd-*, targets, mounts) need an admin
- Patch management β a nightly fleet scan (apt, dnf, yum, zypper) of pending and security updates, reboot-required hosts, and running vs installed kernel, with "scan now" and admin-only apply (security-only or all; never reboots)
- Drift detection β nightly snapshots of accounts, privileged group members, sudoers rules, crontabs, listening ports, enabled units, and authorized SSH keys, compared with an accepted baseline; a new root key, sudoers rule, uid-0 account or privileged member is flagged critical
- Time-boxed access β temporary OS accounts and SSH keys that RackMap locks, deletes, or removes at expiry, with the expiry also enforced on the host (
chage, andexpiry-timeon OpenSSH 8.2+) - Automated system updates β check, enable, disable, or remove
unattended-upgradesper server
Access & security
- RBAC β admin / editor / viewer roles via Better Auth, with two-factor authentication (TOTP) and scoped API keys
- Access requests β viewers and editors request SSH access or password reveal; admins approve with an expiry window
- Credential vault β envelope encryption (PBKDF2, 100k iterations, SHA-512) where the master passphrase is never stored in the database. Unlocking sends the passphrase to the server, which performs the key derivation, so run RackMap behind TLS. Opting in to auto-unlock writes the passphrase to
.envon the host - SSH dual-mode auth β public key first, with automatic fallback to password and PAM keyboard-interactive; host keys are pinned per endpoint (
SSH_HOST_POLICY) - Browser SSH terminal β full xterm.js terminal over WebSocket, admin-only, off by default behind
SSH_ENABLED - OS user & sudoers management β create, update, lock/unlock, and delete Linux accounts with full options (
-m,-r, custom shells, secondary groups, custom UID/GID, sudoers rules). Root work runs as an uploaded script with sudo probed first, so the password never appears on the remote command line. Editors cannot grant sudo or privileged groups or touch root-equivalent accounts. If sudo rejects the stored password, the dialog asks for it once, and the password is never stored - Sign-in protection β per-IP and per-account sign-in rate limits
- Audit log β every data mutation, root action, and auth event with actor, IP, and a before/after JSON diff viewer
Workflow & UX
- Universal pagination β rows-per-page selector (10 / 25 / 50 / 100), range display, and numbered pages across every table
- Saved views β save and reuse server-list filters
- Customer portal β a public dark-mode product showcase at
/portalwith an interactive mock console and pricing comparison - Single-command deploy β
docker compose upfor production, with PostgreSQL 18 and nightlypg_dumpbackups included
apps/
api/ Hono (Node.js 24) β REST API + WebSocket SSH, Prisma schema and migrations
web/ React (Vite) β SPA served by Nginx in Docker
packages/
shared/ Types, schemas, permissions, and constants shared between apps
contrib/ Ansible inventory, Prometheus config, Grafana dashboard
Data flow β metrics
Browser β GET /api/v1/servers/:id/metrics
β API SSHs into the target server
β Runs one compound shell command (~1s, includes a sleep for network sampling)
β Parses output β returns typed JSON
β Browser polls every 5s while the detail view is open
Data flow β SSH terminal
Browser WebSocket β API WS upgrade (validates session + RBAC)
β API opens an SSH connection to the target
β Bidirectional pipe (browser PTY β remote shell)
Data flow β root actions (OS users, cron, systemd, patches, drift, access grants, runbooks)
API opens SSH β uploads the script over stdin into a private temp file
β probes `sudo -n` first; sends a password to sudo's stdin only if needed
β runs the script, captures exit code and output, removes the temp file β audit log
| Layer | Technology |
|---|---|
| Runtime | Node.js 24 |
| API framework | Hono |
| ORM | Prisma |
| Database | PostgreSQL 18 |
| Auth | Better Auth with RBAC |
| SSH | ssh2 |
| Frontend | React 19 + Vite |
| Routing | TanStack Router |
| Data fetching | TanStack Query |
| UI components | shadcn/ui + Tailwind CSS |
| Charts | Recharts |
| Terminal | xterm.js |
| Containerization | Docker + Nginx |
All configuration is environment-driven. The authoritative list, with comments, is .env.example;
defaults below are the API's built-in defaults from apps/api/src/env.ts, with the Docker Compose value noted where
Compose overrides it.
Where the file goes. Docker Compose reads .env in the repository root. A bare-metal or development API reads
apps/api/.env, from its working directory. The systemd unit and PM2 config in this repository both point there.
Docker Compose passes through only the variables listed under services.api.environment in
docker-compose.yml. Every variable below is listed there except those marked β‘, which only apply to bare-metal
installs. To pass another variable through, add it to that section as a bare key (e.g. MY_VAR:) and set the value
in .env.
| Variable | Description |
|---|---|
BETTER_AUTH_SECRET |
Session-signing secret, at least 16 characters. Generate with openssl rand -hex 32 |
APP_ENCRYPTION_KEY or APP_ENCRYPTION_PASSPHRASE |
Master key or passphrase for at-rest encryption (AES-256-GCM). Accepts a 32-byte base64 value (openssl rand -base64 32) or any passphrase of at least 8 characters |
POSTGRES_PASSWORD |
Docker Compose. Password for the bundled PostgreSQL. Compose refuses to start without it. It is embedded in a URL, so use URL-safe characters (openssl rand -hex 24) |
DATABASE_URL |
Bare metal. PostgreSQL 18 connection URL. Percent-encode special characters in the password |
SEED_ADMIN_PASSWORD |
Password for the first admin, created on an empty database only. With NODE_ENV=production it must be at least 12 characters and not a published default |
| Variable | Default | Description |
|---|---|---|
DATABASE_URL |
postgresql://postgres:postgres@localhost:5432/server_inventory?schema=public |
Connection URL. In Compose it is built from POSTGRES_* (or DOCKER_DATABASE_URL) and any value in .env is ignored |
POSTGRES_USER |
rackmap |
Compose: database role for the bundled PostgreSQL |
POSTGRES_DB |
rackmap |
Compose: database name |
POSTGRES_HOST_PORT |
5432 |
Compose: host port the bundled PostgreSQL is published on. Change it if the host already runs PostgreSQL |
POSTGRES_BIND |
127.0.0.1 |
Compose: host address the database port is bound to. Keep it on loopback unless you need remote access |
DOCKER_DATABASE_URL |
β | Compose: use an external PostgreSQL 18 instead of the bundled one |
JOB_LOCK_TTL_MS |
120000 |
Lease TTL for background jobs, so that on multiple replicas each job runs on exactly one of them |
| Variable | Default | Description |
|---|---|---|
PORT |
3000 (Compose: 8080) |
Bare metal: the port the API listens on. In Compose it is the host port for the web UI. The API listens on 3001 inside the network |
NODE_ENV |
development (Compose: production) |
production enables the seed's password check, skips demo data, and refuses unverified license keys |
WEB_ORIGIN |
http://localhost:5173 (Compose: http://localhost:8080) |
URL the web app is reached at, used for CORS and auth cookies |
TRUSTED_ORIGINS |
= WEB_ORIGIN |
Comma-separated origins allowed to call the API. * reflects any origin and disables origin checks; use it only on a trusted private network |
BETTER_AUTH_URL |
http://localhost:5173 (Compose: http://api:3001) |
Base URL Better Auth uses. Compose sets it; bare metal usually matches WEB_ORIGIN |
PUBLIC_BASE_URL |
β | Externally reachable URL of this instance, such as https://rackmap.example.com. Managed hosts use it for heartbeat check-ins, and alerts use it for links. Leave it unset rather than empty |
SERVE_STATIC_DIR β‘ |
β | Bare metal: serve the built web app (apps/web/dist) from the API process |
SEED_ADMIN_EMAIL |
admin@example.com (Compose and .env.example) |
First-run admin email |
SEED_DEMO_DATA |
false |
Create demo accounts and sample servers. The demo accounts' credentials are published, so never enable it in production. Outside production, demo data is always seeded |
VAULT_PASSPHRASE |
β | Master credential-vault passphrase. When set, the vault unlocks at startup so background jobs can decrypt credentials |
VITE_API_URL |
β | Web build-time API base URL. Leave empty for same-origin via the proxy |
VITE_SECURITY_LOCK |
false |
Web build argument (Compose): true disables copy and right-click in the UI |
| Variable | Default | Description |
|---|---|---|
AUTH_RATE_LIMIT_ENABLED |
true |
Enable Better Auth rate limiting |
AUTH_RATE_LIMIT_MAX / AUTH_RATE_LIMIT_WINDOW |
200 / 60 |
General auth requests per window (seconds) |
AUTH_LOGIN_RATE_LIMIT_MAX / AUTH_LOGIN_RATE_LIMIT_WINDOW |
60 / 60 |
Sign-in attempts per client IP per window |
AUTH_LOGIN_ACCOUNT_RATE_LIMIT_MAX / AUTH_LOGIN_ACCOUNT_RATE_LIMIT_WINDOW |
10 / 60 |
Sign-in attempts per account (email) per window, whatever the source address |
ALLOW_SELF_SIGNUP |
false |
Let anyone reaching the API create their own viewer account |
TRUST_PROXY |
false (Compose: true) |
Honour X-Forwarded-For / X-Real-IP. Enable only behind a reverse proxy you control, because these headers set the audit-log IP and the rate-limit bucket |
TRUSTED_PROXY_CIDRS |
loopback + RFC 1918 | Comma-separated CIDRs of the proxies in front of RackMap (with TRUST_PROXY). Set it when a CDN or more than one proxy hop is in front |
| Variable | Default | Description |
|---|---|---|
SCHEDULER_ENABLED |
true |
Enable the background probe scheduler |
PING_INTERVAL_MS |
60000 |
Probe frequency (minimum 5000) |
PING_TIMEOUT_MS |
3000 |
Per-server TCP probe timeout |
PING_CONCURRENCY |
10 |
Maximum simultaneous probes |
STATUS_FLIP_THRESHOLD |
2 |
Consecutive failures before the status changes |
STATUS_RETENTION_DAYS |
30 |
Days of probe history to keep |
STATUS_SAMPLE_INTERVAL_MS |
900000 |
Store a probe result only on a status change or once per interval (0 = every probe) |
STATUS_MAX_ROWS |
10000 |
Cap on stored probe history across all servers; the oldest rows beyond it are pruned (0 = no cap). Admins can also clean it under Settings β Maintenance |
| Variable | Default | Description |
|---|---|---|
BACKUP_DIR |
β (Compose: /backups) |
Where pg_dump backups are written. Unset disables backups |
BACKUP_CRON |
0 2 * * * |
5-field cron, in the API process's local time zone (UTC in the Docker image) |
BACKUP_KEEP |
14 |
Number of newest rackmap-*.dump files kept |
| Variable | Default | Description |
|---|---|---|
METRICS_ENABLED |
true |
Enable agentless SSH metrics collection |
METRICS_SSH_TIMEOUT_MS |
10000 |
SSH exec timeout for metrics collection |
METRICS_ALERT_ENABLED |
true |
Send alerts when a threshold is crossed |
METRICS_ALERT_INTERVAL_MS |
300000 |
Minimum interval between repeat alerts (minimum 60000) |
ALERT_THRESHOLD_CPU / ALERT_THRESHOLD_RAM / ALERT_THRESHOLD_DISK |
90 / 95 / 90 |
Alert thresholds, in percent |
Alert channels (Slack, Teams, Discord, PagerDuty, Telegram, email, webhooks) are managed by admins under
Settings β Alerts. The NOTIFY_* variables are still honoured and appear there as read-only channels.
| Variable | Default | Description |
|---|---|---|
ALERT_DISPATCH_ENABLED |
true |
Run the alert delivery worker |
ALERT_DISPATCH_INTERVAL_MS |
5000 |
How often the delivery outbox is polled |
ALERT_OUTBOUND_TIMEOUT_MS |
10000 |
Timeout for each outbound delivery |
ALERT_OUTBOUND_ALLOW_PRIVATE |
false |
Allow webhook targets on private address ranges. Link-local and cloud-metadata addresses are always refused |
ALERT_OUTBOUND_ALLOW_HTTP |
false |
Allow plain-http webhook targets |
ALERT_OUTBOUND_ALLOWLIST |
β | Comma-separated hosts or CIDRs allowed despite the two rules above |
ALERT_DELIVERY_RETENTION_DAYS |
30 |
Days of delivery log to keep |
ALERT_MAX_EVENT_AGE_MS |
21600000 |
Events older than this (6 hours) are marked expired instead of being sent late |
SSL_SCAN_CRON |
0 6 * * * |
Daily SSL certificate scan (API process local time) |
NOTIFY_WEBHOOK_URL |
β | Legacy HTTP POST target for up/down alerts (read-only channel) |
NOTIFY_TELEGRAM_BOT_TOKEN / NOTIFY_TELEGRAM_CHAT_ID |
β | Legacy Telegram bot and chat (read-only channel) |
SMTP_HOST / SMTP_PORT / SMTP_USER / SMTP_PASS / SMTP_FROM |
β / 587 / β / β / rackmap@example.com |
SMTP for email channels, email preferences, and certificate-expiry mail |
| Variable | Default | Description |
|---|---|---|
HEARTBEAT_SWEEP_INTERVAL_MS |
30000 |
How often late and missed heartbeats are detected |
HEARTBEAT_PING_KEEP |
200 |
Check-ins kept per heartbeat |
HEARTBEAT_PING_RETENTION_DAYS |
30 |
Days of check-ins kept |
HEARTBEAT_PING_MAX_BODY_BYTES |
10240 |
Largest check-in body stored (maximum 1 MiB) |
RUNBOOK_WORKER_ENABLED |
true |
Run the runbook worker |
RUNBOOK_MAX_CONCURRENT_RUNS |
2 |
Runs executing at the same time |
RUNBOOK_MAX_SSH_SESSIONS |
20 |
SSH sessions across all runs |
RUNBOOK_MAX_TARGETS |
500 |
Largest target set a run may resolve to |
RUNBOOK_OUTPUT_MAX_BYTES |
262144 |
Output kept per host |
RUNBOOK_APPROVAL_TTL_HOURS |
24 |
Pending approvals expire after this many hours |
PATCH_SCAN_CRON |
0 3 * * * |
Nightly fleet patch scan |
PATCH_SCAN_CONCURRENCY |
5 |
Hosts scanned at a time (1β50) |
DRIFT_SCAN_CRON |
30 3 * * * |
Nightly configuration snapshot for drift detection |
DRIFT_SNAPSHOT_KEEP |
30 |
Snapshots kept per server (minimum 2) |
ACCESS_EXPIRY_SWEEP_INTERVAL_MS |
60000 |
How often expired access grants are revoked on their hosts |
PROMETHEUS_SD_DEFAULT_PORT |
9100 |
Scrape port advertised by /api/v1/prometheus/sd (node_exporter) |
| Variable | Default | Description |
|---|---|---|
SSH_ENABLED |
false |
Set to true to enable the browser SSH terminal (admin-only). This gates only the interactive terminal. Metrics, discovery, logs, ATOP, OS users, cron, systemd, patches, drift, access grants, and runbooks still run commands over SSH when it is false |
SSH_CONNECT_TIMEOUT_MS |
10000 |
SSH connection timeout |
SSH_IDLE_TIMEOUT_MS |
300000 |
Idle terminal timeout (5 minutes) |
SSH_MAX_SESSION_MS |
3600000 |
Maximum terminal session duration (1 hour) |
SSH_MAX_CONCURRENT |
5 |
Maximum simultaneous SSH terminal sessions |
SSH_REAUTH_INTERVAL_MS |
60000 |
How often a live terminal re-checks that the operator is still authorised |
SSH_HOST_POLICY |
accept-any |
Host-key verification. accept-any pins keys and warns loudly on a change but still connects; tofu refuses the connection. See below |
SSH_PRIVATE_KEY_PATH |
β | Private key tried first. If unset, /data, /root/.ssh, and the API user's ~/.ssh are probed |
DOCKER_HOST_OVERRIDE / HOST_GATEWAY |
β | Advanced: the address used when a server's IP is loopback and the API runs in a container. By default it is detected from host.docker.internal or the default route |
RackMap pins each endpoint's host key on first contact and compares it on every later connection. Verification runs during key exchange, before any credential is offered, so a changed key cannot harvest your password.
accept-any (the default) pins and warns but still connects β it exists so an
existing fleet can populate the store without an outage. tofu refuses.
Migrating an existing fleet:
-
Run on
accept-anyuntil every server has been contacted at least once. Servers that are never polled need a manual connection test, otherwise they are simply absent from the store. -
Review what was pinned, and compare against the hosts themselves:
curl -H "Authorization: Bearer sk_..." https://rackmap.example.com/api/v1/ssh-host-keys ssh <host> 'for f in /etc/ssh/ssh_host_*_key.pub; do ssh-keygen -lf $f; done'
Fingerprints are standard OpenSSH
SHA256:values, so they compare directly.Each entry carries
sharedWithOtherEndpoints. Expected for a cluster built from one image, worth investigating otherwise β it means one key is answering for several addresses. Narrow it down with?fingerprint=SHA256:.... -
Resolve anything unexpected, then set
SSH_HOST_POLICY=tofuand restart.
After a legitimate rebuild or reimage, forget the pin so the next connection re-pins it. Admin only, and audited with the fingerprint being discarded:
curl -X DELETE -H "Authorization: Bearer sk_..." \
https://rackmap.example.com/api/v1/ssh-host-keys/<id>A mismatch never overwrites the stored key β self-healing would erase the evidence.
| Variable | Default | Description |
|---|---|---|
LICENCIA_URL |
β (Compose: http://host.docker.internal:3003) |
Base URL of the Licencia server |
LICENCIA_API_KEY |
β | Tenant API key (lic_live_...) |
LICENCIA_LICENSE_KEY |
β | Master license key (LIC-PRO-...), activated on startup |
LICENCIA_PUBLIC_KEY |
β | Ed25519 SPKI public key for offline, air-gapped token verification |
BILLING_MODE |
disabled |
disabled: completing a checkout returns 501, and in production a license key is accepted only if Licencia can verify it. simulated marks orders paid without payment and accepts any key. Use it for local demos only |
RackMap supports hybrid fleets where some hosts require key pairs and others enforce password or PAM authentication.
- Automatic fallback β public key first (
/data/id_ed25519plus custom uploaded keys). If the target rejects the key, it falls back to password and PAM keyboard-interactive without failing. - Server detail controls (
/servers/:id) β Test Key Login (reports round-trip latency), Test Password Login, and Set / Change Password, which stores the credential encrypted in the vault. - Auto-prompt on auth failure β if discovery, ATOP, logs, or metrics hit an unauthorized host, a password dialog appears and the operation retries.
- Sudo password prompt β root actions (OS users, cron, systemd, patches, drift, access grants, auto-update) use the stored password only for
sudo. If sudo rejects it or has none, one app-wide prompt asks for it and retries with anX-Sudo-Passwordheader; it can be remembered in memory for the session or saved as the server's password (so background jobs work). It is never logged. Passwordless sudo on the host avoids the prompt entirely β see the User Guide.
RackMap uses a two-tier cryptographic architecture to protect infrastructure credentials.
Set APP_ENCRYPTION_KEY (or APP_ENCRYPTION_PASSPHRASE) in .env. It encrypts sensitive database fields β
server passwords, tokens, secrets β with AES-256-GCM.
# Recommended: maximum entropy
APP_ENCRYPTION_KEY=$(openssl rand -base64 32)
# Or a human-readable passphrase (a 32-byte AES key is derived from it)
APP_ENCRYPTION_PASSPHRASE="YourSecurePassphraseHere123!"Envelope encryption (v2.<iv>.<tag>.<cipher>) using PBKDF2 (100,000 iterations, SHA-512) to derive a 256-bit
key-encryption key that wraps an ephemeral 256-bit data-encryption key. The master passphrase is never stored in the
database β only a random salt and a verifier. Key derivation happens on the server, so the passphrase travels over
the connection on unlock: terminate TLS in front of RackMap. Option A below deliberately writes it to .env.
Three ways to unlock it:
| Option | Where | Best for |
|---|---|---|
| A β automated unlock | VAULT_PASSPHRASE in .env |
Production. Unlocks at API startup, so discovery, metrics, scheduled runbooks, patch and drift scans, and access-grant revocation can decrypt credentials unattended |
| B β global unlock via UI | Settings β Vault Security | Admins unlocking for the whole instance. Optionally tick Keep unlocked permanently (saves the passphrase to .env) to survive restarts |
| C β ephemeral session | Header of any server detail page | Per-operator, time-boxed unlock (30 minutes) |
There are two distinct operations, and only one of them destroys data.
Change the passphrase (safe). Requires the current passphrase. The data-encryption key is re-wrapped under the new passphrase, so every stored credential keeps working.
curl -X POST https://rackmap.example.com/api/v1/vault/reset \
-H "Content-Type: application/json" \
-H "Cookie: better-auth.session_token=<admin-session>" \
-d '{"currentPassphrase":"OldPassphrase!","newPassphrase":"NewSecureMasterPassphrase!"}'Recover a forgotten passphrase (destructive). Only when the current passphrase is genuinely lost. This mints a brand-new data-encryption key.
curl -X POST https://rackmap.example.com/api/v1/vault/reset \
-H "Content-Type: application/json" \
-H "Cookie: better-auth.session_token=<admin-session>" \
-d '{"newPassphrase":"NewSecureMasterPassphrase!","forceDestroy":true}'
forceDestroyis irreversible. Every credential encrypted under the old passphrase becomes permanently unreadable and must be re-entered. A request that supplies neithercurrentPassphrasenorforceDestroyis rejected β the API will not guess which one you meant.
In the UI: Settings β Vault Security β Reset / Change Passphrase, Security β Master Credential Vault, or the Vault badge in any server header. The destructive path is behind an explicit checkbox.
- Passwords encrypted at rest β AES-256-GCM, keyed by
APP_ENCRYPTION_KEY - Passwords never sent to the client β
passwordEncfields are stripped from every API response - No passwords on remote command lines β root scripts are uploaded over stdin; sudo passwords only reach sudo's stdin
- SSH terminal off by default β requires
SSH_ENABLED=trueand admin role (or an approved access request) - WebSocket auth β the WS upgrade validates the Better Auth session, ban status, and RBAC before opening SSH
- Outbound SSRF guard β alert webhooks are pinned to the DNS-checked address, never follow redirects, and refuse private, link-local, and metadata addresses by default
- Audit trail β every write, root action, and auth event recorded with actor, IP, and before/after state
- CORS β locked to
WEB_ORIGINunlessTRUSTED_ORIGINSsays otherwise; set it explicitly in production - Non-root container β the API image runs as
node(uid 1000)
Found a vulnerability? Please follow our Security Policy β do not open a public issue.
Permissions are defined once in packages/shared/src/permissions.ts and enforced
by the API; the UI hides what you cannot do.
| Permission | admin | editor | viewer |
|---|---|---|---|
| View servers, services, SSL certificates, tags, lookups | β | β | β |
| Create / update servers, services, and SSL entries | β | β | β |
| Delete / restore servers, services, and SSL entries | β | β | β |
| Reveal SSH password | β | β | request |
| Probes, live metrics, logs, ATOP, hardware discovery | β | β | β |
| SSH terminal | β | request | request |
| OS users: list, create, edit, lock, delete | β | β ΒΉ | β |
| Sudoers rules and privileged groups | β | β | β |
| Cron editor | β | β Β² | β |
| systemd services | β | β Β³ | β |
| Patch report | β | β | β |
| Patch scan | β | β | β |
| Apply patches | β | β | β |
| Drift: view, scan, acknowledge | β | β | β |
| Drift: accept a new baseline | β | β | β |
| Access grants: create; extend or revoke your own | β | β ΒΉ | β |
| Access grants: extend or revoke anyone's | β | β | β |
| Heartbeats: view | β | β | β |
| Heartbeats: create, edit, pause, rotate token | β | β | β |
| Heartbeats: delete | β | β | β |
| Runbooks: view, preview targets, run, cancel, rerun | β | β β΄ | β |
| Runbooks: author, edit, delete | β | β | β |
| Runbooks: approve a run (not your own) | β | β | β |
| Alert channels: view | β | β | β |
| Alert channels: manage, test, delivery log; server test alert | β | β | β |
| Tags: create | β | β | β |
| Tags: delete | β | β | β |
| Lookups: create / edit | β | β | β |
| Lookups: delete | β | β | β |
| Vault: unlock for your session | β | β | β |
| Vault: initialise, global unlock, change passphrase | β | β | β |
| Settings β Maintenance (status history) | β | β | β |
| Users, license, checkout, audit log | β | β | β |
| Approve access requests | β | β | β |
ΒΉ Not root, root-equivalent accounts (uid 0, a privileged group, or any sudoers rule), sudo, or privileged groups
(sudo, wheel, admin, docker, lxd, disk, root, adm, shadow, plus any group a %group sudoers rule or
gid 0 makes root-equivalent on that host).
Β² Ordinary users' crontabs only. Root's crontab, /etc/crontab, /etc/cron.d, and root-equivalent users need an admin.
Β³ Not protected units (SSH, networking, D-Bus, Docker, systemd-*, targets, mounts).
β΄ Root runs requested by editors, and runbooks marked requires approval, wait for a different admin to approve.
Viewers and editors submit access requests for the SSH terminal and password reveal; admins approve or reject with an expiry window. Several features also need a Pro license β see Licensing tiers.
Nothing is installed on monitored servers. Each poll:
- SSHs into the target using the stored (encrypted) credentials
- Runs a single compound shell command reading from
/proc,ps,df, and GPU tools - Parses the output server-side and returns typed JSON
- The browser polls every 5 seconds while the detail view is open
Detection runs in priority order:
| Priority | Vendor | Detection | Tools required |
|---|---|---|---|
| 1 | NVIDIA | nvidia-smi -L |
nvidia-smi |
| 2 | AMD (sysfs) | /sys/class/drm/card*/device/gpu_busy_percent exists |
Kernel amdgpu driver β no extra tools |
| 3 | AMD (ROCm) | rocm-smi on PATH |
rocm-smi |
| 4 | Intel | xpu-smi on PATH |
xpu-smi |
| β | None | fallback | β |
All vendors normalize to the same shape: utilization %, VRAM used/total (MiB), and temperature (Β°C, or null when unavailable).
Every endpoint requires an authenticated Better Auth session cookie or an Authorization: Bearer sk_β¦ API key,
except /health/*, /api/auth/*, /api/v1/public/config, and the heartbeat check-in /api/v1/ping/:token.
Role notes below are the minimum role; "Pro" marks a license feature. Root actions accept an optional
X-Sudo-Password header (never stored) when the stored password is rejected by sudo.
# Auth
POST /api/auth/sign-in/email
POST /api/auth/sign-out
# Servers
GET /api/v1/servers List + search + paginate (page, limit)
POST /api/v1/servers Create (editor+)
GET /api/v1/servers/:id Detail
PATCH /api/v1/servers/:id Update (editor+)
DELETE /api/v1/servers/:id Soft-delete (admin)
POST /api/v1/servers/:id/restore Restore a soft-deleted server (admin)
POST /api/v1/servers/:id/reveal-password Reveal the SSH password (editor+, or an approved request; audited)
GET /api/v1/servers/:id/status-history Recent probe results
POST /api/v1/servers/:id/check Probe now (editor+)
POST /api/v1/servers/check-all Probe every server now (editor+)
GET /api/v1/servers/:id/metrics Live SSH metrics (editor+)
POST /api/v1/servers/:id/auto-discover Detect and persist CPU, RAM, storage, OS (editor+, Pro)
POST /api/v1/servers/:id/recalculate-storage (editor+)
GET|POST /api/v1/servers/:id/auto-update unattended-upgrades status / enable, disable, remove (editor+; POST is Pro)
POST /api/v1/servers/:id/ssh-keys/test Test key login (editor+)
GET /api/v1/servers/:id/alert-channels Channels routed to this server (editor+)
POST /api/v1/servers/:id/test-alert Queue a test event for those channels (admin)
POST /api/v1/servers/import Excel import, with dryRun (editor+)
GET /api/v1/servers/export.xlsx Export to Excel
GET /api/v1/servers/export.json Export to JSON
WS /api/v1/servers/:id/ssh Browser terminal (SSH_ENABLED; admin or approved request)
# OS users & sudoers (editor+; create/update/delete are Pro)
GET /api/v1/servers/:id/os-users List accounts, UIDs, shells, groups, sudo privileges
POST /api/v1/servers/:id/os-users Create a Linux user
PATCH /api/v1/servers/:id/os-users/:username Update shell, home, groups, password, lock state, sudo rules
DELETE /api/v1/servers/:id/os-users/:username Delete a user (root/SSH safeguards apply)
POST /api/v1/servers/:id/os-users/sudo Atomic sudoers update (/etc/sudoers.d/rackmap_*) (admin)
# Granting sudo or a privileged group, or touching a root-equivalent account, requires admin.
# Cron (editor+; saving and run-now are Pro; root, /etc/crontab, /etc/cron.d and root-equivalent users need admin)
GET /api/v1/servers/:id/cron All crontabs + systemd timers on the host
PUT /api/v1/servers/:id/cron Replace one crontab (compare-and-set on its hash)
POST /api/v1/servers/:id/cron/run Run one entry now as its user
POST /api/v1/servers/:id/cron/monitor Wrap an entry with a heartbeat (Pro)
POST /api/v1/servers/:id/cron/unmonitor Remove the heartbeat wrapper (Pro)
# Heartbeats
GET|POST /api/v1/heartbeats List (all roles) / create (editor+)
GET /api/v1/heartbeats/config Ping base URL (null until PUBLIC_BASE_URL is set)
GET|PATCH|DELETE /api/v1/heartbeats/:id Detail, update (editor+), delete (admin)
GET /api/v1/heartbeats/:id/pings Recent check-ins
POST /api/v1/heartbeats/:id/{pause,resume,rotate-token} (editor+)
GET|POST /api/v1/ping/:token[/start|/fail|/log|/<exit code>] Check-in (no auth β the token is the credential)
# Runbooks (Pro)
GET|POST /api/v1/runbooks List (editor+) / create (admin)
GET|PATCH|DELETE /api/v1/runbooks/:id Detail (editor+), update, soft-delete (admin)
POST /api/v1/runbooks/:id/preview-targets Resolve the target servers first (editor+)
POST /api/v1/runbooks/:id/runs Start a run (editor+; root runs by editors need approval)
GET /api/v1/runbook-runs[/:id] Run history and per-host status
GET /api/v1/runbook-runs/pending-count Runs awaiting approval (admin)
GET /api/v1/runbook-runs/:id/hosts/:serverId/output Incremental output
POST /api/v1/runbook-runs/:id/{approve,reject} (admin, not the requester)
POST /api/v1/runbook-runs/:id/{cancel,rerun} (editor+)
# Alert channels (admin; editors can list and view)
GET|POST /api/v1/alert-channels
POST /api/v1/alert-channels/test Test an unsaved configuration
GET|PATCH|DELETE /api/v1/alert-channels/:id
POST /api/v1/alert-channels/:id/test
GET /api/v1/alert-channels/:id/deliveries Delivery log (admin)
GET /api/v1/alert-events Alert events (admin)
# systemd (editor+; actions are Pro; protected units need admin)
GET /api/v1/servers/:id/systemd/units[/:unit[/logs]]
POST /api/v1/servers/:id/systemd/units/:unit/action {action: start|stop|restart|reload|enable|disable}
# Patches
GET /api/v1/patches[/summary] Fleet report (all roles)
POST /api/v1/patches/scan Queue scans (editor+, Pro)
GET /api/v1/servers/:id/patches
POST /api/v1/servers/:id/patches/scan (editor+, Pro)
POST /api/v1/servers/:id/patches/apply {mode: security|all} (admin, Pro)
# Drift (editor+; scans are Pro; accepting a baseline needs admin)
GET /api/v1/drift/events | /api/v1/drift/summary
POST /api/v1/drift/events/:id/acknowledge
GET /api/v1/servers/:id/drift
POST /api/v1/servers/:id/drift/{scan,baseline}
# Access grants (editor+, Pro; root or privileged targets need admin)
GET /api/v1/access-grants[/:id]
POST /api/v1/access-grants/{users,keys} Temporary account / SSH key
POST /api/v1/access-grants/:id/{extend,revoke} Creator or admin
# Status history (admin)
GET /api/v1/status-history/stats
POST /api/v1/status-history/prune {olderThanDays?, keepNewest?}
# Prometheus (session or API key; viewer is enough)
GET /api/v1/metrics Exporter
GET /api/v1/prometheus/sd http_sd_configs targets
# Logs & ATOP forensics (editor+; ATOP history is Pro)
POST /api/v1/servers/:id/logs Query journalctl/syslog by priority and unit
GET /api/v1/servers/:id/atop/dates List historical ATOP activity dates
POST /api/v1/servers/:id/atop/snapshots Query ATOP interval snapshots
POST /api/v1/servers/:id/atop/interval-processes Processes in one interval
POST /api/v1/servers/:id/atop/top-processes Top CPU/memory/disk processes per interval
# Services (same permission model as servers)
GET|POST /api/v1/services
GET|PATCH|DELETE /api/v1/services/:id
POST /api/v1/services/:id/{restore,reveal-password,check}
POST /api/v1/services/check-all
POST /api/v1/services/import Β· GET /api/v1/services/export.{xlsx,json}
# SSL certificates
GET /api/v1/ssl
POST /api/v1/ssl Add a domain (editor+)
PATCH /api/v1/ssl/:id (editor+)
DELETE /api/v1/ssl/:id (admin)
POST /api/v1/ssl/:id/restore (admin)
POST /api/v1/ssl/scan | /api/v1/ssl/:id/scan Scan now (editor+)
# SSH keys and host keys
GET|POST /api/v1/ssh-keys Custom private keys tried before password auth (editor+)
DELETE /api/v1/ssh-keys/:id (admin)
POST /api/v1/ssh-keys/test-server/:serverId (editor+)
GET /api/v1/ssh-host-keys Review pinned host keys (editor+); ?serverId= / ?fingerprint=
DELETE /api/v1/ssh-host-keys/:id Forget a pin so the next connection re-pins (admin, audited)
# Credential vault
GET /api/v1/vault/status
POST /api/v1/vault/unlock | /api/v1/vault/lock Your session (unlock: editor+)
POST /api/v1/vault/{init,unlock-global,lock-global,reset} (admin)
# Lookup tables β :type is cloud-providers, gpu-types, allocated-to, locations, server-types, network-types
GET /api/v1/lookups/:type (all roles)
POST /api/v1/lookups/:type (editor+)
PATCH /api/v1/lookups/:type/:id (editor+)
DELETE /api/v1/lookups/:type/:id (admin)
# Tags
GET /api/v1/tags Β· POST /api/v1/tags (editor+) Β· DELETE /api/v1/tags/:id (admin)
# Users (admin) β listing and creation go through Better Auth's admin API
PATCH /api/v1/users/:id
POST /api/v1/users/:id/set-password
POST /api/v1/users/:id/ban
POST /api/v1/users/:id/unban
PATCH /api/v1/users/:id/role
DELETE /api/v1/users/:id
# Access requests
POST /api/v1/access-requests
GET /api/v1/access-requests Admin: all; others: their own
GET /api/v1/access-requests/check Does the caller hold an approval for a server/service?
GET /api/v1/access-requests/pending-count (admin)
PATCH /api/v1/access-requests/:id Approve or reject (admin)
DELETE /api/v1/access-requests/:id (admin)
# API keys, saved views, current user
GET|POST /api/v1/api-keys Β· DELETE /api/v1/api-keys/:id
GET|POST /api/v1/views Β· PATCH|DELETE /api/v1/views/:id
GET /api/v1/me Β· GET|PATCH /api/v1/me/preferences
# License & checkout
GET /api/v1/license
POST /api/v1/license/{activate,deactivate} (admin)
GET /api/v1/checkout/plans
POST /api/v1/checkout/session (admin)
POST /api/v1/checkout/complete (admin; 501 unless BILLING_MODE=simulated)
GET /api/v1/checkout/orders[/:id]
# Audit
GET /api/v1/audit Paginated audit log (admin, cursor-based)
# Health
GET /health/live {"status":"ok"} while the process is up
GET /health/ready Database check (503 when unreachable) + backup status
-
Prepare the environment. Create
.envas in the Quick Start.POSTGRES_PASSWORDis required and Compose refuses to start without it. It is embedded in a connection URL, so use URL-safe characters (openssl rand -hex 24does). For a public deployment also set:WEB_ORIGIN=https://rackmap.example.com PUBLIC_BASE_URL=https://rackmap.example.com # needed for cron heartbeats
-
Pick ports. The web UI is published on
8080by default; override withPORT. PostgreSQL is published on127.0.0.1:5432; override withPOSTGRES_HOST_PORTif the host already runs PostgreSQL. -
Start:
docker compose up -d --build docker compose ps # postgres, api and web should become "healthy"The API container runs migrations (
prisma migrate deploy) and the seed on every start. The seed only acts on an empty database. -
Volumes.
Volume Mounted at Holds pgdata/var/lib/postgresql(postgres)The PostgreSQL 18 data directory backups/backups(api)Nightly pg_dumpbackupssqlite_data/data(api)SSH keys, plus the old inventory.dbon installs upgraded from SQLite. The name is kept so an upgrade does not detach your keysThe API runs as the non-root
nodeuser (uid 1000). A volume first created by a root-run image (before 0.7), or files copied into it as root, must be handed over once:docker run --rm --user root -v <project>_sqlite_data:/data <api-image> chown -R node:node /data
<project>is the Compose project name (the directory name by default) and<api-image>is the image shown bydocker compose images api. -
Reverse proxy (recommended). Put RackMap behind Nginx, Caddy, or Traefik with TLS termination. The API and web UI are already combined behind the web container's Nginx, so one upstream (
PORT) is enough. Give the SSH terminal path WebSocket upgrade headers, and allow long reads: RackMap's own Nginx gives the terminal 24 hours and host actions such as patch apply 30 minutes.
External PostgreSQL. Set DOCKER_DATABASE_URL=postgresql://rackmap:<password>@db.example.com:5432/rackmap. The
bundled postgres service still starts and still needs POSTGRES_PASSWORD, but the API does not use it.
Updating. git pull && docker compose up -d --build. Always rebuild both images: the web image carries the Nginx
configuration.
Requirements: Node.js 24, pnpm 10 (corepack enable pnpm), PostgreSQL 18, and postgresql-client-18 if you want
backups.
# 1. Database
sudo -u postgres psql -c "CREATE ROLE rackmap WITH LOGIN PASSWORD '<strong-password>';"
sudo -u postgres psql -c "CREATE DATABASE rackmap OWNER rackmap;"
# 2. Configuration β the API reads .env from its working directory
cp .env.example apps/api/.env
# then set at least: NODE_ENV=production, DATABASE_URL, APP_ENCRYPTION_KEY, BETTER_AUTH_SECRET,
# SEED_ADMIN_EMAIL, SEED_ADMIN_PASSWORD, WEB_ORIGIN, BETTER_AUTH_URL, PORT
# and optionally BACKUP_DIR=/backups, SERVE_STATIC_DIR=<repo>/apps/web/dist
# 3. Build, migrate, seed
pnpm install --frozen-lockfile
pnpm build
pnpm --filter @inv/api db:deploy
(cd apps/api && node dist/seed.js)
# 4a. PM2
pm2 start ecosystem.config.cjs
# 4b. or systemd (expects the checkout at /opt/server-inventory; edit User= and paths to suit)
sudo cp server-inventory.service /etc/systemd/system/
sudo systemctl daemon-reload
sudo systemctl enable --now server-inventoryA typical DATABASE_URL is postgresql://rackmap:<password>@127.0.0.1:5432/rackmap?schema=public. Either serve
apps/web/dist with your own web server and proxy /api/ and /health/ to the API, or set SERVE_STATIC_DIR to
let the API serve it. MIGRATION.md has a
full walkthrough, including the PostgreSQL 18 apt repository and an Nginx example.
1.0.0 has breaking changes. SQLite is gone, POSTGRES_PASSWORD is now required, and some permissions and license
checks have moved. Follow the checklist in MIGRATION.md before pulling the new
images. It covers copying an existing SQLite database into PostgreSQL with
pnpm --filter @inv/api db:migrate:postgres.
With BACKUP_DIR set (Compose sets it to /backups), the API writes pg_dump --format=custom backups named
rackmap-<timestamp>.dump on BACKUP_CRON (default 0 2 * * *) and keeps the newest BACKUP_KEEP (default 14).
The API image includes the PostgreSQL 18 client. /health/ready reports the last backup as backup.status
(ok, degraded with a reason, or disabled) without failing readiness.
Restore into the bundled database (stop the API first so nothing writes during the restore):
docker compose cp api:/backups/rackmap-<timestamp>.dump ./restore.dump
docker compose stop api
docker compose exec -T postgres pg_restore -U rackmap -d rackmap --clean --if-exists --no-owner < restore.dump
docker compose start apiBare metal: pg_restore -h 127.0.0.1 -U rackmap -d rackmap --clean --if-exists --no-owner rackmap-<timestamp>.dump.
A backup is only as good as the secrets that decrypt it: keep APP_ENCRYPTION_KEY and VAULT_PASSPHRASE somewhere
other than the backup volume.
Background jobs (probes, alerts, backups, scans, sweeps) take a lease row in the database before running, so each job
runs on exactly one replica. No configuration is needed; JOB_LOCK_TTL_MS controls how quickly another replica takes
over from one that died.
RackMap is meant to be the source of truth for your fleet, so machine clients are first-class. Create an API key under Security β API Keys and use it as a bearer token:
curl -H "Authorization: Bearer sk_..." https://rackmap.example.com/api/v1/serversKeys carry a role ceiling (scopeRole) and an optional expiry. A key can never
exceed the role of whoever created it, and it is capped again at request time
against the owner's current role β so demoting or banning a user immediately
demotes their keys. Keys default to viewer; mint the least privilege that works.
# A read-only key that expires in 90 days
curl -X POST https://rackmap.example.com/api/v1/api-keys \
-H "Content-Type: application/json" \
-H "Cookie: better-auth.session_token=<your session>" \
-d '{"name":"ansible","scopeRole":"viewer","expiresInDays":90}'The raw key is returned once.
contrib/rackmap-inventory.py turns your RackMap
inventory into an Ansible one, so you stop maintaining the fleet in two places:
export RACKMAP_URL=https://rackmap.example.com
export RACKMAP_API_KEY=sk_...
ansible -i contrib/rackmap-inventory.py gpu -m pingHosts are grouped by environment, cloud provider, location, server type, owning team, tag, probe status, and whether they have GPUs. See contrib/README.md.
GET /api/v1/metrics exposes fleet state in the Prometheus text format. RackMap
deliberately does not store a time series of its own β Prometheus does that job
better than an inventory database would.
scrape_configs:
- job_name: rackmap
metrics_path: /api/v1/metrics
scheme: https
authorization:
type: Bearer
credentials_file: /etc/prometheus/rackmap.key
static_configs:
- targets: ["rackmap.example.com"]Exports server/service/certificate counts by status, per-host up/down and probe
latency, probe staleness (a rising rackmap_server_last_probe_age_seconds means
the scheduler has stopped), GPU counts, days remaining on every tracked
certificate, heartbeats, runbook runs, alert deliveries, pending/security
updates, reboot-required hosts, open drift events, and active access grants.
Service discovery. GET /api/v1/prometheus/sd returns http_sd_configs
targets for every server (default port PROMETHEUS_SD_DEFAULT_PORT, 9100 for
node_exporter) with rackmap_* labels, filterable by environment, location, tag,
or status. Both endpoints accept a viewer-scoped API key as a Bearer token.
contrib/prometheus/prometheus.yml has a ready scrape config and
contrib/grafana/rackmap-fleet.json an importable dashboard.
Prerequisites: Node.js 24, pnpm 10, and a local PostgreSQL 18.
pnpm install
cp .env.example apps/api/.env # the API reads apps/api/.env; set PORT=3001 and DATABASE_URL
pnpm --filter @inv/api db:generate
pnpm --filter @inv/api db:deploy # apply migrations to your dev database
pnpm --filter @inv/api db:seed # admin + demo accounts (development only)
pnpm dev
# API β http://localhost:3001 (the Vite dev server proxies /api and /health here)
# Web β http://localhost:5173| Command | What it does |
|---|---|
pnpm dev |
Run API and web in watch mode |
pnpm build |
Build every workspace. Run it before pnpm typecheck, because it generates the web route tree |
pnpm typecheck |
Type-check every workspace |
pnpm test |
Run the Vitest suite against PostgreSQL (TEST_DATABASE_URL, a database whose name ends in _test) |
pnpm e2e |
Run Playwright end-to-end tests against a running instance |
pnpm e2e:ui |
Playwright in interactive UI mode |
pnpm db:studio |
Open Prisma Studio |
See CONTRIBUTING.md for the test database, writing migrations, branch naming, commit conventions, and the PR checklist.
RackMap ships with a public product portal at /portal:
- Product showcase β agentless SSH architecture, encrypted credential vault, kernel-level ATOP analysis
- Interactive mock console β simulated server fleet, ATOP replay, vault unlock, and remote OS user audit
- Pricing matrix β monthly vs. annual toggle across Free, Professional, and Enterprise tiers
- Self-hosting quickstart β a copyable
docker-compose.ymlsnippet - FAQ β envelope encryption, air-gapped activation, supported Linux distributions
Subscription entitlements are backed by Licencia. RackMap runs fully functional without it β the Free Community Edition is the default when no license is configured.
| Feature / limit | License feature | Free Community | Pro | Enterprise |
|---|---|---|---|---|
| Max managed servers | unlimited_servers |
10 | 100 | Unlimited |
| Server, service & SSL inventory, probes, live metrics, logs | β | β | β | β |
| SSH terminal, access requests, audit log, vault | β | β | β | β |
| Viewing OS users, crontabs, systemd units, patch report | β | β | β | β |
| Heartbeats created by hand | β | β | β | β |
| Alert channels | multi_channel_alerts |
1 channel ΒΉ | Unlimited | Unlimited |
| Hardware auto-discovery | hardware_discovery |
β | β | β |
| ATOP historical replay | atop_history |
β | β | β |
| OS users: create, edit, delete | remote_os_users |
β | β | β |
| Automated system updates | auto_update |
β | β | β |
| Cron editor: save, run now, heartbeat monitoring | remote_cron |
β | β | β |
| Runbooks & fleet execution | runbooks |
β | β | β |
| systemd actions (start/stop/restart/β¦) | service_manager |
β | β | β |
| Patch scan now & apply | patch_management |
β | β | β |
| Drift detection | drift_detection |
β | β | β |
| Time-boxed access grants | access_expiry |
β | β | β |
ΒΉ Free allows one channel of type Slack, Discord, Telegram, email, or webhook, without filters or custom templates,
plus the legacy NOTIFY_* channels and per-user email preferences. Pro adds unlimited channels, Microsoft Teams,
PagerDuty, filters, and templates. If a license lapses nothing is deleted; deliveries outside the free allowance are
logged as suppressed.
Manage licenses in Settings β Subscription & Billing (admin): view node quota usage, activate an online key, or paste
an offline signed Ed25519 lease token. Activating, deactivating, and checkout are admin-only. Completing a checkout
requires BILLING_MODE=simulated, because there is no payment gateway. In production, a license key is accepted only
when Licencia can verify it.
Two GitHub Actions workflows ship with the repository:
ci.ymlβ on every push tomainand every PR, against a PostgreSQL 18 service: a migration drift check (prisma migrate diff --exit-code), build, typecheck, and the full test suitedocker-publish.ymlβ build and publish multi-stage production images
| Trigger | Result |
|---|---|
Push to main |
Images tagged latest and sha-<commit> |
Git tag v*.*.* |
Images tagged with the semantic version (1.0.0, 1.0) |
| Pull request | Test build of both containers, nothing pushed |
| Manual dispatch | Choose target registries via push_to_dockerhub / push_to_ghcr |
Registries
- GHCR β zero configuration via
GITHUB_TOKEN. Images:ghcr.io/<owner>/rackmap/apiandghcr.io/<owner>/rackmap/web - Docker Hub β enabled when
DOCKERHUB_USERNAMEandDOCKERHUB_TOKENrepository secrets are set
Contributions are welcome. Start with CONTRIBUTING.md, and please read our Code of Conduct.
- π Report a bug
- π‘ Request a feature
- π Report a vulnerability β privately, please
RackMap is licensed under the GNU Affero General Public License v3.0 (AGPL-3.0). See LICENSE for the full text.
In short: you may use, modify, and self-host RackMap freely. If you run a modified version as a network service, you must make your source available to its users under the same license.
