14 KiB
Project: Incus-Restic Backup Control Plane (ZFS Block-Level)
Overview
Build a dark-mode backup operations dashboard (React frontend + Node.js backend) to manage incremental ZFS block-level backups of Incus VMs via Restic to S3 storage.
The UI should be visually inspired by Zerobyte: calm dark operator interface, compact status panels, clear backup lifecycle visibility, and strong restore safeguards. This is an application dashboard, not a marketing landing page.
The backend is a safe CLI orchestration layer that executes native host commands (incus, zfs, restic, udevadm, dd) and exposes structured REST APIs to the frontend.
Core product goals:
- Show Incus VM backup health at a glance.
- Trigger and monitor ZFS block-level Restic backups.
- List Restic snapshots per VM.
- Restore a VM from a selected snapshot with explicit destructive confirmation.
- Provide job status, logs, cleanup behavior, and per-VM locking for operational safety.
Tech Stack
| Layer | Technology |
|---|---|
| Frontend | React (Vite), Tailwind CSS, Lucide Icons, Axios |
| Backend | Node.js, Express |
| Communication | REST API |
| Execution model | Background jobs with per-VM locks |
| Host requirement | Backend must run as root/sudo on the Incus host (needs /dev/zvol/ and Incus Unix socket access) |
Repository Structure
/
├── backend/
│ ├── .env
│ ├── package.json
│ └── src/
│ ├── index.js # Express server entry point
│ ├── config.js # environment validation
│ ├── executor.js # spawnCommand() and streaming helpers
│ ├── jobs.js # in-memory job store and per-VM locks
│ ├── validators.js # VM/snapshot input validation
│ └── routes/
│ ├── vms.js
│ ├── snapshots.js
│ ├── backup.js
│ ├── restore.js
│ ├── jobs.js
│ └── health.js
└── frontend/
├── vite.config.js
├── tailwind.config.js
└── src/
├── main.jsx
├── api.js # Axios instance
├── App.jsx
└── components/
├── Dashboard.jsx
├── VMDetail.jsx
├── SnapshotTable.jsx
├── JobStatusPanel.jsx
├── StatusCard.jsx
└── RestoreModal.jsx
Environment Variables (backend/.env)
# Restic & S3 Config
AWS_ACCESS_KEY_ID="your_s3_key"
AWS_SECRET_ACCESS_KEY="your_s3_secret"
RESTIC_REPOSITORY="s3:https://s3.eu-central-1.amazonaws.com/bucket-name"
RESTIC_PASSWORD="restic_encryption_password"
# Incus & ZFS Config
ZFS_POOL_NAME="incus-pool"
PORT=3000
# Optional UI/API guard for first MVP deployment
API_TOKEN="change-me"
Note:
AWS_ACCESS_KEY_ID,AWS_SECRET_ACCESS_KEY,RESTIC_REPOSITORY, andRESTIC_PASSWORDmust be passed as environment variables to everyresticsubprocess call.
Backend Implementation Principles
Safe Command Execution
- Use Node.js
child_process.spawn(command, args, options)for all host commands. - Do not interpolate user-controlled values into shell strings.
- Avoid
execandshell: true. - Always inject required env vars (AWS credentials, Restic config) into the child process environment.
- Return
{ stdout, stderr, exitCode }. - On non-zero exit code, throw an error with the stderr content.
- Stream long-running command output into the job log.
- Implement pipelines such as
restic dump | ddwith connected Node streams, not by passing a shell pipeline string tosh.
Validation
vmNamemust be validated againstincus list --format jsonbefore use.snapshotIdmust be either"latest"or match an existing Restic snapshot for that VM.- Restore requests must include explicit confirmation:
{
"snapshotId": "a1b2c3d4",
"confirmVmName": "my-vm"
}
The backend must reject restore requests where confirmVmName !== vmName.
Job Model
Backup and restore operations are long-running and must run as background jobs.
Job shape:
{
"id": "job_abc123",
"type": "backup",
"vmName": "my-vm",
"status": "queued|running|success|failed",
"startedAt": "2026-05-20T12:00:00Z",
"finishedAt": null,
"currentStep": "Creating Incus snapshot",
"logs": ["..."],
"error": null
}
Rules:
- Only one backup or restore job may run per VM at a time.
- Use an in-memory job store for MVP.
- Return
409 Conflictif a job is already running for the same VM. - Keep recent completed jobs in memory for UI visibility.
- Restore jobs must never be automatically retried.
API Endpoints
GET /api/health
Purpose: Show backend and host command readiness.
Checks:
- Required environment variables are present.
incus,zfs, andresticare executable.- Restic repository can be reached with
restic snapshots --jsonor a lightweight equivalent.
Response Example:
{
"ok": true,
"checks": {
"config": "ok",
"incus": "ok",
"zfs": "ok",
"restic": "ok"
}
}
GET /api/vms
Purpose: List all Incus VMs on the host.
CLI Command:
incus list --format json
Logic:
- Parse the JSON array returned by Incus.
- Filter for entries where
type === "virtual-machine". - Enrich with latest known job status if available.
- Return array of VM summary objects.
Response Example:
[
{
"name": "my-vm",
"status": "Running",
"activeJob": null,
"lastJobStatus": "success"
}
]
GET /api/snapshots/:vmName
Purpose: List all Restic backup snapshots for a specific VM.
CLI Command:
restic snapshots --json --tag <vmName>
Logic:
- Parse the JSON array.
- Sort descending by
timefield. - Return the array as-is (frontend uses
id,time,tags).
Response Example:
[
{ "id": "a1b2c3d4", "time": "2024-07-10T02:00:00Z", "tags": ["my-vm"] }
]
GET /api/jobs
Purpose: List recent backup and restore jobs.
Response Example:
[
{
"id": "job_abc123",
"type": "backup",
"vmName": "my-vm",
"status": "running",
"currentStep": "Streaming block device to Restic"
}
]
GET /api/jobs/:jobId
Purpose: Retrieve detailed job status and logs.
POST /api/backup/:vmName
Purpose: Trigger a ZFS block-level backup to S3 via Restic.
Request Body: (none)
Response: Start a background job.
{
"jobId": "job_abc123",
"message": "Backup job started."
}
Sequential Shell Steps — execute in order, abort on failure:
# Step 1: Create a temporary Incus snapshot
incus snapshot create <vmName> s3-backup-<timestamp>
# Step 2: Make the ZFS snapshot device visible
zfs set snapdev=visible <ZFS_POOL_NAME>/virtual-machines/<vmName>.block
# Step 3: Wait for device to appear
sleep 2
# Step 4: Stream the block device into Restic (pipe)
restic backup --stdin \
--stdin-filename "<vmName>.raw" \
--tag "<vmName>" \
< /dev/zvol/<ZFS_POOL_NAME>/virtual-machines/<vmName>.block@snapshot-s3-backup-<timestamp>
# Step 5: Hide the snapshot device again
zfs set snapdev=hidden <ZFS_POOL_NAME>/virtual-machines/<vmName>.block
# Step 6: Delete the temporary Incus snapshot
incus snapshot delete <vmName> s3-backup-<timestamp>
# Step 7: Apply retention policy
restic forget --tag "<vmName>" --keep-daily 7 --prune
Error Handling / Cleanup: If any step (1–4) fails, the backend must still execute the following cleanup commands before marking the job as failed:
zfs set snapdev=hidden <ZFS_POOL_NAME>/virtual-machines/<vmName>.blockincus snapshot delete <vmName> s3-backup-<timestamp>(ignore errors if snapshot doesn't exist)
The job record must move to success or failed and preserve logs for the frontend.
POST /api/restore/:vmName
Purpose: Restore a VM's disk from a Restic snapshot.
Request Body:
{
"snapshotId": "a1b2c3d4",
"confirmVmName": "my-vm"
}
Pass
"latest"assnapshotIdto restore the most recent snapshot.
Response: Start a background job.
{
"jobId": "job_def456",
"message": "Restore job started."
}
Sequential Shell Steps — execute in order:
# Step 1: Stop the VM (ignore error if already stopped)
incus stop <vmName> --force || true
# Step 2: Set ZFS volume to raw device mode
zfs set volmode=dev <ZFS_POOL_NAME>/virtual-machines/<vmName>.block
# Step 3: Trigger udev and wait for device node
udevadm trigger && udevadm settle && sleep 2
# Step 4: Stream Restic snapshot data into the block device
restic dump <snapshotId> <vmName>.raw \
| dd of=/dev/zvol/<ZFS_POOL_NAME>/virtual-machines/<vmName>.block \
bs=4M conv=sparse status=none
# Step 5: Restore ZFS volume mode
zfs set volmode=none <ZFS_POOL_NAME>/virtual-machines/<vmName>.block
# Step 6: Start the VM
incus start <vmName>
Restore Error Handling / Cleanup:
- If restore fails after
volmode=dev, always attemptzfs set volmode=none <ZFS_POOL_NAME>/virtual-machines/<vmName>.block. - Do not automatically start the VM if the disk write failed.
- Preserve detailed job logs and expose the failure in the UI.
Frontend Implementation
Configuration
- Tailwind dark theme base:
bg-zinc-950,text-zinc-100. - Create an Axios instance pointing to
http://localhost:3000/api. - Display backend errors as both toast notifications and persistent job errors where relevant.
- Use Lucide icons for actions and status indicators.
Visual Direction
The interface should follow the Zerobyte-inspired operator dashboard direction:
- Dark, quiet, dense UI.
- Subtle panel borders:
border-zinc-800. - Panels:
bg-zinc-900orbg-zinc-900/70. - Muted secondary text:
text-zinc-400. - Status colors:
- Running/success: emerald or green.
- Failed/destructive: red.
- Warning/attention: amber.
- Active/running job: cyan or blue.
- Avoid marketing hero sections, oversized decorative cards, gradient blobs, and visual noise.
- First screen must be the actual dashboard.
- Cards are for repeated status panels or VM rows only; do not nest cards inside cards.
View A: Dashboard
Top Status Strip:
- Total VMs.
- Running VMs.
- Active jobs.
- Last successful backup.
- Repository health.
VM Backup Table:
- Dense table layout, optimized for scanning.
- Per VM row:
- VM Name
- Status indicator: green dot =
Running, red dot =Stopped - Latest snapshot time
- Snapshot count
- Last job status
- Active job progress, if any
- Button: Manage Backups → navigates to View B for that VM.
- Data sources:
GET /api/healthGET /api/vmsGET /api/jobs
View B: VM Detail & Snapshot List
Header:
- VM name as page title.
- Incus status badge.
- Latest snapshot timestamp.
- Primary button: Create Backup
- On click: calls
POST /api/backup/:vmName. - Receives
jobId. - Shows job status and logs via
GET /api/jobs/:jobId.
- On click: calls
Job Status Panel:
- Shows active or latest job.
- Fields:
- Status
- Current step
- Started/finished time
- Log output
- Error details
- This panel is persistent; do not rely on toast messages for long-running operations.
Snapshot Table:
- Data source:
GET /api/snapshots/:vmName - Columns:
| Column | Value |
|---|---|
| ID | First 8 characters of snapshot hash |
| Date & Time | Formatted time field |
| Tags | Comma-separated tag list |
| Actions | "Restore" button |
Restore Button Behavior:
-
User clicks "Restore".
-
A red warning modal appears with the text:
⚠️ Warning: The VM will be stopped and the current disk will be irreversibly overwritten with the state from snapshot
[snapshot-id]. Do you want to continue? -
Modal has two buttons: "Cancel" and "Confirm Restore" (red, destructive style).
-
Modal requires explicit VM name confirmation.
-
Only on "Confirm Restore": call
POST /api/restore/:vmNamewith{ "snapshotId": "<id>", "confirmVmName": "<vmName>" }. -
Show restore job status in the persistent job panel.
Error Handling Summary
| Layer | Requirement |
|---|---|
| Backend command execution | Use spawn with argument arrays; no shell interpolation for user input |
| Backend validation | Validate VM names against Incus and snapshot IDs against Restic |
| Backend jobs | Backup/restore run as jobs with status, logs, timestamps, and errors |
| Backend locking | Only one active backup/restore job per VM |
| Backend backup cleanup | On failure in backup steps 1–4, always hide snapdev and delete the temp snapshot |
| Backend restore cleanup | If restore fails after volmode=dev, attempt to restore volmode=none |
| Backend errors | Return structured { "error": "..." }; job failures must also be visible via /api/jobs/:jobId |
| Frontend errors | Show immediate toast plus persistent job error/details |
| Frontend restore action | Always show destructive confirmation modal and require VM-name confirmation |
MVP Implementation Order
- Scaffold backend and frontend folders.
- Implement backend config validation and safe command executor.
- Implement
GET /api/health. - Implement
GET /api/vms. - Implement
GET /api/snapshots/:vmName. - Implement in-memory job store and per-VM lock.
- Implement backup job flow with cleanup.
- Build dashboard UI in the Zerobyte-inspired operator style.
- Build VM detail UI with snapshot table and job status panel.
- Implement restore job flow with defensive validation and cleanup.
- Wire restore modal with explicit destructive confirmation.
- Add deployment notes for running backend as root/sudo on the Incus host.
Setup Instructions
Backend
cd backend
npm init -y
npm install express cors dotenv
# Copy .env and fill in values
node src/index.js
Frontend
cd frontend
npm create vite@latest . -- --template react
npm install
npm install -D tailwindcss postcss autoprefixer
npx tailwindcss init -p
npm install axios lucide-react
npm run dev
In tailwind.config.js, set darkMode: 'class' and use bg-zinc-950 as the default body background.