feat: add pure NixOS flake with bun2nix

Adds a complete Nix flake for NixOS integration:

Package:
- Pure reproducible builds using bun2nix (no network during build)
- Bundles restic, rclone, shoutrrr, and other runtime dependencies
- Supports x86_64-linux, aarch64-linux, x86_64-darwin, aarch64-darwin
- Version read from package.json to avoid drift

NixOS Module:
- Systemd service with security hardening
- FUSE support for remote mounts (NFS, SMB, WebDAV)
- Configurable options: port, dataDir, user/group, timezone, etc.
- protectHome option for backing up home directories
- CAP_DAC_READ_SEARCH capability when protectHome=false
- PORT env var properly passed to server

Development:
- devShell with bun, node, biome, typescript, and runtime tools
- bun2nix CLI for regenerating bun.nix after dependency changes

Also includes:
- Overlay exposing zerobyte and shoutrrr packages
- NixOS VM integration tests
- Experimental darwin module (requires TCC permissions for home dirs)
- Configurable MIGRATIONS_PATH and PORT for Nix store compatibility

Note: package.json includes additional peer dependencies (@standard-community/*,
@standard-schema/spec, openapi-types, quansync, react-is) required by bun2nix
for pure builds - bun requires all transitive peer deps to be satisfied.
This commit is contained in:
James Brink 2025-12-26 23:15:08 -07:00
parent 54068d5269
commit 5cb092f130
10 changed files with 3024 additions and 5 deletions

3
.gitignore vendored
View file

@ -1,5 +1,7 @@
.DS_Store
.direnv/
/node_modules/
result
# React Router
/.react-router/
@ -13,3 +15,4 @@ CLAUDE.md
mutagen.yml.lock
notes.md
smb-password.txt
.envrc

View file

@ -98,6 +98,67 @@ services:
If you need remote mount capabilities, keep the original configuration with `cap_add: SYS_ADMIN` and `devices: /dev/fuse:/dev/fuse`.
### Nix Flake
Zerobyte provides a Nix flake for NixOS users and Nix-based development environments.
**Development shell:**
```bash
nix develop
```
**NixOS module:**
```nix
{
inputs.zerobyte.url = "github:nicotsx/zerobyte";
outputs = { self, nixpkgs, zerobyte }: {
nixosConfigurations.myhost = nixpkgs.lib.nixosSystem {
modules = [
zerobyte.nixosModules.default
{
services.zerobyte = {
enable = true;
port = 4096;
openFirewall = true;
# fuse.enable = true; # Enabled by default for remote mounts
};
}
];
};
};
}
```
**Available module options (NixOS):**
| Option | Default | Description |
|--------|---------|-------------|
| `enable` | `false` | Enable Zerobyte service |
| `port` | `4096` | HTTP port |
| `dataDir` | `/var/lib/zerobyte` | Data directory |
| `user` | `"zerobyte"` | User to run service as |
| `group` | `"zerobyte"` | Group to run service as |
| `createUser` | `true` | Create user/group automatically |
| `serverIp` | `"0.0.0.0"` | Bind address |
| `openFirewall` | `false` | Open firewall port (NixOS only) |
| `fuse.enable` | `true` | Enable FUSE/remote mounts (NixOS only) |
| `protectHome` | `true` | Block /home access (NixOS only, set false to backup home dirs) |
| `extraReadWritePaths` | `[]` | Additional writable paths (e.g., `["/mnt/storage"]` for custom repos) |
| `timezone` | `"UTC"` | Timezone for scheduling |
| `resticHostname` | `"zerobyte"` | Hostname for restic |
**Updating dependencies (for contributors):**
After modifying `package.json` or `bun.lock`, regenerate the Nix dependency file:
```bash
nix develop
bun2nix -o bun.nix
```
## Examples
See [examples/README.md](examples/README.md) for runnable, copy/paste-friendly examples.

View file

@ -3,15 +3,19 @@ import "dotenv/config";
const envSchema = type({
NODE_ENV: type.enumerated("development", "production", "test").default("production"),
PORT: 'string.integer.parse = "4096"',
SERVER_IP: 'string = "localhost"',
SERVER_IDLE_TIMEOUT: 'string.integer.parse = "60"',
RESTIC_HOSTNAME: "string = 'zerobyte'",
MIGRATIONS_PATH: "string?",
}).pipe((s) => ({
__prod__: s.NODE_ENV === "production",
environment: s.NODE_ENV,
port: s.PORT,
serverIp: s.SERVER_IP,
serverIdleTimeout: s.SERVER_IDLE_TIMEOUT,
resticHostname: s.RESTIC_HOSTNAME,
migrationsPath: s.MIGRATIONS_PATH,
}));
const parseConfig = (env: unknown) => {

View file

@ -14,9 +14,17 @@ const sqlite = new Database(DATABASE_URL);
export const db = drizzle({ client: sqlite, schema });
export const runDbMigrations = () => {
let migrationsFolder = path.join("/app", "assets", "migrations");
let migrationsFolder: string;
if (!config.__prod__) {
// Migration path priority:
// 1. MIGRATIONS_PATH env var (Nix, custom deployments)
// 2. /app/assets/migrations (Docker production)
// 3. /app/app/drizzle (Docker development)
if (config.migrationsPath) {
migrationsFolder = config.migrationsPath;
} else if (config.__prod__) {
migrationsFolder = path.join("/app", "assets", "migrations");
} else {
migrationsFolder = path.join("/app", "app", "drizzle");
}

View file

@ -18,7 +18,7 @@ await validateRequiredMigrations(REQUIRED_MIGRATIONS);
startup();
logger.info(`Server is running at http://localhost:4096`);
logger.info(`Server is running at http://localhost:${config.port}`);
export type AppType = typeof app;
@ -36,7 +36,7 @@ process.on("SIGINT", async () => {
export default await createHonoServer({
app,
port: 4096,
port: config.port,
customBunServer: {
idleTimeout: config.serverIdleTimeout,
error(err) {

View file

@ -26,7 +26,11 @@
"@react-router/node": "^7.10.0",
"@react-router/serve": "^7.10.0",
"@scalar/hono-api-reference": "^0.9.25",
"@standard-community/standard-json": "^0.3.5",
"@standard-community/standard-openapi": "^0.2.9",
"@standard-schema/spec": "^1.1.0",
"@tanstack/react-query": "^5.90.11",
"@types/json-schema": "^7.0.15",
"arktype": "^2.1.28",
"class-variance-authority": "^0.7.1",
"clsx": "^2.1.1",
@ -45,9 +49,12 @@
"lucide-react": "^0.555.0",
"next-themes": "^0.4.6",
"node-cron": "^4.2.1",
"openapi-types": "^12.1.3",
"quansync": "^1.0.0",
"react": "^19.2.1",
"react-dom": "^19.2.1",
"react-hook-form": "^7.68.0",
"react-is": "^19.2.3",
"react-router": "^7.10.0",
"react-router-hono-server": "^2.22.0",
"recharts": "3.5.1",
@ -952,7 +959,7 @@
"qs": ["qs@6.14.0", "", { "dependencies": { "side-channel": "^1.1.0" } }, "sha512-YWWTjgABSKcvs/nWBi9PycY/JiPJqOD4JA6o9Sej2AtvSGarXxKC3OQSk4pAarbdQlKAh5D4FCQkJNkW+GAn3w=="],
"quansync": ["quansync@0.2.11", "", {}, "sha512-AifT7QEbW9Nri4tAwR5M/uzpBuqfZf+zwaEM/QkzEjj7NBuFD2rBuy0K3dE+8wltbezDV7JMA0WfnCPYRSYbXA=="],
"quansync": ["quansync@1.0.0", "", {}, "sha512-5xZacEEufv3HSTPQuchrvV6soaiACMFnq1H8wkVioctoH3TRha9Sz66lOxRwPK/qZj7HPiSveih9yAyh98gvqA=="],
"radix3": ["radix3@1.1.2", "", {}, "sha512-b484I/7b8rDEdSDKckSSBA8knMpcdsXudlE/LNL639wFoHKwLbEkQFZHWEYwDC0wa0FKUcCY+GAF73Z7wxNVFA=="],

2224
bun.nix Normal file

File diff suppressed because it is too large Load diff

170
flake.lock Normal file
View file

@ -0,0 +1,170 @@
{
"nodes": {
"bun2nix": {
"inputs": {
"flake-parts": "flake-parts",
"import-tree": "import-tree",
"nixpkgs": [
"nixpkgs"
],
"systems": "systems",
"treefmt-nix": "treefmt-nix"
},
"locked": {
"lastModified": 1765198123,
"narHash": "sha256-pkahE6wwIszQ8e107qa95wyNr4Qj94hEdL9fA/IE288=",
"owner": "nix-community",
"repo": "bun2nix",
"rev": "29d2c26c269b2bc7d8885c6a2fc90ec0fc86ec40",
"type": "github"
},
"original": {
"owner": "nix-community",
"repo": "bun2nix",
"type": "github"
}
},
"flake-parts": {
"inputs": {
"nixpkgs-lib": "nixpkgs-lib"
},
"locked": {
"lastModified": 1763759067,
"narHash": "sha256-LlLt2Jo/gMNYAwOgdRQBrsRoOz7BPRkzvNaI/fzXi2Q=",
"owner": "hercules-ci",
"repo": "flake-parts",
"rev": "2cccadc7357c0ba201788ae99c4dfa90728ef5e0",
"type": "github"
},
"original": {
"owner": "hercules-ci",
"repo": "flake-parts",
"type": "github"
}
},
"flake-utils": {
"inputs": {
"systems": "systems_2"
},
"locked": {
"lastModified": 1731533236,
"narHash": "sha256-l0KFg5HjrsfsO/JpG+r7fRrqm12kzFHyUHqHCVpMMbI=",
"owner": "numtide",
"repo": "flake-utils",
"rev": "11707dc2f618dd54ca8739b309ec4fc024de578b",
"type": "github"
},
"original": {
"owner": "numtide",
"repo": "flake-utils",
"type": "github"
}
},
"import-tree": {
"locked": {
"lastModified": 1763762820,
"narHash": "sha256-ZvYKbFib3AEwiNMLsejb/CWs/OL/srFQ8AogkebEPF0=",
"owner": "vic",
"repo": "import-tree",
"rev": "3c23749d8013ec6daa1d7255057590e9ca726646",
"type": "github"
},
"original": {
"owner": "vic",
"repo": "import-tree",
"type": "github"
}
},
"nixpkgs": {
"locked": {
"lastModified": 1766651565,
"narHash": "sha256-QEhk0eXgyIqTpJ/ehZKg9IKS7EtlWxF3N7DXy42zPfU=",
"owner": "NixOS",
"repo": "nixpkgs",
"rev": "3e2499d5539c16d0d173ba53552a4ff8547f4539",
"type": "github"
},
"original": {
"owner": "NixOS",
"ref": "nixos-unstable",
"repo": "nixpkgs",
"type": "github"
}
},
"nixpkgs-lib": {
"locked": {
"lastModified": 1761765539,
"narHash": "sha256-b0yj6kfvO8ApcSE+QmA6mUfu8IYG6/uU28OFn4PaC8M=",
"owner": "nix-community",
"repo": "nixpkgs.lib",
"rev": "719359f4562934ae99f5443f20aa06c2ffff91fc",
"type": "github"
},
"original": {
"owner": "nix-community",
"repo": "nixpkgs.lib",
"type": "github"
}
},
"root": {
"inputs": {
"bun2nix": "bun2nix",
"flake-utils": "flake-utils",
"nixpkgs": "nixpkgs"
}
},
"systems": {
"locked": {
"lastModified": 1681028828,
"narHash": "sha256-Vy1rq5AaRuLzOxct8nz4T6wlgyUR7zLU309k9mBC768=",
"owner": "nix-systems",
"repo": "default",
"rev": "da67096a3b9bf56a91d16901293e51ba5b49a27e",
"type": "github"
},
"original": {
"owner": "nix-systems",
"repo": "default",
"type": "github"
}
},
"systems_2": {
"locked": {
"lastModified": 1681028828,
"narHash": "sha256-Vy1rq5AaRuLzOxct8nz4T6wlgyUR7zLU309k9mBC768=",
"owner": "nix-systems",
"repo": "default",
"rev": "da67096a3b9bf56a91d16901293e51ba5b49a27e",
"type": "github"
},
"original": {
"owner": "nix-systems",
"repo": "default",
"type": "github"
}
},
"treefmt-nix": {
"inputs": {
"nixpkgs": [
"bun2nix",
"nixpkgs"
]
},
"locked": {
"lastModified": 1762938485,
"narHash": "sha256-AlEObg0syDl+Spi4LsZIBrjw+snSVU4T8MOeuZJUJjM=",
"owner": "numtide",
"repo": "treefmt-nix",
"rev": "5b4ee75aeefd1e2d5a1cc43cf6ba65eba75e83e4",
"type": "github"
},
"original": {
"owner": "numtide",
"repo": "treefmt-nix",
"type": "github"
}
}
},
"root": "root",
"version": 7
}

535
flake.nix Normal file
View file

@ -0,0 +1,535 @@
{
description = "Zerobyte - Self-hosted backup automation and management";
inputs = {
nixpkgs.url = "github:NixOS/nixpkgs/nixos-unstable";
flake-utils.url = "github:numtide/flake-utils";
bun2nix.url = "github:nix-community/bun2nix";
bun2nix.inputs.nixpkgs.follows = "nixpkgs";
};
outputs = { self, nixpkgs, flake-utils, bun2nix }:
let
# Systems for packages and devShells
allSystems = [
"x86_64-linux"
"aarch64-linux"
"x86_64-darwin"
"aarch64-darwin"
];
# Linux-only systems for NixOS module and tests
linuxSystems = [
"x86_64-linux"
"aarch64-linux"
];
# shoutrrr version and hashes (SRI format)
shoutrrrVersion = "0.13.1";
shoutrrrHashes = {
x86_64-linux = "sha256-TZrDstm5InQOalYf9da5rhnsJm7qTnmG18jLJtvsD8A=";
aarch64-linux = "sha256-IHgZhsykJbmW/uYUsd6o7Wh3EIsUldduIKFZ0GkjrwI=";
x86_64-darwin = "sha256-pzmAGRzbWYVHoZqvx6tsuxpuKcfIXMVNPXbKHeeAyxs=";
aarch64-darwin = "sha256-DKQRzdDd1xccNqetscEKKzgyT1IatOlwPBwa4E8fbDc=";
};
# Map Nix system to shoutrrr release naming
shoutrrrArch = {
x86_64-linux = "linux_amd64";
aarch64-linux = "linux_arm64v8";
x86_64-darwin = "macOS_amd64";
aarch64-darwin = "macOS_arm64v8";
};
# Check if system is Linux
isLinux = system: builtins.elem system linuxSystems;
in
flake-utils.lib.eachSystem allSystems (system:
let
pkgs = import nixpkgs {
inherit system;
config.allowUnfree = true;
overlays = [ bun2nix.overlays.default ];
};
shoutrrr = pkgs.stdenv.mkDerivation {
pname = "shoutrrr";
version = shoutrrrVersion;
src = pkgs.fetchurl {
url = "https://github.com/nicholas-fedor/shoutrrr/releases/download/v${shoutrrrVersion}/shoutrrr_${shoutrrrArch.${system}}_${shoutrrrVersion}.tar.gz";
hash = shoutrrrHashes.${system};
};
sourceRoot = ".";
nativeBuildInputs = pkgs.lib.optionals (isLinux system) [ pkgs.autoPatchelfHook ];
installPhase = ''
runHook preInstall
install -Dm755 shoutrrr $out/bin/shoutrrr
runHook postInstall
'';
meta = with pkgs.lib; {
description = "Notification library and CLI for various services";
homepage = "https://github.com/nicholas-fedor/shoutrrr";
license = licenses.mit;
platforms = platforms.unix;
};
};
# Read version from package.json to avoid drift
packageJson = builtins.fromJSON (builtins.readFile ./package.json);
zerobyte = pkgs.stdenv.mkDerivation {
pname = "zerobyte";
version = packageJson.version or "0.0.0";
src = pkgs.lib.cleanSource ./.;
nativeBuildInputs = [
pkgs.bun2nix.hook
pkgs.makeWrapper
];
# Fetch bun dependencies using bun2nix
bunDeps = pkgs.bun2nix.fetchBunDeps {
bunNix = ./bun.nix;
};
buildPhase = ''
runHook preBuild
export HOME=$(mktemp -d)
# Build the application (react-router build)
bun run build
runHook postBuild
'';
installPhase = ''
runHook preInstall
# Create output directories matching the expected structure
mkdir -p $out/lib/zerobyte/dist
mkdir -p $out/lib/zerobyte/drizzle
mkdir -p $out/bin
# Copy built assets (server expects dist/server and dist/client)
cp -r dist/server $out/lib/zerobyte/dist/server
cp -r dist/client $out/lib/zerobyte/dist/client
cp -r app/drizzle/* $out/lib/zerobyte/drizzle/
cp package.json $out/lib/zerobyte/
# Copy node_modules for runtime dependencies
cp -r node_modules $out/lib/zerobyte/
# Create wrapper script with runtime dependencies
# --chdir ensures server finds dist/client relative to package dir
makeWrapper ${pkgs.bun}/bin/bun $out/bin/zerobyte \
--chdir $out/lib/zerobyte \
--add-flags "dist/server/index.js" \
--prefix PATH : ${pkgs.lib.makeBinPath ([
pkgs.restic
pkgs.rclone
shoutrrr
pkgs.openssh
] ++ pkgs.lib.optionals (isLinux system) [
pkgs.fuse3
pkgs.davfs2
])} \
--set NODE_ENV "production"
runHook postInstall
'';
meta = with pkgs.lib; {
description = "Self-hosted backup automation and management";
homepage = "https://github.com/nicotsx/zerobyte";
license = licenses.mit;
platforms = platforms.unix;
mainProgram = "zerobyte";
};
};
in
{
packages = {
inherit zerobyte shoutrrr;
default = zerobyte;
};
devShells.default = pkgs.mkShell {
buildInputs = [
# JavaScript runtime and package manager
pkgs.bun
pkgs.nodejs
# Development tools
pkgs.biome
pkgs.typescript
# bun2nix CLI for regenerating bun.nix
bun2nix.packages.${system}.bun2nix
# External tools (for local testing)
pkgs.restic
pkgs.rclone
shoutrrr
# Database tools
pkgs.sqlite
# Utilities
pkgs.git
pkgs.curl
pkgs.jq
];
shellHook = ''
echo "Zerobyte development environment"
echo " bun: $(bun --version)"
echo " node: $(node --version)"
echo " restic: $(restic version | head -1)"
echo " rclone: $(rclone version | head -1)"
echo ""
echo "To update bun.nix after changing dependencies:"
echo " bun2nix -o bun.nix"
'';
};
}
) // {
# Overlay
overlays.default = final: prev: {
zerobyte = self.packages.${final.system}.zerobyte;
shoutrrr = self.packages.${final.system}.shoutrrr;
};
# NixOS Module
nixosModules.default = { config, lib, pkgs, ... }:
let
cfg = config.services.zerobyte;
in
{
options.services.zerobyte = {
enable = lib.mkEnableOption "Zerobyte backup management service";
package = lib.mkOption {
type = lib.types.package;
default = self.packages.${pkgs.system}.zerobyte;
defaultText = lib.literalExpression "pkgs.zerobyte";
description = "The Zerobyte package to use.";
};
user = lib.mkOption {
type = lib.types.str;
default = "zerobyte";
description = "User account under which Zerobyte runs.";
};
group = lib.mkOption {
type = lib.types.str;
default = "zerobyte";
description = "Group under which Zerobyte runs.";
};
createUser = lib.mkOption {
type = lib.types.bool;
default = true;
description = ''
Whether to create the user and group automatically.
Set to false if using an existing user account.
'';
};
dataDir = lib.mkOption {
type = lib.types.path;
default = "/var/lib/zerobyte";
description = "Directory to store Zerobyte data.";
};
port = lib.mkOption {
type = lib.types.port;
default = 4096;
description = "Port on which Zerobyte listens.";
};
openFirewall = lib.mkOption {
type = lib.types.bool;
default = false;
description = "Whether to open the firewall for Zerobyte.";
};
serverIp = lib.mkOption {
type = lib.types.str;
default = "0.0.0.0";
description = "IP address to bind the server to.";
};
timezone = lib.mkOption {
type = lib.types.str;
default = "UTC";
description = "Timezone for scheduling backups.";
};
resticHostname = lib.mkOption {
type = lib.types.str;
default = "zerobyte";
description = "Hostname used for restic operations.";
};
environment = lib.mkOption {
type = lib.types.attrsOf lib.types.str;
default = {};
description = "Additional environment variables for Zerobyte.";
};
fuse = {
enable = lib.mkOption {
type = lib.types.bool;
default = true;
description = ''
Enable FUSE mounting capabilities.
Requires CAP_SYS_ADMIN and access to /dev/fuse.
Enables NFS, SMB, and WebDAV volume mounts.
'';
};
};
protectHome = lib.mkOption {
type = lib.types.bool;
default = true;
description = ''
Enable ProtectHome systemd security hardening.
When true, /home, /root, and /run/user are inaccessible.
Set to false if you need to backup home directories.
'';
};
extraReadWritePaths = lib.mkOption {
type = lib.types.listOf lib.types.str;
default = [];
example = [ "/mnt/storage" "/backup" ];
description = ''
Additional paths the service can write to.
Use this for custom repository locations outside of dataDir.
Required because ProtectSystem=strict makes the filesystem read-only.
'';
};
};
config = lib.mkIf cfg.enable {
users.users.${cfg.user} = lib.mkIf cfg.createUser {
isSystemUser = true;
group = cfg.group;
home = cfg.dataDir;
createHome = true;
description = "Zerobyte service user";
};
users.groups.${cfg.group} = lib.mkIf cfg.createUser {};
systemd.services.zerobyte = {
description = "Zerobyte backup management service";
wantedBy = [ "multi-user.target" ];
after = [ "network.target" ];
environment = {
NODE_ENV = "production";
PORT = toString cfg.port;
SERVER_IP = cfg.serverIp;
RESTIC_HOSTNAME = cfg.resticHostname;
DATABASE_URL = "${cfg.dataDir}/data/zerobyte.db";
MIGRATIONS_PATH = "${cfg.package}/lib/zerobyte/drizzle";
TZ = cfg.timezone;
} // cfg.environment;
serviceConfig = {
Type = "simple";
User = cfg.user;
Group = cfg.group;
ExecStart = "${cfg.package}/bin/zerobyte";
Restart = "on-failure";
RestartSec = 5;
# State directory
StateDirectory = "zerobyte";
StateDirectoryMode = "0750";
WorkingDirectory = cfg.dataDir;
# Capabilities
# - CAP_SYS_ADMIN: Required for FUSE mounts
# - CAP_DAC_READ_SEARCH: Required to read restricted directories (e.g., 700 home dirs)
# - CAP_DAC_OVERRIDE: Required to write to directories not owned by service user
AmbientCapabilities =
lib.optional cfg.fuse.enable "CAP_SYS_ADMIN"
++ lib.optional (!cfg.protectHome) "CAP_DAC_READ_SEARCH"
++ lib.optional (cfg.extraReadWritePaths != []) "CAP_DAC_OVERRIDE";
CapabilityBoundingSet =
lib.optional cfg.fuse.enable "CAP_SYS_ADMIN"
++ lib.optional (!cfg.protectHome) "CAP_DAC_READ_SEARCH"
++ lib.optional (cfg.extraReadWritePaths != []) "CAP_DAC_OVERRIDE";
DeviceAllow = lib.mkIf cfg.fuse.enable [ "/dev/fuse rw" ];
# Security hardening
PrivateTmp = true;
ProtectSystem = "strict";
ProtectHome = cfg.protectHome;
# Disable when capabilities are needed (FUSE, home access, or extra write paths)
NoNewPrivileges = !cfg.fuse.enable && cfg.protectHome && cfg.extraReadWritePaths == [];
ProtectKernelTunables = true;
ProtectKernelModules = true;
ProtectControlGroups = true;
RestrictAddressFamilies = [ "AF_UNIX" "AF_INET" "AF_INET6" ];
RestrictNamespaces = !cfg.fuse.enable;
LockPersonality = true;
MemoryDenyWriteExecute = false; # Required for bun/V8
RestrictRealtime = true;
RestrictSUIDSGID = true;
RemoveIPC = true;
PrivateMounts = !cfg.fuse.enable;
# Allow write access to data directory
ReadWritePaths = [ cfg.dataDir ] ++ cfg.extraReadWritePaths;
};
};
networking.firewall.allowedTCPPorts = lib.mkIf cfg.openFirewall [ cfg.port ];
};
};
# nix-darwin Module (macOS) - EXPERIMENTAL/FUTURE USE
# macOS lacks Linux capabilities (CAP_DAC_READ_SEARCH, etc.) and uses TCC
# (Transparency, Consent, and Control) which blocks access to ~/Desktop,
# ~/Documents, etc. even for root. Full support requires significant code
# changes to handle TCC permission grants via System Preferences.
darwinModules.default = { config, lib, pkgs, ... }:
let
cfg = config.services.zerobyte;
in
{
options.services.zerobyte = {
enable = lib.mkEnableOption "Zerobyte backup management service";
package = lib.mkOption {
type = lib.types.package;
default = self.packages.${pkgs.system}.zerobyte;
defaultText = lib.literalExpression "pkgs.zerobyte";
description = "The Zerobyte package to use.";
};
dataDir = lib.mkOption {
type = lib.types.path;
default = "/var/lib/zerobyte";
description = "Directory to store Zerobyte data.";
};
port = lib.mkOption {
type = lib.types.port;
default = 4096;
description = "Port on which Zerobyte listens.";
};
serverIp = lib.mkOption {
type = lib.types.str;
default = "0.0.0.0";
description = "IP address to bind the server to.";
};
timezone = lib.mkOption {
type = lib.types.str;
default = "UTC";
description = "Timezone for scheduling backups.";
};
resticHostname = lib.mkOption {
type = lib.types.str;
default = "zerobyte";
description = "Hostname used for restic operations.";
};
environment = lib.mkOption {
type = lib.types.attrsOf lib.types.str;
default = {};
description = "Additional environment variables for Zerobyte.";
};
};
config = lib.mkIf cfg.enable {
# Create data directory
system.activationScripts.zerobyte.text = ''
mkdir -p ${cfg.dataDir}/data
chmod 750 ${cfg.dataDir}
'';
launchd.daemons.zerobyte = {
serviceConfig = {
Label = "org.zerobyte.daemon";
ProgramArguments = [ "${cfg.package}/bin/zerobyte" ];
RunAtLoad = true;
KeepAlive = true;
WorkingDirectory = "${cfg.dataDir}";
EnvironmentVariables = {
NODE_ENV = "production";
PORT = toString cfg.port;
SERVER_IP = cfg.serverIp;
RESTIC_HOSTNAME = cfg.resticHostname;
DATABASE_URL = "${cfg.dataDir}/data/zerobyte.db";
MIGRATIONS_PATH = "${cfg.package}/lib/zerobyte/drizzle";
TZ = cfg.timezone;
} // cfg.environment;
StandardOutPath = "/var/log/zerobyte.log";
StandardErrorPath = "/var/log/zerobyte.error.log";
};
};
};
};
# NixOS VM Tests (Linux only)
checks = builtins.listToAttrs (map (system:
let
pkgs = import nixpkgs { inherit system; };
in
{
name = system;
value = {
integration = pkgs.testers.nixosTest {
name = "zerobyte-integration";
nodes.machine = { config, pkgs, ... }: {
imports = [ self.nixosModules.default ];
services.zerobyte = {
enable = true;
openFirewall = true;
};
# Ensure the test VM has enough resources
virtualisation = {
memorySize = 1024;
diskSize = 2048;
};
};
testScript = ''
machine.start()
machine.wait_for_unit("zerobyte.service")
machine.wait_for_open_port(4096)
# Test healthcheck endpoint (returns {"status":"ok"})
result = machine.succeed("curl -s http://localhost:4096/healthcheck")
assert '"status":"ok"' in result or '"ok"' in result, f"Healthcheck failed: {result}"
machine.log("Zerobyte integration test passed!")
'';
};
};
}
) linuxSystems);
};
}

View file

@ -43,7 +43,11 @@
"@react-router/node": "^7.10.0",
"@react-router/serve": "^7.10.0",
"@scalar/hono-api-reference": "^0.9.25",
"@standard-community/standard-json": "^0.3.5",
"@standard-community/standard-openapi": "^0.2.9",
"@standard-schema/spec": "^1.1.0",
"@tanstack/react-query": "^5.90.11",
"@types/json-schema": "^7.0.15",
"arktype": "^2.1.28",
"class-variance-authority": "^0.7.1",
"clsx": "^2.1.1",
@ -62,9 +66,12 @@
"lucide-react": "^0.555.0",
"next-themes": "^0.4.6",
"node-cron": "^4.2.1",
"openapi-types": "^12.1.3",
"quansync": "^1.0.0",
"react": "^19.2.1",
"react-dom": "^19.2.1",
"react-hook-form": "^7.68.0",
"react-is": "^19.2.3",
"react-router": "^7.10.0",
"react-router-hono-server": "^2.22.0",
"recharts": "3.5.1",