Configuration
backupctl configuration is split between two files: .env for global settings and secrets, and config/projects.yml for per-project settings. Secrets always live in .env and are referenced in YAML via ${VAR_NAME} syntax, which is resolved at load time.
Most configuration changes in projects.yml take effect automatically on the next backup run — the config is re-read from disk each time. The exception is cron schedules: if you change the cron: field, run backupctl config reload to re-register the new schedule with the scheduler. Changes to .env require a container restart (docker compose up -d --force-recreate backupctl).
Environment Variables (.env)
Application
| Variable | Default | Description |
|---|---|---|
APP_PORT | 3100 | HTTP port for health and status endpoints |
TIMEZONE | Europe/Berlin | Timezone for file names, audit timestamps, notifications, and logs |
BACKUP_BASE_DIR | /data/backups | Base directory for all backup data, lock files, fallback audit, and logs |
BACKUP_HOST_DIR | ${BACKUP_BASE_DIR} | Host-side path for the Docker volume mount. Use when the host backup directory differs from the container path (e.g., /home/user/backups). Eliminates the need for docker-compose.override.yml |
LOG_LEVEL | info | Logging level: debug, info, warn, error |
LOG_DIR | /data/backups/.logs | Directory for log files (Winston daily rotate) |
LOG_MAX_SIZE | 10m | Maximum size per log file before rotation |
LOG_MAX_FILES | 5 | Number of rotated log files to keep |
Audit Database
| Variable | Default | Description |
|---|---|---|
AUDIT_DB_HOST | backupctl-audit-db | PostgreSQL host (matches Docker Compose service name) |
AUDIT_DB_PORT | 5432 | PostgreSQL port |
AUDIT_DB_NAME | backup_audit | Database name |
AUDIT_DB_USER | audit_user | Database user |
AUDIT_DB_PASSWORD | — | Required. Database password |
Hetzner Storage Box
Only required when a project uses storage.type: sftp. An S3-only or rclone-only deployment can leave these unset.
| Variable | Default | Description |
|---|---|---|
HETZNER_SSH_HOST | — | Storage box hostname (e.g., u123456.your-storagebox.de) |
HETZNER_SSH_USER | — | Storage box SSH user (e.g., u123456) |
HETZNER_SSH_PORT | 23 | SSH port for the storage box |
HETZNER_SSH_KEY_PATH | /home/node/.ssh/id_ed25519 | Path to SSH private key inside the container |
Storage Backend Credentials
Referenced from projects.yml via ${} under storage.config. Only set what you use. See Storage for the full backend list.
| Variable | Default | Description |
|---|---|---|
B2_ENDPOINT | — | S3 endpoint, e.g. https://s3.eu-central-003.backblazeb2.com |
B2_KEY_ID | — | S3 access key ID |
B2_APP_KEY | — | S3 secret access key |
RCLONE_CONFIG_PATH | rclone's default (/home/node/.config/rclone/rclone.conf) | Path to the rclone config file. Must be writable — rclone rewrites refreshed OAuth tokens into it |
Restic
| Variable | Default | Description |
|---|---|---|
RESTIC_PASSWORD | — | Required. Global restic repository password. Overridable per project in YAML |
Retry
| Variable | Default | Description |
|---|---|---|
BACKUP_RETRY_COUNT | 3 | Maximum retry attempts for retryable stages (3–8) |
BACKUP_RETRY_DELAY_MS | 5000 | Base delay between retries in milliseconds (exponential backoff) |
Notification Defaults
These are used when a project has no notification block in YAML.
| Variable | Default | Description |
|---|---|---|
NOTIFICATION_TYPE | slack | Default notification channel: slack, email, webhook |
SLACK_WEBHOOK_URL | — | Slack incoming webhook URL |
SMTP_HOST | — | SMTP server hostname |
SMTP_PORT | 587 | SMTP server port |
SMTP_SECURE | true | Enable TLS/STARTTLS (true or false) |
SMTP_TO | — | Recipient email address |
SMTP_FROM | — | Sender email address |
SMTP_USER | — | SMTP authentication username (if required) |
SMTP_PASSWORD | — | SMTP authentication password |
WEBHOOK_URL | — | Webhook endpoint URL |
Encryption Defaults
These are used when a project has no encryption block in YAML.
| Variable | Default | Description |
|---|---|---|
ENCRYPTION_ENABLED | false | Enable GPG encryption globally |
ENCRYPTION_TYPE | gpg | Encryption type (only gpg supported in v1) |
GPG_RECIPIENT | — | GPG key recipient identifier (email or key ID) |
GPG_KEYS_DIR | /app/gpg-keys | Directory for GPG public key files (auto-imported on startup) |
Heartbeat Monitoring
| Variable | Default | Description |
|---|---|---|
UPTIME_KUMA_BASE_URL | — | Base URL of your Uptime Kuma instance (e.g., https://kuma.example.com). Required if any project has monitor.type: uptime-kuma |
Health
| Variable | Default | Description |
|---|---|---|
HEALTH_DISK_MIN_FREE_GB | 5 | Minimum free disk space in GB before health check fails |
HEALTH_STORAGE_CHECK_TTL_SECONDS | 300 | How long /health caches its per-project storage reachability probe. Each probe is a real restic round trip, so lowering this increases API calls (and cost) against billed backends like B2 |
Daily Summary
| Variable | Default | Description |
|---|---|---|
DAILY_SUMMARY_CRON | 0 7 * * * | Cron expression for the daily backup summary notification |
Project Secrets
Project-specific secrets follow the naming pattern {PROJECT}_DB_PASSWORD and {PROJECT}_RESTIC_PASSWORD (uppercase project name with hyphens replaced by underscores):
VINELAB_DB_PASSWORD=secret
VINELAB_RESTIC_PASSWORD=restic-secret
PROJECTX_DB_PASSWORD=secret
PROJECTY_DB_PASSWORD=secretThese are referenced in projects.yml via ${VINELAB_DB_PASSWORD}.
Project Configuration (projects.yml)
The file defines an array of projects under the projects key. Each project must have at least one of database or assets configured. This supports three backup modes:
- Database + files — dumps a database and syncs asset directories
- Database only — dumps a database (no asset files)
- Files only — syncs asset directories (no database dump)
Top-Level Fields
| Field | Type | Required | Default | Description |
|---|---|---|---|---|
name | string | yes | — | Unique project identifier. Lowercase, hyphens allowed. Used in directory names, lock files, audit records, and notifications |
enabled | boolean | no | true | Whether this project is active. Disabled projects are skipped by the scheduler and run --all |
cron | string | yes | — | 5-field cron expression defining the backup schedule |
timeout_minutes | number | no | — | If set, a warning notification fires when the backup exceeds this duration. The backup continues running |
docker_network | string | no | — | Docker network name where this project's database is reachable. On startup, backupctl auto-connects to this network. If omitted, the database is assumed reachable via the host or an already-connected network |
Database
Optional. When omitted, the project operates in files-only mode — dump, verify, and encrypt stages are skipped entirely.
| Field | Type | Required | Description |
|---|---|---|---|
database.type | string | yes | Database type: postgres, mysql, mongodb |
database.host | string | yes | Database hostname (Docker service name or IP) |
database.port | number | yes | Database port |
database.name | string | yes | Database name to dump |
database.user | string | yes | Database user with backup privileges |
database.password | string | yes | Database password (use ${VAR_NAME} to reference .env) |
Compression
Only applies when database is configured.
| Field | Type | Required | Default | Description |
|---|---|---|---|---|
compression.enabled | boolean | no | true | Whether to compress dumps. Defaults to true — a per-project override is only needed to disable |
Assets
| Field | Type | Required | Default | Description |
|---|---|---|---|---|
assets.paths | string[] | no | [] | Filesystem paths to include in the backup alongside the database dump (or as the sole backup target for files-only projects). Paths that don't exist at backup time are skipped with a warning |
Storage
Each project chooses its own backend. Whatever the backend, restic encrypts the repository, so the provider never sees plaintext.
| Field | Type | Required | Default | Description |
|---|---|---|---|---|
storage.type | string | no | sftp | One of sftp, s3, b2, rclone, local |
storage.repository | string | yes | — | Repository location. Meaning depends on type — see the table below |
storage.password | string | no | RESTIC_PASSWORD from .env | Per-project restic repo password |
storage.snapshot_mode | string | no | combined | combined (one snapshot for dump + assets) or separate (individual snapshots) |
storage.config | map | depends | {} | Per-backend credentials. Required keys depend on type |
Backends
type | repository format | Required config keys | Notes |
|---|---|---|---|
sftp | backups/myproject | none — uses HETZNER_SSH_* from .env | Must be relative — Hetzner Storage Box chroots to the user home directory |
s3 | <bucket>/<path> | endpoint, access_key_id, secret_access_key. Optional region | Backblaze B2, Wasabi, Cloudflare R2, MinIO, AWS S3 |
b2 | <bucket>:<path> | account_id, account_key | B2's native API. Restic recommends using s3 against B2 instead |
rclone | <remote>:<path> | none. Optional config_path | Google Drive, OneDrive, Dropbox. Requires an authorized rclone remote |
local | /srv/restic-repo | none | A path inside the container. Useful for testing or a mounted disk |
config validate checks the required keys for the chosen type, so a missing credential is caught before a backup runs rather than at 2am.
Backblaze B2 / S3-compatible
The recommended cloud backend. One block covers every S3-compatible provider; only the endpoint changes.
storage:
type: s3
repository: my-backup-bucket/myproject
password: ${MYPROJECT_RESTIC_PASSWORD}
snapshot_mode: combined
config:
endpoint: ${B2_ENDPOINT} # https://s3.eu-central-003.backblazeb2.com
access_key_id: ${B2_KEY_ID}
secret_access_key: ${B2_APP_KEY}Google Drive, OneDrive, Dropbox (rclone)
These go through rclone, which needs an authorized remote. OAuth needs a browser, so authorize on your laptop and paste the token into the container:
rclone authorize "drive" # on a machine with a browser
docker exec -it backupctl rclone config # paste the token hereThe remote name becomes the repository prefix:
storage:
type: rclone
repository: gdrive:backups/myproject
password: ${MYPROJECT_RESTIC_PASSWORD}rclone's config lives at /home/node/.config/rclone/rclone.conf, mounted read-write from ./rclone-config. That mount must stay writable: rclone rewrites refreshed OAuth tokens into it, and a read-only mount loses them on every container recreate, surfacing later as an expired-token failure.
Worth knowing before you pick these: a restic repository is thousands of small pack files, and consumer Drive/OneDrive/Dropbox APIs are rate-limited and slow for that access pattern. Fine for modest data; prefer s3 for anything large. Automated bulk storage on consumer tiers is also a grey area in those providers' terms.
iCloud is not supported
rclone's iCloud Drive backend is experimental, rejects app-specific passwords, and needs interactive 2FA with a trust token that expires every 30 days — which breaks unattended cron backups and would put your primary Apple ID password in .env.
Deprecated: the restic: block
The old SFTP-only block still works and is read as storage: with type: sftp, logging a warning on load. Migrate by renaming the key and repository_path → repository:
# Before
restic:
repository_path: backups/myproject
password: ${MYPROJECT_RESTIC_PASSWORD}
snapshot_mode: combined
# After
storage:
type: sftp
repository: backups/myproject
password: ${MYPROJECT_RESTIC_PASSWORD}
snapshot_mode: combinedSpecifying both restic: and storage: is a validation error.
Retention
| Field | Type | Required | Default | Description |
|---|---|---|---|---|
retention.local_days | number | yes | — | Days to keep local dump files before cleanup |
retention.keep_daily | number | yes | — | Daily snapshots to keep in restic |
retention.keep_weekly | number | no | 0 | Weekly snapshots to keep in restic |
retention.keep_monthly | number | no | 0 | Monthly snapshots to keep in restic |
Encryption
| Field | Type | Required | Default | Description |
|---|---|---|---|---|
encryption.enabled | boolean | no | ENCRYPTION_ENABLED from .env | Enable GPG encryption for this project |
encryption.type | string | no | gpg | Encryption type (only gpg in v1) |
encryption.recipient | string | no | GPG_RECIPIENT from .env | GPG key recipient for encryption |
Hooks
| Field | Type | Required | Description |
|---|---|---|---|
hooks.pre_backup | string | no | Shell command to execute before the backup starts |
hooks.post_backup | string | no | Shell command to execute after the backup completes |
Hooks run via child_process.execFile — no shell injection. Configured timeouts are enforced.
Verification
| Field | Type | Required | Default | Description |
|---|---|---|---|---|
verification.enabled | boolean | no | false | Enable dump verification after dumping |
Notification
| Field | Type | Required | Default | Description |
|---|---|---|---|---|
notification.type | string | no | NOTIFICATION_TYPE from .env | Channel type: slack, email, webhook |
notification.config | object | no | Values from .env | Channel-specific configuration (see examples below) |
If the entire notification block is absent, the global notification channel from .env is used.
Monitor
Optional. Configures a heartbeat monitor for passive failure detection. Independent of the notification block — you can have both, either, or neither.
| Field | Type | Required | Description |
|---|---|---|---|
monitor.type | string | yes | Monitor type. Currently only uptime-kuma is supported |
monitor.config.push_token | string | yes | Push token from the Uptime Kuma push monitor page |
monitor:
type: uptime-kuma
config:
push_token: YOUR_PUSH_TOKENWhen the monitor block is absent, the heartbeat step is skipped entirely. See Monitoring with Uptime Kuma for full setup instructions.
Config Resolution Rules
- Project YAML > .env global > hardcoded defaults — project-level values always take priority.
${VAR_NAME}resolved from.envat config load time.- Secrets always in
.env— referenced via${}in YAML. Never put passwords directly in YAML. - Missing
notificationblock — usesNOTIFICATION_TYPE+ channel config from.env. - Missing
encryptionblock — usesENCRYPTION_ENABLED/ENCRYPTION_TYPE/GPG_RECIPIENTfrom.env. - Missing
restic.password— usesRESTIC_PASSWORDfrom.env. compression.enableddefaults totrue— compression is always on unless explicitly disabled.projects.ymlchanges apply on next backup run — no restart needed. Only cron schedule changes requirebackupctl config reload..envchanges require a container restart.
Cron Expression Examples
The cron field uses standard 5-field cron syntax (minute, hour, day-of-month, month, day-of-week):
| Expression | Schedule |
|---|---|
0 2 * * * | Daily at 2:00 AM |
0 */6 * * * | Every 6 hours |
30 1 * * 1-5 | Weekdays at 1:30 AM |
0 0 * * 0 | Weekly on Sunday at midnight |
0 3 1 * * | Monthly on the 1st at 3:00 AM |
0 0,12 * * * | Twice daily at midnight and noon |
*/30 * * * * | Every 30 minutes |
Times are interpreted in the timezone set by the TIMEZONE environment variable (default Europe/Berlin).
Snapshot Modes
Combined (default)
A single restic snapshot contains both the database dump and all asset paths. Tagged with backupctl:combined,project:{name}.
storage:
snapshot_mode: combinedPros: simpler management, one snapshot per backup run, atomic restore.
Cons: any change in assets triggers a new snapshot even if the DB hasn't changed.
Separate
Individual restic snapshots for the database dump and each asset path. The dump snapshot is tagged backupctl:db,project:{name}. Each asset snapshot is tagged backupctl:assets:{path},project:{name}.
storage:
snapshot_mode: separatePros: independent retention and restore per data type, better deduplication when assets change frequently.
Cons: more snapshots to manage, restore requires specifying --only db or --only assets.
Notification Configuration Examples
Slack
notification:
type: slack
config:
webhook_url: https://hooks.slack.com/services/T.../B.../xxxEmail
notification:
type: email
config:
smtp_host: smtp.gmail.com
smtp_port: 587
smtp_secure: true
to: devops@company.com
from: backup@company.com
password: ${SMTP_PASSWORD}The smtp_secure field controls TLS: true enables TLS/STARTTLS, false sends in plain text. Always use true in production.
Webhook
notification:
type: webhook
config:
url: https://api.company.com/hooks/backupctlThe webhook notifier POSTs application/json with an event field, a text field (markdown-formatted), and a structured data object.
Encryption Configuration
Per-Project
encryption:
enabled: true
type: gpg
recipient: vinelab-backup@company.comGlobal (via .env)
ENCRYPTION_ENABLED=true
ENCRYPTION_TYPE=gpg
GPG_RECIPIENT=backup@company.comWhen a project has no encryption block, these global values apply. When a project explicitly sets encryption.enabled: false, encryption is disabled for that project regardless of the global setting.
GPG public key files placed in gpg-keys/ are auto-imported into the container's keyring on every startup. Use backupctl config import-gpg-key <file> for runtime imports.
Complete Multi-Project Example
A full config/projects.yml with three projects using different databases and settings:
projects:
# PostgreSQL with full configuration
- name: vinelab
enabled: true
cron: "0 0 * * *"
timeout_minutes: 30
docker_network: vinelab_vinelab-network
database:
type: postgres
host: postgres-vinelab
port: 5432
name: vinelab_db
user: backup_user
password: ${VINELAB_DB_PASSWORD}
assets:
paths:
- /data/vinelab/uploads
- /data/vinelab/assets
storage:
type: sftp
repository: backups/vinelab
password: ${VINELAB_RESTIC_PASSWORD}
snapshot_mode: combined
retention:
local_days: 7
keep_daily: 7
keep_weekly: 4
keep_monthly: 0
encryption:
enabled: true
type: gpg
recipient: vinelab-backup@company.com
hooks:
pre_backup: "curl -s http://vinelab-app:3000/maintenance/on"
post_backup: "curl -s http://vinelab-app:3000/maintenance/off"
verification:
enabled: true
notification:
type: slack
config:
webhook_url: https://hooks.slack.com/services/VINELAB/SPECIFIC/HOOK
monitor:
type: uptime-kuma
config:
push_token: YOUR_VINELAB_PUSH_TOKEN
# MySQL with email notifications and separate snapshots
- name: project-x
enabled: true
cron: "30 1 * * *"
database:
type: mysql
host: mysql-projectx
port: 3306
name: projectx_db
user: backup_user
password: ${PROJECTX_DB_PASSWORD}
assets:
paths:
- /data/projectx/storage
storage:
type: sftp
repository: backups/project-x
snapshot_mode: separate
retention:
local_days: 14
keep_daily: 14
keep_weekly: 8
notification:
type: email
config:
smtp_host: smtp.gmail.com
smtp_port: 587
smtp_secure: true
to: devops@company.com
from: backup@company.com
password: ${SMTP_PASSWORD}
# MongoDB with global defaults (no overrides)
- name: analytics
enabled: true
cron: "0 3 * * *"
database:
type: mongodb
host: mongo-analytics
port: 27017
name: analytics_db
user: backup_user
password: ${ANALYTICS_DB_PASSWORD}
assets:
paths: []
storage:
type: sftp
repository: backups/analytics
snapshot_mode: combined
retention:
local_days: 7
keep_daily: 7
keep_weekly: 4
# No encryption block → uses ENCRYPTION_ENABLED from .env
# No notification block → uses NOTIFICATION_TYPE + config from .env
# Files-only project (no database) — syncs asset directories only
- name: static-assets
enabled: true
cron: "0 4 * * *"
assets:
paths:
- /data/static/uploads
- /data/static/media
storage:
type: sftp
repository: backups/static-assets
snapshot_mode: combined
retention:
local_days: 14
keep_daily: 14
keep_weekly: 4Directory Structure
backupctl organizes all data under BACKUP_BASE_DIR (default /data/backups):
${BACKUP_BASE_DIR}/
├── vinelab/
│ ├── vinelab_backup_20260318_000000_a1b2.sql.gz # compressed dump
│ ├── vinelab_backup_20260318_000000_a1b2.sql.gz.gpg # encrypted dump (if enabled)
│ ├── vinelab_backup_20260317_000000_c3d4.sql.gz
│ └── .lock # present while backup is running
├── project-x/
│ ├── project-x_backup_20260318_013000_e5f6.sql.gz
│ └── .lock
├── analytics/
│ └── ...
├── .fallback-audit/
│ └── fallback.jsonl # JSONL fallback for audit/notification failures
└── .logs/
├── backupctl-2026-03-18.log # Winston daily rotate
└── backupctl-2026-03-17.logEach project gets its own subdirectory. The .lock file is present only while a backup is in progress and is removed on completion (or cleaned by startup recovery if the process crashed). The .fallback-audit/ directory holds JSONL entries that are replayed into the audit database on the next successful startup.
Validation
Use the CLI to validate configuration at any time:
# Validate YAML structure, required fields, and .env variable resolution
backupctl config validate
# Show resolved config for a specific project (secrets masked)
backupctl config show vinelabconfig validate checks:
- YAML syntax and structure
- Required fields present for each project
${VAR_NAME}references resolve to non-empty values in.env- Database type is one of
postgres,mysql,mongodb - Notification type is one of
slack,email,webhook - Monitor type is
uptime-kuma(if present) andpush_tokenis set UPTIME_KUMA_BASE_URLis set in.envwhen any project usesmonitor.type: uptime-kuma- Cron expressions are valid
- Retention values are non-negative
- GPG recipient is set when encryption is enabled
Getting Help
- Config not working? — Run
backupctl config validateto check for errors - Need examples? — See the Complete Multi-Project Example above
- Still stuck? — Report an issue on GitHub
What's Next
- Run commands — CLI Reference covers all 16 commands with flags and examples.
- Understand the flow — Backup Flow explains the 11-step orchestration pipeline.
- Quick reference — Cheatsheet for daily operations.