Baki
rsync backup TUI, scheduler, CLI, and MCP server with point-in-time snapshots, tiered SquashFS retention, and WireGuard auto-join
Language: Go · View on GitHub ↗
Baki is a terminal-based frontend for managing and executing rsync backup jobs over SSH. It provides both an interactive Text User Interface (TUI) and a background service mode for scheduled and triggered backups.
Features
- Point-in-time snapshots: every run is a dated snapshot; unchanged files are hard-linked against the previous one, so history costs only the delta. A
latestsymlink always points at the newest good backup. - Tiered (GFS) retention: keep N daily snapshots; aged-out ones become weekly/monthly SquashFS archives with gzipped manifests — compressed, mountable, and still instantly listable/searchable.
- Restore anywhere: whole snapshot, one file, or a directory — from any snapshot or archive — to a local directory or pushed back to the source server over SSH.
- Search: find files by name across all snapshots and archive manifests of a job, with size and mtime.
- TUI: job management, live status with server up/down indicator, snapshot browser, in-backup file explorer, filename search, and mc-style confirmation/progress/result dialogs for every restore.
- CLI: everything is also scriptable —
add,edit,remove,list,run,status,snapshots,search,restore,setup-host,service-install— with--jsonoutput for automation and AI agents. - Server mode: cron-style scheduler (
baki server) that watchesbackup_jobs.yamlfor changes, picks up manual triggers, prevents overlapping runs, and writes a heartbeat the TUI/CLI report on. - One-command deployment:
sudo baki service-installwrites, enables, and starts a systemd unit;baki setup-hostgenerates SSH keys and prints exactly what to run on a new source machine.
Prerequisites
- Go (for building the application)
rsyncinstalled on the machine running Baki.sshclient installed on the machine running Baki.squashfs-tools(optional) — only needed for weekly/monthly archive tiers and restoring from them.- SSH access configured between the machine running Baki and the remote hosts defined in the jobs (key-based authentication is expected).
Installation
Prebuilt binaries
Tagged releases ship prebuilt binaries on the releases page for:
| File | Target |
|---|---|
baki-linux-arm64 | Raspberry Pi 3B/4/5 with a 64-bit OS, other ARM64 Linux |
baki-linux-armv7 | Raspberry Pi 2/3 with 32-bit Raspberry Pi OS (armhf) |
baki-linux-armv6 | Raspberry Pi 1 / Zero / Zero W |
baki-linux-amd64 | x86-64 Linux |
baki-darwin-arm64 / baki-darwin-amd64 | macOS (Apple Silicon / Intel) |
curl -LO https://github.com/vexdynamics/baki/releases/latest/download/baki-linux-arm64
chmod +x baki-linux-arm64 && sudo mv baki-linux-arm64 /usr/local/bin/baki
Not sure which one your Pi needs? uname -m — aarch64 → arm64, armv7l → armv7, armv6l → armv6.
Build from source
-
Clone the repository (or ensure you have the source code):
# git clone <your-repo-url> # cd <repository-directory> -
Build the binary:
go build -o baki .This will create an executable file named
bakiin the current directory.
Usage
Baki can be run in two modes: TUI (default) or Service.
TUI Mode
Run the application directly:
./baki
This will launch the interactive terminal interface.
TUI Controls:
- ↑/↓: Navigate the list of backup jobs.
- a / n: Add a new backup job (opens the form).
- e: Edit the selected backup job (opens the form).
- d: Delete the selected backup job (requires confirmation implicitly via saving).
- r: Manually trigger a run for the selected job. This creates a trigger file that the service mode (if running) will detect and execute.
- s: Browse the selected job's snapshots and archives. Enter opens a file explorer inside the highlighted snapshot/archive (archives are browsed via their manifest, no decompression);
r/Rrestores the highlighted snapshot — or, inside the explorer, the highlighted file/directory — locally / to the source server. - /: Search the selected job's backups by filename (all snapshots + archive manifests); Enter on a hit restores that single file to
/tmp/baki/. - q / Ctrl+C: Quit the application.
Form Controls (Add/Edit View):
- Tab / ↓ / Enter: Move to the next input field.
- Shift+Tab / ↑: Move to the previous input field.
- Enter (on last field): Save the job (validates required fields and schedule format).
- Esc: Cancel and return to the job list view.
CLI Mode
All job management is also available non-interactively, for scripting or driving Baki from another program:
# Prepare SSH access to a new source host: creates ~/.ssh/baki (ed25519) if
# missing and prints the exact command to authorize it on the remote machine;
# --check verifies key-based access works.
./baki setup-host --host HOST --user USER [--port 22] [--key ~/.ssh/baki] [--check]
# Add a job (required: --name --user --host --source --dest)
./baki add --name "My Server Home" --user backupuser --host server.example.com \
--source /home/user/data --dest /mnt/backups/home \
--schedule "0 6 * * *" --retention 14 [--port 22] [--key ~/.ssh/id_baki]
# Inspect jobs (--json for machine-readable output)
./baki list [--json]
./baki status [JOB] [--json]
# Change only the given flags; JOB is a job id or name
./baki edit JOB --schedule "0 3 * * *" --retention 30
# Trigger a run now (the running server picks it up within 10s)
./baki run JOB
# Remove a job (snapshots on disk are kept)
./baki remove JOB
# List restorable points in time for a job (daily snapshots + weekly/monthly archives)
./baki snapshots JOB [--json]
# Find a file across every snapshot and archive (case-insensitive substring)
./baki search JOB report.txt [--json]
# Restore — purely local, works without the server running
./baki restore JOB # latest snapshot -> /tmp/baki/<job>_<snapshot>/
./baki restore JOB 2026-07-10_06-00-01 # a specific point in time
./baki restore JOB --file docs/report.txt # a single file/dir from the latest snapshot
./baki restore JOB 2026-07-10_06-00-01 \
--file docs/report.txt --to /home/user/recovered # specific file, specific time, custom target
# Push the restore back to the job's SOURCE SERVER instead (lands in /tmp/baki/ there);
# uses the job's SSH credentials. --to overrides the remote path.
./baki restore JOB --remote
./baki restore JOB weekly_2026-W27 --file docs/report.txt --remote
Commands that write the config print a confirmation with the job id; the running server reloads the config automatically. status --json includes server_up so a caller can tell whether the scheduler is actually running.
Using Baki from AI Agents (MCP)
baki mcp serves all operations as Model Context Protocol tools over stdio, so any MCP client (Claude Code, Claude Desktop, other agents) can manage and restore backups with typed, schema-validated tools:
# Register with Claude Code (point --dir at the directory holding backup_jobs.yaml)
claude mcp add baki -- /path/to/baki mcp --dir /path/to/config-dir
Tools: baki_list_jobs, baki_job_status, baki_add_job, baki_edit_job, baki_remove_job, baki_run_job, baki_list_snapshots, baki_search_backups, baki_restore.
- Read-only tools (
list_jobs,job_status,list_snapshots,search_backups) and restores work without the baki server running;baki_run_jobonly queues a trigger — the server executes it, and the result reportsserver_upso the agent knows whether it actually will. edit_job,remove_job, andrestorecarry MCP destructive annotations, so well-behaved clients ask the human before running them.- Results include both a human-readable summary and structured JSON content.
Remote access (Streamable HTTP)
For network clients (e.g. a bot on another host), serve the same tools over Streamable HTTP instead of stdio:
baki mcp-token add my-agent --url https://your-host:8443 # create a token first
./baki mcp --transport http --addr 0.0.0.0:8443 \
--tls-cert cert.pem --tls-key key.pem --dir /path/to/config-dir
-
Endpoint:
POST https://<host>:8443/mcp(standard MCP Streamable HTTP:initialize,tools/list,tools/call; session ids and SSE handled per spec). Health check:GET /healthz→ 200, no auth. -
Bearer auth is mandatory — every
/mcprequest needsAuthorization: Bearer <token>; anything else gets 401, and the server refuses to start in http mode with no tokens configured, since these tools can trigger restores. -
Per-agent tokens — give each agent its own token instead of sharing one, so you can revoke agents individually:
baki mcp-token add discord-bot --url https://backup.example.lan:8443 # prints the token ONCE plus ready-to-paste config (endpoint, auth header, # 'claude mcp add' command, generic JSON client config) baki mcp-token list baki mcp-token remove discord-bot # takes effect immediately, no restartTokens are stored as SHA-256 hashes in
.mcp_tokens.json(0600, gitignored) next tobackup_jobs.yaml; the token file is re-read per request, so add/remove apply to a running server without a restart. -
--addrdefaults to127.0.0.1:8443; binding to all interfaces is an explicit choice (0.0.0.0:8443). -
Without
--tls-cert/--tls-keyit serves plain HTTP and logs a warning — only do that behind a TLS-terminating reverse proxy, as the token travels in the header. -
Tool names, schemas, annotations, and results are identical to the stdio transport.
Backing Up over WireGuard
If your source hosts live behind a WireGuard VPN, baki can join this machine to an existing WireGuard server automatically — the same trust model as backups (SSH):
sudo baki vpn join --server [email protected] [--iface wg0] [--local-iface wg-baki] \
[--ssh-key ~/.ssh/baki] [--ssh-port 22]
This generates a WireGuard keypair locally, reads the server's interface over SSH (wg show ... dump), allocates the next free VPN address, registers this machine as a peer (wg set + wg-quick save), writes /etc/wireguard/wg-baki.conf (0600), and enables a persistent wg-quick@wg-baki systemd service — then verifies the tunnel with a ping. From there, jobs just use WireGuard IPs:
baki add --name inner-host --user kiki --host 10.8.0.7 --key ~/.ssh/baki --source /home/kiki --dest ~/backups/inner
-
wirebard-managed servers (multi-network setups with isolated/full-proxy networks, see wirebard): let wirebard own the policy — baki asks it for a peer and receives a ready client config:
baki vpn networks --server [email protected] # what's on offer sudo baki vpn join --server [email protected] --provisioner wirebard --network backups sudo baki vpn leave --server [email protected] --provisioner wirebard --network backupsIn this mode wirebard allocates the address and decides AllowedIPs/DNS/MTU per network type; baki only generates the keypair locally (the private key never leaves the machine — configs arrive with a {{PRIVATE_KEY}} placeholder and are refused without it) and manages the local tunnel service.
-
sudo baki vpn status— interface state and last handshake. -
sudo baki vpn leave --server ...— removes the peer from the server and tears down the local tunnel. -
Requirements:
wireguard-toolson both ends; the SSH user on the server must be root or have passwordless sudo forwg/wg-quick. The tunnel survives reboots; if provisioning fails midway, the half-added peer is rolled back on the server.
Service Mode
Run the application with the server subcommand. It's recommended to run it in the background using nohup or a process manager like systemd.
nohup ./baki server &
In service mode, Baki will:
- Load jobs from
backup_jobs.yaml. - Initialize status files in
.backup_status. - Schedule jobs according to their cron expressions.
- Watch
backup_jobs.yamlfor changes and reload/reschedule automatically. - Periodically check the
.backup_triggersdirectory for trigger files (e.g.,trigger_jobname_12345) and execute the corresponding job if found and not already running. - Log activities to
backup-tui-debug.log.
To stop the service if run with nohup, find its process ID (PID) and use kill <PID>. See the next section for running with systemd.
Running as a Systemd Service
The easy way — from the directory containing backup_jobs.yaml:
sudo ./baki service-install # writes /etc/systemd/system/baki.service, enables + starts it
sudo ./baki service-remove # stops and removes it again (data/config untouched)
service-install fills in the unit automatically: it runs the service as the sudo caller (--user overrides), uses the current directory as WorkingDirectory (--dir overrides), and points ExecStart at the invoked binary. It warns if a leftover nohup server instance is still running.
Alternatively, ready-to-edit unit files are in examples/:
-
examples/baki.service— system-level service (starts at boot, runs as a dedicated user):sudo cp examples/baki.service /etc/systemd/system/baki.service # edit User/Group/WorkingDirectory/ExecStart for your setup, then: sudo systemctl daemon-reload sudo systemctl enable --now baki.service sudo journalctl -u baki.service -f # follow logs -
examples/baki.user.service— per-user service, no root needed:mkdir -p ~/.config/systemd/user cp examples/baki.user.service ~/.config/systemd/user/baki.service systemctl --user daemon-reload systemctl --user enable --now baki.service loginctl enable-linger $USER # keep it running while logged out
WorkingDirectory must be the directory containing backup_jobs.yaml; the status/trigger directories and the log file live there too.
Configuration (backup_jobs.yaml)
Jobs live in backup_jobs.yaml in the directory where you run baki (or the systemd WorkingDirectory). The easiest way to manage it is baki add/edit/remove or the TUI; to write it by hand, start from backup_jobs.yaml.example. The file is gitignored — it describes your infrastructure, so keep it out of version control.
Example:
- id: my_server_home_1678886400
name: My Server Home Backup
user: backupuser
host: server.example.com
port: "22" # Optional, defaults to 22
key_path: "~/.ssh/id_baki" # Optional, defaults to ~/.ssh/id_rsa
source: /home/user/data/ # Trailing slash is recommended for rsync directory contents
destination: /mnt/backups/my_server/home
schedule: "0 3 * * *" # Optional, cron schedule (runs daily at 3:00 AM)
retention: 14 # Optional, snapshots to keep (default 7)
- id: another_project_1678886401
name: Another Project Files
user: projectsync
host: project.internal
source: /srv/project/files/
destination: /data/backups/project_files
schedule: "0 0 * * 1" # Optional, runs weekly on Monday at midnight
Fields:
id: (Optional, auto-generated if missing) A unique identifier for the job. Used internally for status/trigger files.name: A user-friendly name for the job displayed in the TUI. Required.user: The SSH username on the remote host. Required.host: The hostname or IP address of the remote host. Required.port: (Optional) The SSH port on the remote host. Defaults to22.key_path: (Optional) The path to the SSH private key file. Supports~/expansion. Defaults to~/.ssh/id_rsa.source: The remote source directory path on the host. Required. A trailing slash (/) is significant forrsync(copies contents vs. the directory itself).destination: The local destination directory path. Required. Created if it doesn't exist. Snapshots are written into dated subdirectories here (see below).schedule: (Optional) A standard cron expression (minute hour day-of-month month day-of-week). If omitted, the job will only run via manual triggers.retention: (Optional) Number of daily snapshots to keep as plain directories. Defaults to 7.retention_weekly: (Optional) Number of weekly SquashFS archives to keep. 0 (default) disables the weekly tier.retention_monthly: (Optional) Number of monthly SquashFS archives to keep. 0 (default) disables the monthly tier.
Snapshots and Retention
Each run creates a new point-in-time snapshot instead of overwriting the previous backup:
destination/
├── 2026-07-10_06-00-01/
├── 2026-07-11_06-00-00/
├── 2026-07-12_06-00-00/
└── latest -> 2026-07-12_06-00-00
- Every run rsyncs into a new
YYYY-MM-DD_HH-MM-SSdirectory and uses--link-destagainst the previous snapshot, so unchanged files are hard links — each additional snapshot only consumes the space of what changed since the last run. latestis a symlink to the most recent successful snapshot; restoring "the current state" means copying fromlatest/.- After each successful run, snapshots beyond the job's
retentioncount (default 7) are deleted, oldest first. - A failed run's partial snapshot directory is removed automatically;
latestkeeps pointing at the last good snapshot, and the next run hard-links against it.
Tiered Retention (weekly/monthly archives)
With retention_weekly and/or retention_monthly set (requires squashfs-tools installed), aged-out daily snapshots are not simply deleted: the first snapshot of each new ISO week/month is first compressed into a SquashFS archive under destination/archive/:
destination/
├── 2026-07-06_06-00-00/ ... 2026-07-12_06-00-00/ # daily snapshots (plain dirs)
├── latest -> 2026-07-12_06-00-00
└── archive/
├── weekly_2026-W27_2026-06-29_06-00-00.sqsh
├── weekly_2026-W27_2026-06-29_06-00-00.manifest.gz
├── monthly_2026-06_2026-06-01_06-00-00.sqsh
└── monthly_2026-06_2026-06-01_06-00-00.manifest.gz
- Each archive is a compressed, mountable SquashFS image plus a gzipped manifest (file list with sizes and mtimes), so archived contents can be listed and searched instantly without decompressing.
- Archives beyond their retention counts are deleted, oldest first. If archive creation fails (e.g.
mksquashfsmissing), the snapshot directory is kept and archiving is retried on the next run — data is never deleted before its archive exists. baki search JOB PATTERNfinds files across all daily snapshots (directory walk) and all archives (manifest scan);baki restore JOB weekly_2026-W27 [--file ...]extracts from an archive viaunsquashfs.- Note: archives trade the hard-link dedup of plain snapshots for compression and single-file portability. For pure disk savings with zero workflow change, filesystem-level compression (btrfs/ZFS zstd) on the destination is a good alternative or complement.
SquashFS Cheat Sheet
Baki drives mksquashfs/unsquashfs for you, but the archives are standard SquashFS images you can work with directly (sudo pacman -S squashfs-tools on Arch, apt install squashfs-tools on Debian/Ubuntu). A=destination/archive/weekly_2026-W27_2026-06-29_06-00-00.sqsh in the examples below.
# List every file inside an archive (instant — reads the index, not the data)
unsquashfs -ll "$A"
# Search inside an archive without Baki
unsquashfs -ll "$A" | grep -i report
# Extract the whole archive to a directory
unsquashfs -d /tmp/restore "$A"
# Extract only specific files/dirs (paths as shown by -ll, relative to the root)
unsquashfs -d /tmp/restore "$A" docs/report.txt photos/2026/
# Browse an archive in place — mount it read-only, no extraction at all
sudo mount -t squashfs -o loop,ro "$A" /mnt
ls /mnt && cp /mnt/docs/report.txt ~ && sudo umount /mnt
# Show archive info: compression algorithm, block size, original vs compressed size
unsquashfs -stat "$A"
# What Baki runs to create an archive (zstd compression)
mksquashfs destination/2026-06-29_06-00-00 "$A" -comp zstd -quiet -no-progress
# Better compression at the cost of speed (if creating archives manually)
mksquashfs SRC OUT.sqsh -comp zstd -Xcompression-level 19
# Verify an archive is intact (full test extraction, then discard)
unsquashfs -d "$(mktemp -d)/check" "$A" > /dev/null && echo OK
# Read Baki's manifest without any squashfs tooling
zcat "${A%.sqsh}.manifest.gz" | less # columns: size, mtime (unix), path
Tips:
unsquashfsrefuses to overwrite an existing target directory unless you pass-f; Baki'srestorepasses it for you.- Mounting is the fastest way to poke around a big archive interactively — nothing is decompressed until you actually read a file.
- Archives are immutable. To "change" one you extract, modify, and re-create — but for backups you should never need to.
Status Files (.backup_status/)
The service mode (and TUI indirectly) reads/writes status information for each job into JSON files within the .backup_status directory (created automatically in the working directory). Each file is named job_status_<safe_job_id>.json. These files track the current status, progress (rudimentary 0.0 or 1.0), start/end times, and last error messages.
Trigger Files (.backup_triggers/)
When you select 'Run Now' ('r') in the TUI, it creates an empty file in the .backup_triggers directory (created automatically in the working directory, e.g., trigger_my_server_home_1678886400). The service mode periodically scans this directory. If it finds a trigger file for a job that isn't currently running, it executes the job and removes the trigger file.
Logging (backup-tui-debug.log)
Both TUI and service modes log detailed information, including errors, scheduling actions, rsync commands, and status updates, to backup-tui-debug.log in the application's working directory.
Dependencies
This application uses the following Go modules:
github.com/charmbracelet/bubblesgithub.com/charmbracelet/bubbleteagithub.com/charmbracelet/lipglossgithub.com/fsnotify/fsnotifygithub.com/robfig/cron/v3gopkg.in/yaml.v3
Dependencies are managed using Go modules and will be downloaded automatically during the build process.
License
This project is licensed under the MIT License.