Introduction

DPlaneOS is an integration layer and NixOS appliance for homelab and small-office NAS deployments. It is not building a storage OS from scratch. The hard problems were already solved by other projects: NixOS handles the OS, bootloader, service supervision, and reproducible builds. OpenZFS handles pools, datasets, snapshots, send/receive, scrub, encryption, and integrity. PostgreSQL, nginx, Patroni, etcd, Samba, Docker, and a dozen other upstream projects handle the rest.

What DPlaneOS contributes is the seams: roughly 49k lines of Go that tie all of the above together coherently. That means ~400 HTTP routes mapping React UI actions to validated, allowlisted exec calls; state persisted in PostgreSQL with goose migrations; a GitOps reconciler that compares declared state.yaml against the live system and handles drift; an HMAC-chained audit log; ZFS event delivery over WebSocket; the A/B OTA flow with health-checked auto-revert; and the HA state machine including SCSI-3 PR fencing and Patroni integration. Plus ~30k lines of React 19 / TypeScript frontend: 48 pages, ~67 modal/dialog components, one embedded PTY terminal.

It ships as a NixOS appliance ISO. Boot it, answer a handful of prompts, and you have a running system. The daemon (dplaned) listens on 127.0.0.1:9000 behind nginx. It accepts no shell input and validates every argument individually through a hardcoded exec allowlist. Nothing about its design is exotic computer science; it is integration work that nobody had bothered to do under one roof.

info
Built by one person. DPlaneOS is maintained by Dan (4nonX) and runs in production on ~33 TB across 60+ services. It is not vendor-backed. If you need a phone number to call at 3am, this is not the right tool yet.

Project Status

AreaStatusNotes
ZFS pool / dataset / snapshot managementStableThe core. Used daily in production.
SMB, NFS, iSCSI sharingStableSamba + kernel NFS + LIO.
Container management (Docker, Compose)StableIncluding ZFS-clone sandboxes.
Hot-swap detection and auto-importStableudev + ZED, exercised regularly.
LDAP / Active Directory integrationBetaWorks; less battle-tested than local auth.
A/B OTA updates with auto-revertBetaMechanism is sound; sample size is small.
GitOps reconciliationBetaStructural sync works; edge cases at the property-coverage frontier still surfacing.
PostgreSQL HA (Patroni + etcd)ExperimentalTested in lab, never under real load.
Replicated-topology HA with witnessExperimentalSame. Read the Showstopper Mitigation Guide first.
Shared-SAS HA with SCSI-3 PR fencingExperimentalTested on one hardware configuration. Yours will differ.
NVMe-oF over TCPExperimentalFunctional, not yet stress-tested.

System Requirements

Minimum

ComponentMinimumRecommended
CPUDual-core x86_64 or aarch64Quad-core Intel/AMD or better
RAM4 GB16 GB+ (more for large ZFS ARC)
Boot disk20 GB SSD (separate from data disks)60 GB+ NVMe
Network100 Mbps Ethernet1 Gbps+ Ethernet
Archx86_64 or aarch64Raspberry Pi 5 supported (aarch64)
warning
ECC RAM is strongly recommended. ZFS protects data against on-disk corruption but cannot detect RAM corruption that occurs before a write. ECC closes this gap entirely. DPlaneOS detects ECC presence via dmidecode and shows a dashboard advisory if non-ECC RAM is found. See the Non-ECC Warning for a full risk analysis.

Pre-Installation Checklist

  • At least two physical drives: one for the OS (boot disk), one or more for ZFS data pools
  • A USB stick (2 GB minimum) to write the installer ISO
  • Static IP configured on your router/switch (recommended for server use)
  • SSH public key ready (password SSH login is disabled post-install)

Installation

1. Download the ISO

From the latest release, download the ISO for your hardware:

HardwareISO filename
x86_64 (Intel/AMD)dplaneos-v<version>-installer-amd64.iso
aarch64 (Raspberry Pi 5, ARM servers)dplaneos-v<version>-installer-arm64.iso

2. Verify and Write to USB

bash
# Verify the checksum
sha256sum -c dplaneos-v11.6.5-installer-amd64.iso.sha256

# Write to USB (Linux / macOS) - replace /dev/sdX
sudo dd if=dplaneos-v11.6.5-installer-amd64.iso \
    of=/dev/sdX bs=4M status=progress conv=fsync

# Windows: use Rufus (https://rufus.ie) in DD image mode
dangerous
Double-check /dev/sdX before running dd. Use lsblk to identify your USB device. Writing to the wrong device will destroy data.

3. Boot and Install

  1. Boot the target machine from the USB stick. The installer launches automatically on tty1.
  2. Select Install DPlaneOS from the boot menu. (The "Install Witness Node" option is only for HA replicated-topology clusters.)
  3. Follow the prompts: target disk, admin password, boot mode (UEFI).
  4. The installer partitions the disk, installs the NixOS closure from the ISO (no internet required), and reboots.
  5. Remove the USB stick when prompted.

Headless install: SSH to the installer as root with password dplaneos (installer environment only; this password does not persist). Run bash /etc/dplaneos-install/install.sh if the wizard does not launch automatically.

Installation time: 5-10 minutes from boot to running system.

4. Verify Services After Install

bash
# Main daemon
sudo systemctl status dplaned

# nginx
sudo systemctl status nginx

# Database (should return 4 rows: admin, operator, viewer, user)
sudo -u postgres psql dplaneos -c "SELECT count(*) FROM roles;"

# Health check
curl http://127.0.0.1:9000/api/system/health

First Boot & Setup

Open http://<server-ip>/ in a browser. The setup wizard runs automatically on first access.

  1. Create the admin account — set username and password. No pre-generated password is printed; you choose it here.
  2. Configure storage — select disks and create a ZFS pool (optional; can be done later from the Storage page).
  3. Set hostname and timezone.

If the wizard is interrupted (browser closed mid-flow), reopen the URL. It detects the partial state and resumes from the disk selection step.

info
Session persistence: By default, sessions are tab-scoped and lost on browser close. Check Remember me at login to persist the session in localStorage across restarts, within the 24-hour server-side TTL.

HTTPS Setup

Go to Settings → TLS. The daemon provisions certificates via ACME (Let's Encrypt by default). For internal-only deployments, generate a self-signed certificate from the same settings page.

Pool Management

RAID Configurations

DisksConfigurationUsable SpaceDrives You Can Lose
2Mirror50%1
3RAIDZ167%1
4RAIDZ250%2
6RAIDZ267%2
6RAIDZ350%3

Create via UI: Storage → Pools → Create Pool → select disks → choose topology → enter pool name → Create.

warning
Always use /dev/disk/by-id/ paths. Short /dev/sdX paths change on reboot. The UI uses stable by-id paths automatically. If setting paths manually in state.yaml, use by-id paths exclusively.

Pool Maintenance Commands

bash
# Pool health
zpool status
zpool status -v tank    # verbose, shows errors

# Start a scrub (verify on-disk integrity)
zpool scrub tank

# Check scrub progress
zpool status tank | grep scan

# Import pools (after OS reinstall or move)
zpool import -a         # import all available
zpool import tank       # import by name

# Clear transient errors (e.g. after a cable fix)
zpool clear tank

# Force online a vdev that was taken offline
zpool online tank /dev/disk/by-id/ata-...

Recommended Scrub Schedule

Monthly scrubs via cron:

bash
sudo crontab -e
# Add:
0 2 1 * * /usr/sbin/zpool scrub tank

Datasets

Create datasets via Storage → Datasets → Create Dataset.

Recommended Properties

PropertyRecommended ValueNotes
compressionlz4Saves ~30% on typical data with negligible CPU cost
atimeoffEliminates read-triggered writes; improves IOPS
recordsize128K (default)Use 1M for sequential media; 4K-16K for databases
quotaSet per datasetPrevents one dataset from consuming the whole pool
acltypeposixaclRequired for POSIX ACL management (set by default on new pools)

Dataset Hierarchy Best Practice

text
tank/
  data/
    users/
      alice/        # quota=500G per user
      bob/
    media/          # atime=off, recordsize=1M
    backups/        # compression=zstd
  docker/           # /var/lib/docker bind mount

Encryption

DPlaneOS supports native ZFS encryption. Encrypted datasets are created via Storage → Datasets → Create Dataset → Enable Encryption. Choose a passphrase or raw key.

dangerous
Back up your encryption key or passphrase before creating encrypted datasets. If you lose the key, the data is unrecoverable. Store the key in a password manager and an offline backup, not on the same system.

Key Management Commands

bash
# Load a key (unlock after reboot)
sudo zfs load-key tank/encrypted

# Check key status
zfs get keystatus tank/encrypted

# Change the passphrase
sudo zfs change-key tank/encrypted

# Unload key (lock without unmounting)
sudo zfs unload-key tank/encrypted

The DPlaneOS UI exposes lock, unlock, and key-change operations from the dataset detail page. Locked datasets show a padlock icon in the storage tree.

Snapshots

Snapshots are instantaneous, space-efficient (copy-on-write), and can be replicated to a remote system. They are the primary backup mechanism for user data.

Manual Snapshots

bash
# Take a snapshot
zfs snapshot tank/data@backup-$(date +%Y%m%d)

# List snapshots
zfs list -t snapshot tank/data

# Restore a single file (snapshots appear in .zfs/snapshot/)
ls /mnt/data/.zfs/snapshot/backup-20260509/myfile.txt
cp /mnt/data/.zfs/snapshot/backup-20260509/myfile.txt /mnt/data/

# Roll back to a snapshot (destroys all newer data on the dataset)
zfs rollback tank/data@backup-20260509

Automatic Snapshots via NixOS

nix
# In configuration.nix
services.zfs.autoSnapshot = {
  enable   = true;
  frequent = 4;    # every 15 min, keep 4
  hourly   = 24;   # keep 24 hourly
  daily    = 7;    # keep 7 daily
  weekly   = 4;    # keep 4 weekly
  monthly  = 12;   # keep 12 monthly
};

Scheduled Snapshots via the UI

Storage → Datasets → select a dataset → Snapshots tab. Set a cron schedule, retention count, and whether to include child datasets. Schedules are stored in /etc/dplaneos/snapshot-schedules.json and survive reboots.

Opting a Dataset Out of Auto-Snapshots

bash
zfs set com.sun:auto-snapshot=false tank/scratch

SMART Monitoring

DPlaneOS runs scheduled SMART tests and surfaces results on the dashboard. SMART tasks can be declared in state.yaml under smart_tasks or configured from Storage → SMART.

Supported test types: short (5-15 min), long (hours, more thorough), conveyance (after transport). Recommended schedule: short weekly, long monthly.

bash
# Check a drive's SMART status directly
sudo smartctl -a /dev/disk/by-id/ata-...

# Run a short test immediately
sudo smartctl -t short /dev/disk/by-id/ata-...

File Management

The file explorer is accessible from the Files navigation item. It browses datasets and directories in real time and supports chunked multi-GB uploads directly to the server.

Operations

  • Upload: chunked multi-GB uploads, resumable on connection drop
  • Download: single files or directory archives
  • Rename / Copy / Move / Delete via right-click context menu
  • POSIX ACL management: right-click → Manage Permissions (ACL) → add entries for specific users or groups, set Read/Write/Execute, apply recursively
info
POSIX ACLs require acltype=posixacl on the dataset. New pools created through the UI have this set by default. Set it manually with zfs set acltype=posixacl tank/dataset.

Safe Base Paths

File operations (create, delete, rename, chown, chmod) are restricted to safe base paths: /mnt/*, /home/*, /tank/*, /data/*, /media/*, /opt/*, /srv/*, /tmp/*, /var/lib/dplaneos/. Requests outside these paths are rejected.

SMB / Samba

Create SMB shares via Shares → Create Share. The daemon regenerates smb.conf and reloads Samba on every change.

Time Machine Support

Enable the Time Machine toggle when creating a share. DPlaneOS configures the vfs_fruit and vfs_streams_xattr modules automatically, making the share appear as a valid Time Machine destination in macOS.

Troubleshooting SMB

bash
# Test the smb.conf
sudo testparm

# Check Samba status
sudo systemctl status smbd

# View active connections
sudo smbstatus

# Reload Samba config without restarting
sudo smbcontrol smbd reload-config

NFS

Create NFS exports via Shares → NFS → Add Export. The daemon writes /etc/exports and calls exportfs -ra on every change.

bash
# Check active exports
sudo exportfs -v

# Manually reload exports
sudo exportfs -ra

# Show NFS status
sudo systemctl status nfs-server
info
For NFSv4, set Domain = your.domain in /etc/idmapd.conf to ensure consistent UID/GID mapping between client and server.

iSCSI

iSCSI targets are configured via Shares → iSCSI. DPlaneOS uses the Linux kernel's LIO/nvmet framework via configfs. No additional packages are required.

Typical use cases: VMware datastores, Windows Server disk attachments, bare-metal boot over SAN.

warning
iSCSI provides block-level access. The initiator controls the filesystem. Connecting an iSCSI target to multiple initiators simultaneously without proper clustering (e.g. SCSI-3 PR or a cluster filesystem) will corrupt data.

NVMe-oF / TCP

NVMe-oF over TCP is configured via Settings → Optional Protocols → NVMe-oF or declared in state.yaml under fabrics. The daemon uses kernel nvmet via configfs.

dangerous
Experimental. NVMe-oF is functional but not yet stress-tested. Test thoroughly before using in production. The same multi-initiator warning from iSCSI applies here.

Docker & Compose

Requires the docker:write permission. Manage containers from the Containers navigation item.

Deploying Containers

Containers → Deploy Container → fill in image, name, ports, volumes, and environment variables → Deploy.

Compose Stacks

Containers → Stacks → Create Stack → paste or upload a docker-compose.yaml. The daemon calls docker compose up -d through the exec allowlist.

ZFS-Clone Sandboxes

For atomic container updates with rollback, DPlaneOS can create a ZFS clone of a dataset, run the new container version against it, and swap on success or rollback on failure. Configure this in the container's advanced options.

Container Management

ActionUI Path
Start / Stop / RestartContainers → select container → action buttons
View logsContainers → select container → Logs tab
Exec into containerContainers → select container → Exec tab → enter bash
RemoveContainers → select container → Delete → confirm
Pull imageContainers → Images → Pull

Custom Container Icons

Icons are resolved in this priority order:

  1. dplaneos.icon label on the container (Material Symbol name, filename, or URL)
  2. Built-in image-name mapping (covers 80+ well-known images)
  3. Fallback generic icon
bash
# Copy your icon to the custom icons directory
cp myapp.svg /var/lib/dplaneos/custom_icons/

# Reference it in docker-compose.yaml
yaml
services:
  myapp:
    image: myapp:latest
    labels:
      dplaneos.icon: myapp.svg   # filename in custom_icons/
      # Or a Material Symbol name:
      # dplaneos.icon: database

Supported formats: .svg, .png, .webp. No daemon restart required after adding icons.

Local Users

Creating a User

  1. Navigate to Settings → Users → Create User.
  2. Fill in username (lowercase, no spaces), email, and password (12+ characters recommended).
  3. Click Create, then assign a role.

Password Policy

Minimum 8 characters with uppercase, lowercase, digit, and special character. 12+ strongly recommended. The forced-change flag (must_change_password) restricts all API routes except change-password, logout, and session until the flag is cleared.

Password Reset (Admin)

Settings → Users → click the lock icon next to the user → set a temporary password. The user's existing sessions are immediately revoked.

info
There is no self-service password reset. This is intentional for a system with no email integration. An admin must reset the password via the UI or recovery mode.

LDAP / Active Directory

info
DPlaneOS uses LDAP for web UI login, not just SMB. Directory-sourced users authenticate via real-time LDAP bind when logging into the dashboard. Local accounts (including admin user ID 1) always use bcrypt and are never locked out by LDAP being down.

Quick Setup

  1. Navigate to Identity → Directory Service.
  2. Select a preset: Active Directory, OpenLDAP, FreeIPA, or Custom.
  3. Enter server address, Bind DN, Bind Password, and Base DN.
  4. Click Test Connection.
  5. Click Save Configuration and enable the toggle.
  6. Run a sync: Identity → Sync Now (or POST /api/ldap/sync).

Group to Role Mapping

LDAP GroupDPlaneOS RoleAccess Level
IT_AdminsAdminFull system access
Storage_TeamOperatorStorage, Docker, Shares
Domain UsersUserFiles, Dashboard
AuditorsViewerRead-only

Troubleshooting LDAP

IssueSolution
Connection failedVerify server address, port (389/636), and firewall rules
Bind failedVerify Bind DN and password; check service account is not locked
User not foundCheck User Filter: AD uses sAMAccountName, OpenLDAP uses uid
No groups mappedVerify Group Base DN and Group Filter
Admin locked outUser ID 1 always uses local auth. Log in with local credentials.

TOTP Two-Factor Authentication

TOTP 2FA can be enabled per user. When enabled, login issues a pending_totp session that can only call /api/auth/totp/verify. All other routes reject the pending session until the second factor is verified.

Enabling TOTP

  1. Settings → Security → Two-Factor Authentication → Enable.
  2. Scan the QR code with an authenticator app (Google Authenticator, Authy, 1Password, etc.).
  3. Enter the first code to confirm setup.
  4. Save the backup codes in a safe location. They are shown once and stored hashed.
dangerous
Store your backup codes before completing setup. If you lose access to your authenticator app and have no backup codes, an admin must reset your TOTP via the Users page (Settings → Users → user → Reset 2FA).

RBAC & Permissions

Built-in Roles

RoleCan DoCannot Do
AdminAll 31 permissions; manage users, roles, system settingsN/A (cannot be deleted)
OperatorStorage, Docker, file management, sharingCreate users, assign roles, system settings
ViewerRead all data, download files, view logsModify anything
UserUpload/download files, view own usage, read system statusEverything else

Permission Reference

ResourceActionsCovers
storageread, write, delete, adminPools, datasets, quotas, encryption, replication
snapshotsread, writeSnapshot schedules and management
sharesread, write, adminSMB/NFS share configuration and reload
filesread, writeFile Explorer: browse/download and upload/modify/delete
dockerread, write, delete, adminContainers, images, compose, prune
networkread, writeNetwork interfaces and routing
firewallread, writeFirewall rules
usersread, write, adminUser accounts, groups, and sessions
rolesread, writeRole and permission management
systemread, write, adminSystem settings, reboot, poweroff, audit log rotation
monitoringreadMetrics and health dashboard
auditreadAudit log chain view and chain verification
certificatesread, writeTLS certificate management

GitOps & state.yaml

The GitOps engine lets you declare the desired state of your NAS in a state.yaml file committed to a Git repository. The reconciler compares this declared state to the live system and applies the diff.

warning
Safety guarantees: Pool destroy is always BLOCKED. Dataset destroy is BLOCKED when zfs get used is non-zero. A git push cannot delete data. Only metadata and configuration are managed.

Top-Level Structure

yaml
version: "1"
ignore_extraneous: false  # if true, ignore resources not in desired state

pools:        []  # ZFS pools
datasets:     []  # ZFS datasets and properties
shares:       []  # SMB shares
nfs:          []  # NFS exports
stacks:       []  # Docker Compose stacks
system:           # hostname, DNS, NTP, firewall, networking, SSH
users:        []  # local user accounts
groups:       []  # local groups
replication:  []  # ZFS replication schedules
ldap:             # LDAP/AD directory integration
acme:             # ACME certificate automation
certificates: []  # manually provisioned TLS certs
smart_tasks:  []  # SMART test schedules
fabrics:          # NVMe-oF targets (optional)

Pool Declaration

yaml
pools:
  - name: tank
    topology:
      data:
        - type: mirror
          disks:
            - /dev/disk/by-id/ata-WDC_WD40EFRX_00...
            - /dev/disk/by-id/ata-WDC_WD40EFRX_01...
      cache:            # optional L2ARC
        - /dev/disk/by-id/nvme-...
    properties:
      ashift: 12       # 4K sector alignment
      autotrim: on     # for SSDs

Dataset Declaration

yaml
datasets:
  - name: tank/media
    properties:
      compression: lz4
      atime: off
      recordsize: 1M
      quota: 4T
  - name: tank/media/movies
    properties:
      compression: off   # already compressed video

Reconciliation

On every POST /api/gitops/apply, the engine:

  1. Reads state.yaml from the configured Git repository.
  2. Reads the current live system state (ZFS, Docker, Samba, NFS, DB).
  3. Computes a diff plan: items to CREATE, MODIFY, DELETE, or block.
  4. Executes the plan transactionally (halts on first failure; already-applied items are safe by design).
  5. Verifies convergence by re-reading live state after apply.

A background drift detector runs every 5 minutes and broadcasts drift events to connected UI clients. Operators can trigger an immediate check or apply via the UI or API.

bash
# Trigger apply via API
curl -X POST http://localhost:9000/api/gitops/apply \
  -H "X-Session-ID: your-session-token"

# Check drift status
curl http://localhost:9000/api/gitops/status \
  -H "X-Session-ID: your-session-token"

Capture Workflow

The Capture workflow generates a state.yaml that represents the current live state of the system. Use it to bootstrap GitOps from an existing DPlaneOS installation.

GitOps → Capture → review the generated YAML → commit to your Git repository → set the repo as the GitOps source → apply.

After capture, the reconciler will show zero drift between the captured state.yaml and the live system (assuming no changes were made between capture and apply).

HA Topologies

dangerous
Experimental. HA has been tested in lab environments but not under sustained production load. Read the Showstopper Mitigation Guide before deploying.

Topology A: Shared-SAS (Recommended)

Both nodes connect to the same physical disk shelf. SCSI-3 Persistent Reservations enforce storage exclusion at the disk-controller level. No separate witness machine is required; the third etcd member for Patroni quorum runs co-located on node A as a lightweight process.

Best for: two-box deployments where you can connect both servers to a shared JBOD or disk shelf.

Topology B: Replicated ZFS (Stretched)

Nodes do not share physical storage. ZFS state is replicated over the network. A separate witness node provides the quorum tiebreaker. IPMI or SBD fencing ensures the non-surviving node is powered off before promotion.

Best for: geographically separated deployments. Requires a third machine (Raspberry Pi or VM) as the witness node.

FeatureShared-SASReplicated
Separate witness requiredNoYes
Storage fencing mechanismSCSI-3 PR (hardware)IPMI / SBD (software)
Geographic separationNoYes
Recommended for homelabsYesOnly if needed

Patroni & etcd

PostgreSQL is replicated from primary to standby in streaming replication mode. Patroni manages leader election via etcd. Failover sequence:

  1. etcd detects the primary member is down.
  2. Patroni on the standby wins the election.
  3. Standby is promoted to primary.
  4. HAProxy (:5000) begins routing to the new primary.
  5. The daemon reconnects within seconds.

HAProxy runs on each node and listens on 127.0.0.1:5000. It health-checks Patroni (GET /primary returns 200 on primary, 503 on standby) and routes only to the current primary. The daemon always connects to localhost:5000.

RTO (Recovery Time Objective): 10-30 seconds for automatic PostgreSQL failover.

SCSI-3 PR Fencing

The dplane-fenced binary manages SCSI-3 Write Exclusive Registrants Only (WERO) persistent reservations. Reservations survive system reboots (APTPL=1 stores them in disk controller NVRAM).

  • At startup: each node registers an 8-byte key derived from /etc/machine-id.
  • Primary: holds the WERO reservation on all pool member disks.
  • Graceful failover: primary calls FencedRelease() before exporting pools.
  • Unclean failover: surviving node preempts the dead node's reservation via FencedPreempt(device).

Result: the non-reservation-holder's I/O is rejected at the SAS/SCSI layer. Split-brain cannot cause data corruption in the shared-SAS topology.

dplane-fenced runs in its own systemd slice (dplaneos-fenced.slice), isolated from dplaneos.slice, so it survives dplaned restarts.

Rolling OTA Upgrades

  1. Update the standby node: sudo dplaneos-ota-update on node B. Node B reboots into the new slot. Health check passes. Node B is now on the new version.
  2. Fail over: trigger a Patroni failover so node B becomes primary. The VIP moves to node B.
  3. Update the old primary: sudo dplaneos-ota-update on node A. Node A reboots. Health check passes. Node A comes up as standby on the new version.
  4. Optionally fail back to node A.

Clients connected to the VIP see no downtime during steps 1 and 3 (the node being updated is not serving traffic). Step 2 causes a brief failover pause (~10-30s) which is the only client-visible interruption.

Backup: ZFS Snapshots

What Needs Backing Up

DataLocationStrategy
User filesZFS pools on data disksZFS snapshots + ZFS send/receive
NAS config (runtime)state.yaml in Gitgit push to remote
NAS config (OS-level)configuration.nix / flake.nixgit push to remote
Database/var/lib/dplaneos/pgsql/pg_dump or ZFS snapshot of /persist
Docker state/var/lib/docker/ZFS snapshot if on ZFS dataset
check_circle
You do NOT need to back up the NixOS system closure. If the boot disk fails, reinstall from the ISO and import your ZFS pools. The ISO contains the correct closure for the pinned nixpkgs revision.

ZFS Send/Receive (Replication)

bash
# Initial full send to a remote host
zfs send -R tank/data@snap1 | \
  ssh backup@remote "zfs receive -F backup/data"

# Incremental send (after the initial full send)
zfs send -i tank/data@snap1 tank/data@snap2 | \
  ssh backup@remote "zfs receive backup/data"

# Local clone
zfs send tank/data@snap1 | zfs receive tank2/data

Configure replication schedules via Storage → Replication → Add Schedule or declare them in state.yaml under replication. The daemon handles SSH key exchange and incremental sends automatically.

Cloud Sync (rclone)

Cloud sync is powered by rclone. Configure destinations from Settings → Cloud Sync → Add Destination. Supported providers include: AWS S3, Backblaze B2, Google Cloud Storage, Azure Blob, Wasabi, Cloudflare R2, and many more.

Schedules, bandwidth limits, and sync vs. copy modes are all configurable from the UI. Progress is visible in real time on the sync status page.

Database Backup

bash
# Manual backup
sudo -u postgres pg_dump dplaneos > \
  /backup/dplaneos-$(date +%Y%m%d).sql

# Restore
sudo systemctl stop dplaned
sudo -u postgres psql dplaneos < /backup/dplaneos-20260524.sql
sudo systemctl start dplaned

# Automate with a cron job
0 3 * * * sudo -u postgres pg_dump dplaneos | \
  gzip > /mnt/tank/backups/db-$(date +%Y%m%d).sql.gz

The database state lives at /var/lib/dplaneos/pgsql/ on the /persist partition. A ZFS snapshot of the dataset containing /persist is also a valid backup approach.

A/B OTA Update System

The boot disk has two OS slots (A and B). At any time one slot is active (booted) and the other is standby. OTA updates write to the standby slot; the active slot is never touched during an update.

text
Boot disk layout:
  /boot (ESP)      systemd-boot, slot A/B entries
  Slot A (ext4)    NixOS closure (active or standby)
  Slot B (ext4)    NixOS closure (active or standby)
  swap
  /persist (ext4)  ALL durable state - NEVER touched by OTA

Health Check (post-boot)

90 seconds after booting into the new slot, the health check fires and verifies:

  1. GET /api/system/health returns 200 within 10 seconds
  2. zpool list exits 0 and shows no degraded pools
  3. /persist is mounted
  4. If active SMB shares exist in the DB: smbd must be running

All checks pass → update committed. Any failure → bootloader reverted to previous slot, system reboots automatically.

Triggering Updates

Via the UI (Recommended)

Settings → System → Updates → Check for Updates. The UI shows current version, available version, changelog, and estimated install time. Click Install and Reboot to start the process.

Via CLI

bash
# Pull the latest DPlaneOS flake and rebuild into the inactive slot
sudo nixos-rebuild switch --flake github:4nonX/DPlaneOS#dplaneos

# Or use the OTA update script directly
sudo dplaneos-ota-update

Rollback & Revert

Automatic revert: happens without operator intervention if the health check fails. No action needed.

Manual rollback (after a health-check-passing update that still has issues):

bash
sudo dplaneos-ota-update --revert

This sets the bootloader back to the previous slot and reboots. Data on /persist and ZFS data pools are never touched by a rollback.

info
Adjust the health check delay if your services are slow to start: set services.dplaneos.ota.healthCheckDelay = "120s" in configuration.nix.

Security Architecture

Trust Boundary

The daemon listens on 127.0.0.1:9000 only. It is not reachable from the network without a reverse proxy (nginx, Caddy, or Pangolin). TLS is terminated at the proxy. Everything behind the proxy is trusted.

Exec Allowlist

All exec.Command calls pass through internal/security/whitelist.go, which validates:

  1. The binary name (only known tools permitted)
  2. The subcommand (e.g., zpool add validated with a dedicated argument grammar)
  3. Each argument individually against predefined regex patterns

Arguments are passed to exec.Command as a string slice, never as a shell string. There is no bash -c, no shell expansion.

Hardening Checklist

ItemRecommendation
HTTPSEnable via Settings → TLS. Use Let's Encrypt for public-facing, self-signed for internal.
FirewallOpen only ports 80, 443, 22. Close everything else. Configured in NixOS module or via UI.
TOTP 2FAEnable on all admin accounts. Especially important for internet-facing deployments.
Audit log reviewReview monthly. Look for failed logins, unexpected role changes, out-of-hours access.
Least privilegeUse Operator or Viewer roles for daily use. Reserve Admin for administrative tasks only.
DB password for LDAP bindUse a read-only service account. The bind password is stored in PostgreSQL.
SSH keysPassword SSH login is disabled post-install. Keep your SSH private key secure.

HMAC Audit Chain Verification

bash
# Verify audit chain integrity via API
curl http://127.0.0.1:9000/api/system/audit/verify-chain \
  -H "X-Session-ID: your-token"

# View audit log via UI
# Settings -> Roles and Permissions -> Audit Log

Monitoring & Alerts

Dashboard Metrics

CPU, RAM, disk I/O, network traffic, pool health, and container status are displayed in real time via WebSocket. ZED events (scrub, resilver, faults, TRIM) surface immediately in the dashboard as they happen in the kernel.

Alert Channels

ChannelConfigurationNotes
SMTP (email)Settings → Notifications → SMTPRequires an SMTP relay or mail server
WebhookSettings → Notifications → WebhookPOST JSON to any endpoint (Slack, Discord, etc.)
TelegramSettings → Notifications → TelegramBot token + chat ID. Critical ZFS events sent immediately.

ZED Event Types

The ZFS Event Daemon hooks into the daemon and triggers alerts for: pool degraded, disk faulted, scrub started/completed/aborted, resilver started/completed, TRIM events, checksum errors, I/O errors, data loss events. All severity warning+ events are forwarded to configured alert channels.

Troubleshooting

Daemon Will Not Start

bash
sudo journalctl -u dplaned -n 80
# Common causes:
sudo systemctl status dplaneos-zfs-gate   # ZFS gate timeout
sudo systemctl status postgresql           # DB not ready
curl http://127.0.0.1:9000/api/system/health

Cannot Reach Web Interface

bash
sudo systemctl status nginx
curl http://127.0.0.1:9000/api/system/health  # test daemon directly
sudo journalctl -u nginx -n 20

ZFS Pools Not Visible

bash
zpool list                  # check pool status
zpool import -a             # import any un-imported pools
zpool import tank -f        # force import (only if safe)
sudo systemctl restart dplaned

Permission Denied (403)

bash
# Check user's roles
sudo -u postgres psql dplaneos -c \
  "SELECT r.name FROM roles r \
   JOIN user_roles ur ON r.id = ur.role_id \
   WHERE ur.user_id = X;"

# Clear permission cache
sudo systemctl restart dplaned

High Memory / ZFS ARC

bash
arc_summary          # ZFS ARC breakdown

# Limit ARC to 16 GB in /etc/modprobe.d/zfs.conf:
# options zfs zfs_arc_max=17179869184

Build Issues (for developers)

bash
# Use the Nix dev shell (provides Go, gcc, libzfs headers)
nix develop

# Offline / air-gapped build (uses vendored deps)
cd daemon
CGO_ENABLED=1 go build -mod=vendor \
  -ldflags="-s -w -X main.Version=$(cat ../VERSION)" \
  -o ../build/dplaned ./cmd/dplaned/

LDAP Admin Lockout

User ID 1 (the initial admin) always authenticates via local bcrypt, even when LDAP is enabled. Log in with the local admin credentials set during the setup wizard.

OTA Stuck in Auto-Revert Loop

Check what the health check is failing on:

bash
cat /persist/ota/ota.log
journalctl -u dplaneos-ota-health -n 50

Common causes: ZFS pools not imported fast enough (increase healthCheckDelay), daemon not starting before the check fires, PostgreSQL readiness issues.

Command Reference

Service Control

bash
sudo systemctl status dplaned
sudo systemctl restart dplaned
sudo systemctl stop dplaned
sudo journalctl -u dplaned -f          # follow live logs
sudo journalctl -u dplaned -n 100      # last 100 lines

Health & Version

bash
curl http://127.0.0.1:9000/api/system/health
cat /opt/dplaneos/VERSION

ZFS Quick Reference

bash
zpool status                        # pool health overview
zpool status -v tank                # verbose with error counts
zpool list                          # pool sizes and usage
zfs list                            # all datasets
zfs list -t snapshot                # all snapshots
zfs get all tank/data               # all properties on a dataset
zpool scrub tank                    # start a scrub
zpool import -a                     # import all available pools
zpool export tank                   # safely export a pool
arc_summary                         # ARC cache statistics

Database Access

bash
sudo -u postgres psql dplaneos               # open psql shell
sudo -u postgres psql dplaneos -c "\dt"      # list tables
sudo -u postgres psql dplaneos -c \
  "SELECT name FROM roles;"                   # list roles
sudo -u postgres pg_dump dplaneos > db.sql   # backup

OTA Updates

bash
sudo dplaneos-ota-update            # apply latest update
sudo dplaneos-ota-update --revert   # revert to previous slot
cat /persist/ota/ota.log            # OTA operation log

Recovery Mode

bash
sudo dplaneos-recovery     # interactive recovery for lockout / DB issues

Key Paths

PathContents
/opt/dplaneos/daemon/dplanedDaemon binary
/opt/dplaneos/app/Web UI static files
/opt/dplaneos/VERSIONCurrent version string
/var/lib/dplaneos/pgsql/PostgreSQL data directory
/var/lib/dplaneos/audit.keyHMAC audit chain signing key (keep secure)
/var/lib/dplaneos/custom_icons/Custom container icons
/etc/dplaneos/patroni.yamlPatroni HA configuration
/etc/dplaneos/snapshot-schedules.jsonSnapshot schedule definitions
/etc/zfs/zed.d/dplaneos-notify.shZED event hook
/var/log/dplaneos/Daemon logs
/persist/All durable state (bind-mounted from dedicated partition)
/persist/ota/A/B slot markers, pending-revert marker, OTA log
/run/dplaneos/dplaneos.sockZED event Unix socket
/run/dplaneos/fenced.sockdplane-fenced control socket (HA only)

Changelog

v11.6.5 (2026-05-26) - Docker Compose Editor Fix

Fixed: Compose create flow had no .env editor and the deploy call omitted the env field entirely - users could not supply environment variables when creating a new stack. Compose editor rendered blank immediately after deploying a new stack due to a race between selectStack and the stacks query refetch - fixed by awaiting the refetch before selecting. NixOS: dplaneos-patroni-init ran as postgres but writes to /etc/dplaneos/ (root-owned), preventing HA cluster bring-up - service now runs as root and chowns the config file to postgres:postgres afterwards. NixOS: ZED notify hook used bare nc which is not in the system closure - replaced with the full store path of socat so pool health events actually reach the daemon. NixOS: removed dead PGPASSFILE env var from dplaned service (Patroni uses trust auth on localhost). Removed dead inline ISO builder from flake.nix and stale v5.3.2 configuration.nix. frontendPackage module option is now explicitly required with no default; the HA failover NixOS test supplies its own empty derivation explicitly.

v11.6.4 (2026-05-24) — OTA Health Check Hardening

Fixed: OTA health check commits update when ZFS command fails — zpool list exit code now captured explicitly; non-zero exit increments checks_failed. OTA health check skips smbd gate when database is unreachable — psql failure now increments checks_failed instead of silently skipping the check.

v11.6.3 (2026-05-24) — Security & Error Propagation

Security: RBAC hierarchy enforcement silently bypassed on DB read failure — patched. Last-admin deactivation guard silently bypassed on DB read failure — patched. First-boot setup gate silent failure — patched. Audit log silent on pre-delete query failure — failures now logged.

v11.6.2 (2026-05-24) — Error Handling & Reliability

Fixed: Silent database write failures across all handler files. Missing rows.Err() checks after every iteration loop. Unchecked rows.Scan in iteration loops (could corrupt smb.conf on failure). Session revocation failures on password change now logged. Frontend: NixOS reconciliation errors now surface in UI. Network diagnostics auth fixed for "remember me" sessions. NixOS: dplaneos-sbd-init silently ignores zfs create failures — added set -eu. PostgreSQL readiness gate was a no-op — now actually checks connectivity.

v11.6.1 (2026-05-23) — UI Polish

Fixed: Undefined CSS tokens, missing root tokens, broken modal close hover states, password toggle using emoji instead of icon. Changed: Primary color saturation reduced. Outer glows removed from interactive elements. TopBar simplified. ZFS events sidebar shows parsed icon + label rows.

v11.6.0 (2026-05-22) — Infrastructure

Fixed: CGO daemon build compatibility with OpenZFS 2.3.7. OTA auto-revert after every successful update (wrong health endpoint). HA ZFS import guard infinite loop. HA cluster firewall ports not opened. Network config lost after OTA slot swap. etcd cluster state lost after OTA slot swap. OTA signing key never injectable. Patroni config never provisioned. Changed: Production daemon now uses libzfs CGO path.

Full history: CHANGELOG.md on GitHub