- Add app/schemas/config-import.ts with ArkType schemas for upfront validation - Replace runtime type checks with schema-based validation in config-import.ts - Return validation errors early with detailed path and message info - Export retentionPolicySchema from backups.dto.ts for reuse - Update CLI to handle validation errors in both dry-run and import modes - Fix README mirrors examples to use existing repository names
13 KiB
Config file import (Infrastructure as Code)
Zerobyte supports config file import via the CLI. This lets you pre-configure volumes, repositories, backup schedules, notification destinations, and an initial user.
This example includes:
- a runnable
docker-compose.yml - a comprehensive
zerobyte.config.example.jsontemplate (trim it down to what you actually use) .env.exampleshowing how to inject secrets via environment variables
Prerequisites
- Docker + Docker Compose
This example includes SYS_ADMIN and /dev/fuse because it's compatible with remote volume mounts (SMB/NFS/WebDAV).
Setup
- Copy the env file:
cp .env.example .env
- Create a local directory to mount as a sample volume:
mkdir -p mydata
- Create a working config file (copy the example template):
cp zerobyte.config.example.json zerobyte.config.json
This is the recommended workflow for quick testing: if you don't have your own JSON config yet, start from the template.
- Review/edit
zerobyte.config.json.
The example template is intentionally "kitchen-sink" (lots of volume/repository/notification types) so you can copy what you need. Delete the entries you don't plan to use, and keep only the ones you have credentials/mounts for.
- Start Zerobyte:
docker compose up -d
- Run the config import:
docker compose exec zerobyte bun run cli import-config --config /app/zerobyte.config.json
- Access the UI at
http://localhost:4096.
Notes
CLI import command
Import configuration using the CLI:
# Import from a mounted config file (starts a new temporary container)
docker compose run --rm zerobyte bun run cli import-config --config /app/zerobyte.config.json
# Import from a mounted config file into an already-running container
docker compose exec zerobyte bun run cli import-config --config /app/zerobyte.config.json
# Import from stdin (into running container)
cat zerobyte.config.json | docker compose exec -T zerobyte bun run cli import-config --stdin
# Import from stdin in PowerShell (into running container)
Get-Content zerobyte.config.json | docker compose exec -T zerobyte bun run cli import-config --stdin
# Validate config without importing (dry run)
docker compose run --rm zerobyte bun run cli import-config --config /app/zerobyte.config.json --dry-run
# Get JSON output for scripting
docker compose exec zerobyte bun run cli import-config --config /app/zerobyte.config.json --json
The --stdin option is useful when you don't want to mount the config file - just pipe it directly.
CLI options
| Option | Description |
|---|---|
--config <path> |
Path to the configuration file inside the container |
--stdin |
Read configuration from stdin |
--dry-run |
Validate the config without importing |
--json |
Output results in JSON format |
--log-level <level> |
Set log level (debug, info, warn, error) |
--overwrite-recovery-key |
Overwrite existing recovery key (only allowed if database is empty) |
Secrets via env vars
Zerobyte supports two different mechanisms that are easy to confuse:
- Config import interpolation (this example)
- Secret placeholders (
env://...andfile://...)
1) Config import interpolation: ${VAR_NAME}
During config import, any string value in the JSON can reference an environment variable using ${VAR_NAME}.
Example:
{
"recoveryKey": "${RECOVERY_KEY}",
"repositories": [
{
"name": "s3-repo",
"config": {
"backend": "s3",
"accessKeyId": "${ACCESS_KEY_ID}",
"secretAccessKey": "${SECRET_ACCESS_KEY}"
}
}
]
}
Important properties of ${...} interpolation:
- It runs only during import.
- Values are resolved before they are written to the database (meaning the actual secret ends up in the DB for fields that are stored as secrets).
- Because it reads
process.env, Docker Compose must inject those variables into the container.
This example uses:
env_file: .env
So to make ${VAR_NAME} work, put the variables in .env (or otherwise provide them in the container environment).
Host-side interpolation (alternative)
You can also interpolate environment variables on the host before piping the config to the container. This is useful in CI/CD pipelines where secrets are injected by the pipeline and you don't want them exposed to the container environment.
Linux/macOS (using envsubst):
# Load .env and substitute variables before piping
export $(grep -v '^#' .env | xargs) && envsubst < zerobyte.config.json | docker compose exec -T zerobyte bun run cli import-config --stdin
PowerShell:
# Load .env and substitute variables before piping
Get-Content .env | ForEach-Object { if ($_ -match '^([^#][^=]+)=(.*)$') { [Environment]::SetEnvironmentVariable($matches[1], $matches[2]) } }
(Get-Content zerobyte.config.json -Raw) -replace '\$\{(\w+)\}', { $env:($_.Groups[1].Value) } | docker compose exec -T zerobyte bun run cli import-config --stdin
2) Secret placeholders: env://... and file://...
Separately from config import, Zerobyte supports secret placeholders for some sensitive fields. These placeholders are stored as-is in the database (the raw secret is not stored) and resolved at runtime.
Supported formats:
env://VAR_NAME→ readsprocess.env.VAR_NAMEat runtimefile://secret_name→ reads/run/secrets/secret_name(Docker secrets)
This is useful when you want to keep secrets out of the database and rotate them without editing Zerobyte's stored config.
See the runnable example:
Config file behavior (create-only)
The config file is applied using a create-only approach:
- Volumes, notifications, schedules: Skipped if a resource with the same name already exists
- Repositories: Skipped if any of these conditions are met:
- A repository pointing to the same location (path/bucket/endpoint) is already registered
- For local repos: the path is already a restic repository (set
isExistingRepository: trueto import it) - A repository with the same name already exists
- Changes made via the UI are preserved across imports
- To update a resource from config, either modify it via the UI or delete it first
This makes the config file better suited as "initial setup" than as a "desired state sync".
Config structure reference
This example is intended to be the primary, copy/paste-friendly reference for config import.
zerobyte.config.json structure
{
"recoveryKey": "${RECOVERY_KEY}",
"volumes": [
"..."
],
"repositories": [
"..."
],
"backupSchedules": [
"..."
],
"notificationDestinations": [
"..."
],
"users": [
"..."
]
}
Volume types
Local directory
{
"name": "local-volume",
"config": {
"backend": "directory",
"path": "/mydata",
"readOnly": true
}
}
NFS
{
"name": "nfs-volume",
"config": {
"backend": "nfs",
"server": "nfs.example.com",
"exportPath": "/data",
"port": 2049,
"version": "4",
"readOnly": false
}
}
SMB
{
"name": "smb-volume",
"config": {
"backend": "smb",
"server": "smb.example.com",
"share": "shared",
"username": "user",
"password": "${SMB_PASSWORD}",
"vers": "3.0",
"domain": "WORKGROUP",
"port": 445,
"readOnly": false
}
}
WebDAV
{
"name": "webdav-volume",
"config": {
"backend": "webdav",
"server": "webdav.example.com",
"path": "/remote.php/webdav",
"username": "user",
"password": "${WEBDAV_PASSWORD}",
"port": 80,
"readOnly": false,
"ssl": true
}
}
SFTP
{
"name": "sftp-volume",
"config": {
"backend": "sftp",
"host": "sftp.example.com",
"port": 22,
"username": "user",
"password": "${SFTP_PASSWORD}",
"path": "/data",
"readOnly": false,
"skipHostKeyCheck": true
}
}
For key-based authentication:
{
"name": "sftp-volume-key",
"config": {
"backend": "sftp",
"host": "sftp.example.com",
"port": 22,
"username": "user",
"privateKey": "${SFTP_PRIVATE_KEY}",
"path": "/data",
"readOnly": false,
"skipHostKeyCheck": false,
"knownHosts": "sftp.example.com ssh-ed25519 AAAA..."
}
}
Repository types
Local (new repository)
Creates a new restic repository. The path is optional and defaults to /var/lib/zerobyte/repositories:
{
"name": "local-repo",
"config": {
"backend": "local"
},
"compressionMode": "auto"
}
The actual repository will be created at {path}/{auto-generated-id}.
Local (existing repository)
To import an existing restic repository, set isExistingRepository: true and provide the full path to the repository root:
{
"name": "my-local-repo",
"config": {
"backend": "local",
"path": "/var/lib/zerobyte/repositories/abc123",
"isExistingRepository": true
}
}
Note: The path must point directly to the restic repository root (the directory containing config, data/, keys/, etc.).
S3-compatible
{
"name": "backup-repo",
"config": {
"backend": "s3",
"endpoint": "s3.amazonaws.com",
"bucket": "mybucket",
"accessKeyId": "${ACCESS_KEY_ID}",
"secretAccessKey": "${SECRET_ACCESS_KEY}"
},
"compressionMode": "auto"
}
Google Cloud Storage
{
"name": "gcs-repo",
"config": {
"backend": "gcs",
"bucket": "mybucket",
"projectId": "my-gcp-project",
"credentialsJson": "${GCS_CREDENTIALS}"
}
}
Azure Blob Storage
{
"name": "azure-repo",
"config": {
"backend": "azure",
"container": "mycontainer",
"accountName": "myaccount",
"accountKey": "${AZURE_KEY}"
}
}
Backup schedules
{
"name": "local-volume-local-repo",
"volume": "local-volume",
"repository": "local-repo",
"cronExpression": "0 2 * * *",
"retentionPolicy": { "keepLast": 7, "keepDaily": 7 },
"includePatterns": ["important-folder"],
"excludePatterns": ["*.tmp", "*.log"],
"excludeIfPresent": [".nobackup"],
"oneFileSystem": true,
"enabled": true,
"notifications": ["slack-alerts", "email-admin"],
"mirrors": [
{ "repository": "s3-repo" },
{ "repository": "azure-repo" }
]
}
Fields:
name: Unique schedule namevolume: Name of the source volumerepository: Name of the primary destination repositorycronExpression: Cron string for schedule timingretentionPolicy: Object with retention rules (keepLast,keepHourly,keepDaily,keepWeekly,keepMonthly,keepYearly,keepWithinDuration)includePatterns/excludePatterns: Arrays of file patternsexcludeIfPresent: Array of filenames; if any of these files exist in a directory, that directory is excluded (e.g.,[".nobackup"])oneFileSystem: Boolean; iftrue, restic won't cross filesystem boundaries (useful when backing up/to avoid traversing into mounted volumes)enabled: Booleannotifications: Array of notification destination names or detailed objects (see below)mirrors: Array of mirror repositories (see below)
Notifications (detailed)
notifications can be strings (destination names) or objects with fine-grained control:
[
{
"name": "slack-alerts",
"notifyOnStart": false,
"notifyOnSuccess": true,
"notifyOnWarning": true,
"notifyOnFailure": true
}
]
Mirrors
Mirrors let you automatically copy snapshots to additional repositories after each backup. Each mirror references a repository by name:
"mirrors": [
{ "repository": "s3-repo" },
{ "repository": "azure-repo", "enabled": false }
]
User setup (automated)
Zerobyte currently supports a single user.
If multiple entries are provided in users[], only the first one will be applied.
New instance:
{
"recoveryKey": "${RECOVERY_KEY}",
"users": [
{
"username": "my-user",
"password": "${ADMIN_PASSWORD}"
}
]
}
Migration:
{
"recoveryKey": "${RECOVERY_KEY}",
"users": [
{
"username": "my-user",
"passwordHash": "$argon2id$v=19$m=19456,t=2,p=1$..."
}
]
}
Use either password OR passwordHash, not both.
Recovery key
The recovery key is a 64-character hex string that serves two critical purposes:
- Restic repository password (encrypts your backup data)
- Database encryption key (encrypts credentials stored in Zerobyte)
Generating a recovery key ahead of time:
# Using OpenSSL (Linux/macOS)
openssl rand -hex 32
# Using Python
python3 -c "import secrets; print(secrets.token_hex(32))"
# Using Docker (prints the key, container is removed)
docker run --rm python:3.12-alpine sh -lc 'echo "Key is on the next line:"; python -c "import secrets; print(secrets.token_hex(32))"'