No description
  • TypeScript 91.8%
  • Shell 8.2%
Find a file
Repository files (latest commit first)
Filename Latest commit message Latest commit date
2026-10-21 14:48:22 -04:00
packages d 2026-10-21 14:48:22 -04:00
test d 2026-10-21 14:48:22 -04:00
.gitignore d 2026-10-21 14:48:22 -04:00
Behaviour.md v0 2026-10-20 11:38:50 -04:00
bun.lock d 2026-10-21 14:48:22 -04:00
install.sh d 2026-10-21 14:48:22 -04:00
package.json d 2026-10-21 14:48:22 -04:00
README.md d 2026-10-21 14:48:22 -04:00
tsconfig.json d 2026-10-21 14:48:22 -04:00

statusDeployer

Bun + TypeScript status monitoring and service deployment, split into four workspaces:

Package Responsibility
packages/master Public status UI/API, account authentication, worker/service registry, deployment queue, notices, watches and audit history
packages/worker Outbound-only WebSocket agent: systemd status, deploy/install/start/stop/uninstall commands, monitoring of existing units
packages/status-lib Service-side client for reporting independent named watches over HTTP PUT
packages/cli statusctl account and master administration commands

How it works

  1. Master runs the public status page and the admin API. It stores everything in SQLite.
  2. Worker runs on each server. It opens an outbound WebSocket to the master, authenticates with its worker secret, and every REPORT_SECONDS (15) sends the systemd state of the units it manages or monitors. It has no public port.
  3. Operator uses statusctl (or the admin API) to add workers, add services, push commit hashes and start/stop services. Commands are forwarded to the right worker over its WebSocket.
  4. Deploy (on the worker): clone/fetch the repo into SERVICES_ROOT/<service>, check out the exact commit, download the service .env from the master, run install.sh if present, write and enable the systemd unit, restart it.
  5. Service (inner status): the deployed app uses status-lib with the .env the master generated (SERVICE_NAME, STATUS_URL, STATUS_SECRET) to PUT named watch values directly to the master.
  6. Offline detection: a watch that is not refreshed within its threshold (default STALE_SECONDS=90) is shown as offline.

Secrets: each worker has its own secret (shown once on creation/rotation); each service gets a derived secret delivered only through its .env. The worker never receives the master secret.

Quick start: master, worker, service

1. Install the master

Run the installer as root on the master:

curl -fsSL <raw-url>/install.sh | sudo SD_REPO=<git-url> bash -s -- master --public-url https://status.example.com

The installer installs Bun if needed, creates the SQLite database and systemd service, then prints the initial statusadmin password. Save it and log in:

sudo statusctl login --url https://status.example.com --username statusadmin --password '<initial-password>'

2. Add a worker (on the master)

statusctl worker add web1 "Web server 1"   # prints the worker secret once
statusctl worker list
statusctl worker rotate web1               # lost the secret? issue a new one, update the worker
statusctl worker delete web1               # only when it has no services

3. Install the worker (on the target server, as root)

curl -fsSL <raw-url>/install.sh | sudo SD_REPO=<git-url> bash -s -- worker \
  --id web1 --name "Web server 1" --master-url https://status.example.com \
  --worker-secret '<returned-secret>' --monitor nginx,mongod

--monitor is optional (see Monitoring existing systemd units). The installer writes /etc/statusdeployer/worker.env and enables statusdeployer-worker.service. Check with systemctl status statusdeployer-worker and statusctl worker list (last seen updates). To change settings later, edit the env file and systemctl restart statusdeployer-worker. Required env: WORKER_ID, WORKER_SECRET, MASTER_URL; optional: WORKER_NAME, SERVICES_ROOT, STATE_DIR, REPORT_SECONDS, MONITOR_UNITS.

4. Add and deploy a service

See below.

The worker opens an authenticated outbound WebSocket to the master. It does not listen on a port and does not require inbound firewall access. Workers and services use separate credentials. Service-watch secrets are derived per worker/service by the master and delivered to the service only in its generated .env; the worker never receives the master secret.

The master keeps normalized worker/service/watch/deployment/notices/audit data and historical watch values in Bun's embedded SQLite database. This keeps the initial install self-contained; MariaDB is not currently a supported storage backend.

Default paths:

  • Master config: /etc/statusdeployer/master.env
  • Master database: /var/lib/statusdeployer-master/statusdeployer.sqlite
  • Worker config/state: /etc/statusdeployer/worker.env, /var/lib/statusdeployer-worker
  • Service folders: /opt/statusdeployer/services/<service-name>
  • systemd service units: /etc/systemd/system/<unit-name>.service

SERVICES_ROOT must agree between master and workers. Set it in each role's environment file before registering services if you want another root. Place the master behind HTTPS; WebSocket worker traffic then uses WSS.

Separate statusctl client

statusctl is installed on the master by the one-line installer. To use it on another machine, run bun run packages/cli/src/index.ts from a checkout or install the CLI workspace package. CLI credentials are stored under ~/.config/statusdeployer/credentials.json with mode 600.

Create and deploy a service

Each service has one assigned worker, repository, systemd unit, and commit hash. The initial create can omit --hash; send its first deployment with service update.

statusctl service add myapp --worker web1 --repo https://github.com/example/myapp.git --unit myapp
statusctl service env myapp DB_PASSWORD=@/root/db-password.txt OTHER_SETTING=value
statusctl service update myapp 0123456789abcdef0123456789abcdef01234567

An empty value deletes a key (KEY=); use KEY=@/path/to/file to load a secret from a protected file instead of putting its value directly in shell history.

The accepted update contract is:

PUT /api/deploy
Authorization: Bearer <CLI session token>
Content-Type: application/json

{"serviceName":"myapp","serviceGitHash":"0123456789abcdef0123456789abcdef01234567"}

Only changed commit hashes are queued. The master records the deployment and sends the command over the worker WebSocket. The worker fetches and checks out that exact commit, requests the service .env from the master using a short-scope worker/service credential, writes it mode 600, runs install.sh if present, writes the systemd unit, enables/restarts the service, and reports the result. .env environment updates are applied on the next install/deploy.

Systemd and deployment operations are also available through the CLI:

statusctl service install myapp
statusctl service start myapp
statusctl service stop myapp
statusctl service delete myapp
statusctl deployment list

service delete sends an uninstall command before removing the assignment; it refuses if the worker is offline. Source files are retained by uninstall, but the .env file and systemd unit are removed.

Monitoring existing systemd units

A worker can report units it did not install (nginx, mongod, ...). This only reads systemctl show; nothing is deployed or controlled.

  1. Tell the worker which units to watch: MONITOR_UNITS=nginx,mongod in /etc/statusdeployer/worker.env (or --monitor at install), then restart the worker.
  2. Create a monitor-only service on the master whose unit name matches:
statusctl service add nginx --worker web1 --unit nginx --monitor-only

Its alive watch is true while the unit's ActiveState is active, and goes offline/red if the unit stops or the worker disappears. Monitor-only services reject install/start/stop/deploy requests. To test the pipeline on a machine, monitor any unit, even one the worker did not install.

Report named watches

The master creates an alive watch for every service and updates it from worker-reported systemd state. Add independent checks from the service process:

import { StatusClient } from "@statusdeployer/status-lib";

const status = new StatusClient(); // reads SERVICE_NAME, STATUS_URL, STATUS_SECRET from .env
await status.registerWatch("discord-up", true, { message: "connected" });
await status.report("discord-send", true, { version: "0123456", thresholdSeconds: 60 });

status.start("api-check", async () => {
  const response = await fetch("https://api.example.test/health");
  return response.ok;
}, { message: "API health endpoint", category: "warning" }, 15_000);

The service library sends PUT /api/service/{service}/watches/{watch}. Each update becomes a historical database row and refreshes that watch's last value/time. A watch is shown offline when it misses its configured heartbeat threshold. The public UI shows current watches and active notices; public JSON endpoints include GET /api/status, GET /api/services, GET /api/services/{name}, GET /api/services/{name}/watches/{watch}/history, and GET /api/notices.

Accounts, notices and audit

Create additional CLI accounts while logged in:

statusctl account add deployer --password '<at-least-12-characters>'
statusctl account list
statusctl account delete deployer
statusctl notice add myapp warning "Maintenance window at 22:00"
statusctl audit

All administrative APIs except public read-only status routes, health checks, and login require a logged-in account. Interactions, worker connections/commands, and watch updates are audited. Account passwords, session tokens, and worker credentials are stored as hashes. Raw worker secrets are returned at creation/rotation time only.

Development

bun install
bun test
bun run typecheck
bun run build