- Ruby 83.8%
- JavaScript 7.4%
- Shell 3.2%
- HTML 2.7%
- CSS 2.2%
- Other 0.6%
|
|
||
|---|---|---|
| .forgejo | ||
| app | ||
| dev | ||
| docs | ||
| hooks | ||
| screenshots | ||
| .dockerignore | ||
| .gitignore | ||
| .tool-versions | ||
| API_DOCUMENTATION.md | ||
| CHANGELOG.md | ||
| CLAUDE.md | ||
| DEVELOPMENT.md | ||
| docker-compose.cluster.yml | ||
| docker-compose.dev.yml | ||
| docker-compose.yml | ||
| Dockerfile | ||
| entrypoint.sh | ||
| LICENSE | ||
| README.md | ||
| SECURITY.md | ||
| TROUBLESHOOTING.md | ||
Baktainer
A Docker-native backup & restore system for your database containers and volumes.
Baktainer watches a Docker daemon for containers you've labelled for backup, and on a cron schedule dumps each one's database and/or its volumes to compressed (optionally encrypted) archives — with a web dashboard, REST API, notifications, retention, and one-click restore. Run it standalone on a single host, or as a thin-node cluster that backs up many Docker hosts and manages them all from one dashboard.
Features
- Database engines — PostgreSQL, MySQL, MariaDB, SQLite, MongoDB, and Redis
- Volume backups — Docker volumes and bind mounts, standalone or alongside a database
- Thin-node cluster — one dashboard across many Docker hosts; headless worker nodes enroll with a cryptographic identity and are approved from the UI
- Scheduling — cron-based backups per instance
- Container discovery — opt containers in with Docker labels; schema-based label validation with helpful errors
- Web dashboard & REST API — real-time status, per-stack backup grid, history, and restore
- Authentication — local users (Argon2id) with roles, plus OIDC/SSO
- Notifications — Slack, Discord, Microsoft Teams, generic webhooks, and logs
- Backup rotation — automatic cleanup by age, count, or free disk space
- Encryption — AES-256-GCM for backup files
- Compression — gzip (default) or zstd
- Hooks — pre/post-backup scripts for custom workflows (S3, Restic, …)
- Restore & browse — download, inspect, and restore any backup from the dashboard
- Auto-import — imports existing
.metabackups on first start - SSL/TLS — secure connections to a remote Docker API
- High performance — multi-threaded backups with dynamic scaling and streaming for large databases
Installation
⚠️ Security notice: Baktainer needs access to the Docker socket, which grants significant privileges. Review SECURITY.md before deploying, and consider a Docker socket proxy.
services:
baktainer:
image: ruby-code.com/james/baktainer:latest
container_name: baktainer
restart: unless-stopped
ports:
- "8080:8080" # Dashboard + REST API
volumes:
- ./backups:/backups
- ./data:/data # SQLite: history + users
- /var/run/docker.sock:/var/run/docker.sock
environment:
- BT_CRON=0 0 * * * # Daily at midnight
- BT_DOCKER_URL=unix:///var/run/docker.sock
- BT_THREADS=4
- BT_LOG_LEVEL=info
- BT_HEALTH_SERVER_ENABLED=true
- BT_COMPRESS=true
- BT_ROTATION_ENABLED=true
- BT_RETENTION_DAYS=30
# - BT_ENCRYPTION_ENABLED=true
# - BT_ENCRYPTION_KEY=your-hex-key
# - BT_NOTIFICATION_CHANNELS=slack,log
# - BT_SLACK_WEBHOOK_URL=https://hooks.slack.com/...
Then browse to http://localhost:8080. With authentication enabled (the default) you'll be walked through a one-time admin setup.
Quick start
-
Add labels to a database container you want backed up (see Docker labels):
services: db: image: postgres:17 environment: POSTGRES_DB: appdb POSTGRES_USER: app POSTGRES_PASSWORD: secret labels: - baktainer.backup=true - baktainer.db.engine=postgres - baktainer.db.name=appdb - baktainer.db.user=app - baktainer.db.password=secret - baktainer.name=myapp -
Start Baktainer with the compose file above.
-
Validate your setup without writing anything:
docker exec baktainer ruby app.rb --dry-run. -
Trigger a backup immediately:
docker exec baktainer ruby app.rb --now, or click Run All Backups on the dashboard.
Docker labels
Containers opt in via labels. Only baktainer.backup and baktainer.db.engine are always required.
Core labels
| Label | Description | Required |
|---|---|---|
baktainer.backup |
Set true to enable backup for this container |
Yes |
baktainer.db.engine |
postgres, mysql, mariadb, sqlite, mongodb, redis, or volume |
Yes |
baktainer.db.name |
Database name(s), comma-separated (Redis: DB number 0–15) | All except SQLite / volume |
baktainer.db.user |
Database username | MySQL, MariaDB, PostgreSQL (Redis for ACL) |
baktainer.db.password |
Database password | MySQL, MariaDB, PostgreSQL, Redis |
baktainer.db.all |
true to dump all databases |
Optional (Postgres/MySQL) |
Optional labels
| Label | Description | Default |
|---|---|---|
baktainer.name |
Backup name used in filenames and the UI | Database name |
baktainer.stack |
Group backups by stack in the UI | com.docker.compose.project, else name |
baktainer.volumes.backup |
true to also back up the container's volumes/bind mounts |
false |
baktainer.compress |
Per-container compression override (true/false) |
BT_COMPRESS |
baktainer.backup.encrypt |
Per-container encryption (true/false) |
BT_ENCRYPTION_ENABLED |
baktainer.backup.priority |
low, normal, high, or critical |
normal |
baktainer.backup.retention.days |
Days to keep this container's backups | BT_RETENTION_DAYS |
baktainer.backup.retention.count |
Max backups to keep for this container | BT_RETENTION_COUNT |
baktainer.command |
Custom backup command | none |
Environment variables
Booleans accept true/false, 1/0, yes/no, on/off.
Core
| Variable | Description | Default |
|---|---|---|
BT_CRON |
Cron expression for scheduled backups | 0 0 * * * |
BT_THREADS |
Worker threads for backups (max 50) | 4 |
BT_LOG_LEVEL |
debug, info, warn, error |
info |
BT_DOCKER_URL |
Docker API URL | unix:///var/run/docker.sock |
BT_BACKUP_DIR |
Directory for backup files | /backups |
BT_BACKUP_TIMEOUT |
Overall backup timeout (seconds) | 1800 |
Backup, compression & rotation
| Variable | Description | Default |
|---|---|---|
BT_COMPRESS |
Enable compression | true |
BT_COMPRESSION_TYPE |
gzip or zstd |
gzip |
BT_COMPRESSION_LEVEL |
Compression level | engine default |
BT_ROTATION_ENABLED |
Enable automatic rotation | true |
BT_RETENTION_DAYS |
Days to keep backups (0 = unlimited, max 365) | 30 |
BT_RETENTION_COUNT |
Max backups per container (0 = unlimited) | 0 |
BT_MIN_FREE_SPACE_GB |
Trigger cleanup below this free space | 10 |
Encryption
| Variable | Description | Default |
|---|---|---|
BT_ENCRYPTION_ENABLED |
Enable AES-256-GCM encryption | false |
BT_ENCRYPTION_KEY |
Encryption key (hex or base64) | none |
BT_ENCRYPTION_KEY_FILE |
Path to a key file | none |
BT_ENCRYPTION_PASSPHRASE |
Passphrase for key derivation | none |
BT_KEY_ROTATION_ENABLED |
Enable key rotation | false |
Health / dashboard
| Variable | Description | Default |
|---|---|---|
BT_HEALTH_SERVER_ENABLED |
Serve the dashboard + API (must be true) |
unset |
BT_HEALTH_PORT |
Dashboard port | 8080 |
BT_HEALTH_BIND |
Bind address | 0.0.0.0 |
BT_HEALTH_CHECK_TOKEN |
Token for external health checks | none |
Authentication
| Variable | Description | Default |
|---|---|---|
BT_AUTH_ENABLED |
Require login for the dashboard/API | true |
BT_AUTH_SECRET |
Session signing secret (auto-generated if unset) | auto |
BT_DB_PATH |
SQLite database path | /data/baktainer.db |
BT_SESSION_TIMEOUT |
Session lifetime (seconds) | 86400 |
BT_PASSWORD_MIN_LENGTH |
Minimum password length (min 8) | 12 |
BT_MAX_LOGIN_ATTEMPTS |
Failed logins before lockout | 5 |
BT_LOCKOUT_DURATION |
Lockout duration (seconds) | 900 |
OIDC / SSO
| Variable | Description | Default |
|---|---|---|
BT_OIDC_ENABLED |
Enable OIDC login | false |
BT_OIDC_ISSUER |
Issuer URL | none |
BT_OIDC_CLIENT_ID / BT_OIDC_CLIENT_SECRET |
Client credentials | none |
BT_OIDC_REDIRECT_URI |
Callback URL | none |
BT_OIDC_SCOPES |
Requested scopes | openid,profile,email |
BT_OIDC_ADMIN_GROUP |
Group/claim that grants the admin role | none |
Notifications
| Variable | Description | Default |
|---|---|---|
BT_NOTIFICATION_CHANNELS |
Comma-separated channels (log always available) |
log |
BT_NOTIFY_SUCCESS / BT_NOTIFY_FAILURES / BT_NOTIFY_WARNINGS / BT_NOTIFY_HEALTH |
Per-event toggles | false/true/true/true |
BT_SLACK_WEBHOOK_URL / BT_DISCORD_WEBHOOK_URL / BT_TEAMS_WEBHOOK_URL / BT_WEBHOOK_URL |
Channel webhook URLs | none |
Hooks
| Variable | Description | Default |
|---|---|---|
BT_HOOKS_ENABLED |
Run pre/post-backup hook scripts | true |
BT_HOOKS_DIR |
Directory containing hook scripts | /hooks |
SSL to the Docker daemon
| Variable | Description | Default |
|---|---|---|
BT_SSL |
Enable SSL for a remote Docker API | false |
BT_CA / BT_CERT / BT_KEY |
CA / client cert / client key (path or data) | none |
PostgreSQL tuning
| Variable | Description | Default |
|---|---|---|
BT_PG_LOCK_TIMEOUT |
pg_dump lock wait timeout |
60s |
BT_PG_STATEMENT_TIMEOUT |
Statement timeout (0 = disabled) | 0 |
BT_PG_VERBOSE |
Verbose pg_dump output |
false |
Cluster
See Thin-node cluster.
| Variable | Description | Default |
|---|---|---|
BT_CLUSTER_ROLE |
standalone, main, or node |
standalone |
BT_MAIN_URL |
Base URL of the main instance (required on a node) | none |
BT_NODE_NAME |
Display name for this instance | hostname |
BT_CLUSTER_JOIN_TOKEN |
Optional shared enrollment secret | none |
BT_CLUSTER_AUTO_APPROVE |
Auto-approve nodes presenting a valid join secret | false |
BT_HEARTBEAT_INTERVAL |
Node check-in / offline interval basis (seconds) | 30 |
BT_CLUSTER_POLL_IDLE |
Idle check-in cadence (seconds) | 15 |
BT_CLUSTER_POLL_ACTIVE |
Fast cadence right after commands (seconds) | 1 |
BT_CLUSTER_IDENTITY_PATH |
Node keypair identity file | /data/identity.json |
BT_CLUSTER_SPOOL_PATH |
Node event spool file | /data/cluster-spool.jsonl |
BT_MAX_TRANSFERS |
Max concurrent node→main download relays | 3 |
Usage
Dry-run validation
Validate labels and backup tooling without writing any files:
docker exec baktainer ruby app.rb --dry-run
============================================================
DRY-RUN MODE REPORT
============================================================
Discovered 3 containers for backup:
✓ mysql-prod: mysql backup
→ Would backup 'production_db' to: /backups/2026-07-28/mysql-prod-1785268200.sql
✗ redis-cache: redis backup
→ Issues found:
• Backup command 'redis-cli' not found in container
------------------------------------------------------------
Configuration validation: 2 passed, 1 failed
============================================================
Command-line options
docker exec baktainer ruby app.rb --help # Show help
docker exec baktainer ruby app.rb --now # Back up now, ignoring the schedule
docker exec baktainer ruby app.rb --dry-run # Validate configuration
docker exec baktainer ruby app.rb --import-backups # Import legacy .meta files (optional DIR)
Backup file layout
/backups/<YYYY-MM-DD>/<name>-<timestamp>.sql[.gz][.enc]
/backups/<YYYY-MM-DD>/<name>-volumes-<timestamp>.tar.gz[.enc]
<name> is baktainer.name (or the database name), <timestamp> is a Unix timestamp. Compression adds .gz; encryption adds .enc.
Web dashboard
With BT_HEALTH_SERVER_ENABLED=true, the dashboard shows live backup status grouped by compose stack, per-node tabs, running backups, and a cluster overview. Every screen supports light and dark themes.
Drill into any container or stack for its full backup history, statistics, and one-click download / restore / delete:
Health & API endpoints
| Endpoint | Description |
|---|---|
GET / |
Interactive dashboard |
GET /health |
Health check (200 healthy / 503 unhealthy) |
GET /status |
System status and metrics (JSON) |
GET /metrics |
Prometheus-format metrics |
GET /config |
Effective configuration (secrets redacted) |
GET /api/backups |
Backup history |
POST /api/backups/run |
Trigger backups |
GET /api/nodes |
Cluster node status (main only) |
Prometheus
scrape_configs:
- job_name: baktainer
metrics_path: /metrics
static_configs:
- targets: ['baktainer:8080']
Thin-node cluster
Baktainer can span many Docker hosts. One main instance runs the dashboard, database, and coordinator; each node is a headless agent that backs up its own host and reports to main. This is the model described in ADR 0001.
How it works
- Nodes dial main; main never dials a node. All communication is node-initiated, so nodes can sit behind NAT. Put TLS in front of main (a reverse proxy) — it's the only instance that needs to be reachable.
- Nodes are headless. A node runs no web UI and no database — just the scheduler, backup engine, hooks, and a small signed HTTP client. It persists only its keypair identity and a local event spool.
- Cryptographic identity + approval. On first boot a node generates an EC P-256 keypair and signs every request. Its identity is the fingerprint of its public key. An unknown node enrolls as pending and must be approved from the Nodes page (trust-on-first-use). Approve, revoke, or remove nodes at any time.
- Instant browsing, live transfers. Nodes push backup events to main in real time (buffered in a spool that replays after any outage) and reconcile a full file manifest, so browsing a node's backups is instant. Downloads and restores are relayed live through main over the node's own connection.
Each node enrolls with a key fingerprint and first-seen address; an operator approves it from the dashboard.
Once approved, every instance appears in the fleet view with live health, disk usage, and last-backup status.
Drill into any node for its status, disk, backup history, and a browsable file list with download / restore / delete.
Deploying a cluster
Main (dashboard + coordinator):
services:
baktainer-main:
image: ruby-code.com/james/baktainer:latest
restart: unless-stopped
ports:
- "8080:8080"
volumes:
- ./data:/data # SQLite: state, history, node registry
- ./backups:/backups
# - /var/run/docker.sock:/var/run/docker.sock # only if main also backs up local containers
environment:
- BT_CLUSTER_ROLE=main
- BT_NODE_NAME=main
- BT_HEALTH_SERVER_ENABLED=true
- BT_AUTH_ENABLED=true
# - BT_CLUSTER_JOIN_TOKEN=shared-secret # optional enrollment gate
Node (headless worker on another host):
services:
baktainer-node:
image: ruby-code.com/james/baktainer:latest
restart: unless-stopped
volumes:
- /var/run/docker.sock:/var/run/docker.sock:ro
- ./backups:/backups
- ./data:/data # identity.json + event spool
environment:
- BT_CLUSTER_ROLE=node
- BT_MAIN_URL=https://baktainer.example.com
- BT_NODE_NAME=edge-1
- BT_CRON=0 2 * * *
# - BT_CLUSTER_JOIN_TOKEN=shared-secret # must match main
A node has no published ports — it never accepts inbound connections. After it starts, open the main's Nodes page and approve it. To validate a cluster locally end-to-end, use the two-node harness: docker compose -f docker-compose.cluster.yml up --build (main UI on http://localhost:9080). See docs/thin-node-harness-runbook.md.
Authentication
Baktainer secures the dashboard and API with local users and/or OIDC. On first start you'll create an admin through a setup wizard.
Local users & roles
Passwords are hashed with Argon2id. Three roles are available:
| Role | Permissions |
|---|---|
admin |
Full access: manage users, delete backups, change settings |
operator |
Run and restore backups, view everything |
viewer |
Read-only dashboard and history |
Admins manage users from the Users page (or the /api/users REST endpoints).
OIDC / SSO
environment:
- BT_OIDC_ENABLED=true
- BT_OIDC_ISSUER=https://your-idp.com
- BT_OIDC_CLIENT_ID=baktainer
- BT_OIDC_CLIENT_SECRET=your-secret
- BT_OIDC_REDIRECT_URI=https://baktainer.example.com/auth/oidc/callback
- BT_OIDC_ADMIN_GROUP=baktainer-admins
To disable authentication entirely (development / trusted networks only): BT_AUTH_ENABLED=false.
Database engines
PostgreSQL / MySQL / MariaDB
labels:
- baktainer.backup=true
- baktainer.db.engine=postgres # or mysql, mariadb
- baktainer.db.name=appdb # or use baktainer.db.all=true
- baktainer.db.user=app
- baktainer.db.password=secret
SQLite
labels:
- baktainer.backup=true
- baktainer.db.engine=sqlite
- baktainer.db.name=/data/app.db # path to the database file inside the container
Redis
Backs up an RDB snapshot via redis-cli --rdb. Supports legacy password auth and ACL auth (Redis 6+), and a specific DB number (0–15).
labels:
- baktainer.backup=true
- baktainer.db.engine=redis
- baktainer.db.password=mysecret # optional
# - baktainer.db.user=myuser # optional, ACL auth
# - baktainer.db.name=3 # optional, DB number (default 0)
- baktainer.name=my-redis
MongoDB
Dumps a binary archive via mongodump --db <name> --archive. Credentials are optional (omit them for an unauthenticated instance).
labels:
- baktainer.backup=true
- baktainer.db.engine=mongodb
- baktainer.db.name=appdb
- baktainer.db.user=app # optional
- baktainer.db.password=secret # optional
- baktainer.name=my-mongo
Volume backups
Back up Docker volumes and bind mounts — on their own, or alongside a database. Mounts are auto-detected; tmpfs mounts are skipped.
Volumes only:
labels:
- baktainer.backup=true
- baktainer.db.engine=volume
- baktainer.volumes.backup=true
- baktainer.name=my-app
Database + volumes (produces a <name> database artifact and a <name>-volumes archive):
labels:
- baktainer.backup=true
- baktainer.db.engine=mysql
- baktainer.db.name=wordpress
- baktainer.db.user=wp
- baktainer.db.password=secret
- baktainer.volumes.backup=true
Notifications
environment:
- BT_NOTIFICATION_CHANNELS=slack,discord,log
- BT_SLACK_WEBHOOK_URL=https://hooks.slack.com/services/YOUR/WEBHOOK/URL
- BT_DISCORD_WEBHOOK_URL=https://discord.com/api/webhooks/...
- BT_NOTIFY_FAILURES=true
- BT_NOTIFY_SUCCESS=false
Channels: Slack, Discord, Microsoft Teams, generic webhook, and logs.
Encryption, compression & rotation
environment:
- BT_ENCRYPTION_ENABLED=true
- BT_ENCRYPTION_KEY=your-256-bit-hex-key # or BT_ENCRYPTION_KEY_FILE / BT_ENCRYPTION_PASSPHRASE
- BT_COMPRESS=true
- BT_COMPRESSION_TYPE=gzip # or zstd
- BT_ROTATION_ENABLED=true
- BT_RETENTION_DAYS=30
- BT_RETENTION_COUNT=0 # per-container cap (0 = unlimited)
- BT_MIN_FREE_SPACE_GB=10
Encrypted files use AES-256-GCM with authenticated integrity and a .enc extension. Rotation cleans up by age, per-container count, and low free disk, and prunes empty date directories.
Hooks
Place executable scripts in the hooks directory (default /hooks); Baktainer runs pre-backup.sh before and post-backup.sh after each backup.
Hooks receive context via environment variables: BAKTAINER_CONTAINER_NAME, BAKTAINER_BACKUP_FILE, BAKTAINER_BACKUP_SIZE, BAKTAINER_BACKUP_STATUS (success/failed), BAKTAINER_ENGINE, BAKTAINER_TIMESTAMP.
#!/bin/bash
# /hooks/post-backup.sh — sync successful backups to S3
if [ "$BAKTAINER_BACKUP_STATUS" = "success" ]; then
aws s3 cp "$BAKTAINER_BACKUP_FILE" "s3://my-bucket/backups/"
fi
volumes:
- ./hooks:/hooks
environment:
- BT_HOOKS_ENABLED=true
- BT_HOOKS_DIR=/hooks
Importing existing backups
If you have backups from older versions (with .meta files), import them so they appear in the dashboard with full history. The import is idempotent.
docker exec baktainer ruby app.rb --import-backups # from /backups
docker exec baktainer ruby app.rb --import-backups /path # from a specific directory
Backup history lives in the SQLite database at /data/baktainer.db — mount /data to a volume so it (and your users) persist across restarts.
Upgrading
From v0.1.x
v0.2.0 added persistent storage and authentication:
- Add a
/datavolume mount for the SQLite database. - If you customized it, rename
BT_AUTH_DB_PATH→BT_DB_PATH. - If upgrading from an auth preview, rename the DB file:
mv ./data/baktainer_auth.db ./data/baktainer.db.
Existing backup files are imported automatically on first start.
Documentation
- SECURITY.md — threat model and hardening (Docker socket, cluster trust, credentials, TLS)
- DEVELOPMENT.md — development environment, testing, and contributing
- TROUBLESHOOTING.md — diagnostics for backups, auth, and the cluster
- API_DOCUMENTATION.md — internal Ruby class reference
- CHANGELOG.md — release history
- docs/adr/0001-thin-node-cluster-architecture.md — cluster architecture decision record
Development
A complete local development stack with sample databases is included:
docker compose -f docker-compose.dev.yml up --build # dashboard on http://localhost:8080
docker compose -f docker-compose.dev.yml exec baktainer-dev ruby app.rb --dry-run
See DEVELOPMENT.md for details, and run the test suite with cd app && bundle exec rspec.
License
See LICENSE.





