runtipi-companion
A single Python CLI that extends and replaces the shell scripts scattered
across Runtipi's docs (auto-backup, VPS security, server hardening) with one
tool: runtipi-companion.
Built on top of, not instead of, runtipi-cli -- every action that
Runtipi's own CLI already does well (start/stop, app update, appstore
update) is a thin wrapper around it. What this tool adds:
- Backups to multiple rclone remotes, each with its own retention. The official auto-backup-apps script only writes to local disk with one shared retention policy. This keeps that local behavior but adds any number of rclone remotes (B2, S3, Google Drive, SFTP, ...), each syncing independently with its own daily/weekly/monthly/yearly retention.
- Restore from local disk or any configured remote, symmetric with the backup format. Every archive is verified (fully read back) right after creation, so a corrupt backup fails the backup run instead of surfacing at restore time.
- Updates for individual apps, all apps (with an exclude list), app
stores, and Runtipi core itself. By default a local pre-update snapshot
of the affected apps is taken first (
updates.backup_before), so every update is reversible viarestore run. - A config wizard (terminal form with realtime validation, or classic
prompts) that runs on first start (or via
config wizard): interviews you in the terminal and writes a validated config file. - A setup wizard for a fresh box: clone/verify the Runtipi install,
locate
runtipi-cli(it isn't on$PATHby default), runprepare/start, create backup directories, sanity-check rclone remotes. - Security hardening that applies the checklists from VPS security and Server hardening: SSH key-only + no root login, UFW, fail2ban, and an optional tailscale-only lockdown (see below). Pick what to apply from an interactive menu, or target items individually with flags.
- Tailscale install +
up, matching the VPN-only access pattern the VPS security guide recommends (don't open 80/443 publicly).security harden --tailscale-securitygoes further, based on this UFW+tailscale writeup: it switches SSH totailscale sshand restricts UFW to allow inbound traffic only on thetailscale0interface, so the box is unreachable from the public internet entirely (only tailscale's own coordination port stays open). - One global YAML config for all of the above.
Safety model
Every command that changes system state (backups stop/start containers,
security hardening edits sshd_config/firewall/installs packages, restore
overwrites app data) defaults to --dry-run, which only prints what it
would do. Add --apply to actually execute. SSH hardening additionally
refuses to disable password authentication unless it finds an existing
authorized_keys for root/the current user/$SUDO_USER (override with
--force if you know what you're doing), and validates sshd_config with
sshd -t before restarting -- rolling back automatically if the config is
broken, so you can't lock yourself out of a remote box.
Install
From PyPI:
pipx install runtipi-companion
(Or without pipx:)
python3 -m venv /opt/runtipi-companion-venv
/opt/runtipi-companion-venv/bin/pip install /path/to/runtipi-companion
sudo ln -s /opt/runtipi-companion-venv/bin/runtipi-companion /usr/local/bin/runtipi-companion
Or, for local development:
pip install -e ".[dev]"
pre-commit install # ruff lint + format on every commit
pytest
The end-to-end suite (tests/e2e/e2e.sh) exercises real rclone/tailscale
binaries and applies changes for real, so run it in the provided Docker
image rather than on your own machine:
docker build -f tests/e2e/Dockerfile -t runtipi-companion-e2e .
docker run --rm runtipi-companion-e2e
Requires rclone on $PATH for remote backups, and tailscale's installer
handles its own binary. runtipi-cli does not need to be on $PATH --
runtipi-companion looks for it at <runtipi.path>/runtipi-cli automatically
(set runtipi.cli_path in the config if yours lives elsewhere).
Managing several boxes (or provisioning unattended)? There's a thin Ansible
playbook in deploy/ansible/ that installs the package,
uploads a config, and runs the setup/hardening steps non-interactively.
Runtipi app / container
The Mistborn app store packages Companion as a backup service with an authenticated web dashboard. The dashboard shows scheduler and remote status, lists recent local archives, and can start daily, weekly, monthly, or yearly backups on demand. Restore stays CLI-only because it replaces live app data. The container schedules verified backups and transfers them through the authenticated Remote Control API of the store's Rclone Mount app, so the backup remote does not need a FUSE mount. Host setup, updates, Tailscale and security hardening remain standalone-CLI commands by design.
An API-backed remote uses the same retention model as a CLI-backed remote:
backup:
remotes:
- name: cloud
rclone_remote: encrypted:runtipi-backups
api_url: http://rclone:5533
api_username: rclone-admin
api_password_env: RCLONE_API_PASSWORD
schedules:
daily: {retention: 14}
The named password environment variable must be present at runtime. Never expose rclone's RC endpoint publicly: it provides administrative access to rclone and every configured remote.
Quickstart
On a first run (no config file anywhere), any command offers to launch the interactive config wizard: a terminal form (text fields, checkboxes, a security-preset dropdown) with realtime per-field validation -- invalid paths, ports, rclone targets, and apprise URLs are flagged as you type. It writes a validated config file on save. Start it explicitly with:
runtipi-companion config wizard # form UI
runtipi-companion config wizard --classic # question-by-question prompts
(The classic prompt flow is also used automatically when stdin/stdout isn't a terminal.)
Or go the manual route:
runtipi-companion config init --path ~/.config/runtipi-companion/config.yaml
$EDITOR ~/.config/runtipi-companion/config.yaml # set runtipi.path, add rclone remotes
Then bootstrap the system and take a first backup:
runtipi-companion setup --apply # the setup wizard (same as 'setup wizard')
runtipi-companion setup rclone --apply # install rclone + configure remotes
runtipi-companion setup services --apply # systemd timers for automated backups
runtipi-companion backup run --type daily --apply
runtipi-companion backup list jellyfin
runtipi-companion security harden # interactive: pick what to harden
runtipi-companion security harden --all --apply # or apply everything at once
runtipi-companion security harden --tailscale-security --apply # VPN-only lockdown only
runtipi-companion setup tailscale --apply
Configuration
See runtipi-companion.example.yaml for
every field with comments. Config is searched at /etc/runtipi-companion/config.yaml
then ~/.config/runtipi-companion/config.yaml, or pass --config /path.
The file carries a version: schema number. When a companion update changes
the config shape, every command prints a hint and
runtipi-companion config migrate # preview steps + diff
runtipi-companion config migrate --apply # back up original, rewrite
upgrades the file stepwise (v1 → v2 → ...). Files without version: count
as v1. A config newer than the installed companion is refused with a
pointer to self-update.
The part that matters most for the "individual retention per remote" ask:
backup:
schedules: # local disk retention
daily: { retention: 7 }
weekly: { retention: 4 }
remotes:
- name: backblaze
rclone_remote: "b2-runtipi:my-bucket/runtipi-backups"
schedules: # THIS remote's own retention, independent of local
daily: { retention: 14 }
monthly: { retention: 12 }
- name: gdrive
rclone_remote: "gdrive:runtipi-backups"
schedules:
weekly: { retention: 4 } # only syncs weekly backups, keeps 4
A remote only receives backups for schedules it explicitly lists, and prunes itself independently of local disk and every other remote.
Local backups stay flat (<backup dir>/<store>/<app>/...); on every
remote, each machine's backups sync into their own subfolder:
<remote>/<host_label>/<store>/<app>/<app>-<schedule>-<date>.tar.gz
backup.host_label defaults to the machine's hostname (the config wizard
asks about it too). Several machines can share one remote bucket: each syncs
and prunes only its own subtree, and never touches another host's backups.
Per-app backup settings
Default backup behavior is stop the container, no filtering, plain
app/app-data/user-config file copy for both backup and restore. Override
that per app under backup.app_settings.<app_id> in config.yaml, or with
runtipi-companion.backup.* docker labels on the app's own container --
config file wins field-by-field when both are set, so labels work well as a
fleet-wide default and config.yaml as the per-box exception.
| config.yaml field | docker label | meaning |
|---|---|---|
keep_running: true |
runtipi-companion.backup.keep-running=true |
Don't stop this app's container for its backups. |
exclude_patterns: [...] |
runtipi-companion.backup.exclude=<comma-separated> |
Regex list; matching archive members (e.g. app-data/.../cache/) are left out. |
pre_backup_command: "..." |
runtipi-companion.backup.pre-command=... |
Runs via docker exec into the app's own container, before the stop/archive decision (so it always has a running container to reach). A failing command fails only that app's backup -- the app is never touched, since this runs before any stop. |
post_backup_command: "..." |
runtipi-companion.backup.post-command=... |
Runs via docker exec unconditionally after the archive attempt -- success or failure, same guarantee as the app restart. For cleanup a database's own docs require regardless of outcome (see QuestDB below). |
restore_command: "..." |
runtipi-companion.backup.restore-command=... |
Runs via docker exec, after the normal file restore and after the app is started -- for apps where the raw files aren't enough (e.g. a dump needs importing). |
Use case: zero-downtime QuestDB backups, using its own checkpoint mechanism instead of stopping the container:
backup:
app_settings:
questdb:
keep_running: true
pre_backup_command: >-
curl -sf -G --data-urlencode "query=CHECKPOINT CREATE" http://localhost:9000/exec
post_backup_command: >-
curl -sf -G --data-urlencode "query=CHECKPOINT RELEASE" http://localhost:9000/exec
CHECKPOINT CREATE pauses WAL housekeeping and freezes the on-disk state
QuestDB needs backed up (db, snapshot, and any other directories under
its data root -- the default app/app-data/user-config archive already
covers that) while the database keeps serving reads and writes; the normal
archive step then captures a consistent copy. CHECKPOINT RELEASE must run
afterward no matter what -- QuestDB's own docs are explicit that it has
to fire whether the copy succeeded or failed, which is exactly why this is
post_backup_command (unconditional) and not pre_backup_command again or
a plain shell &&. No restore_command is needed here: restoring is just
putting the backed-up directories back, same as any other app.
Commands
runtipi-companion config wizard|init|show|validate|migrate # wizard: form UI, --classic for prompts
runtipi-companion backup run|list|remotes
runtipi-companion restore run|list
runtipi-companion update apps|core|appstores
runtipi-companion setup wizard|services|rclone|fail2ban|tailscale # bare 'setup' = wizard
runtipi-companion security harden|status
runtipi-companion tailscale status
runtipi-companion notify test # send a test message to every channel
runtipi-companion doctor # health audit: pass/warn/fail, changes nothing
runtipi-companion self-update # upgrade this tool from PyPI (pipx or pip)
runtipi-companion version
doctor audits the whole setup read-only -- runtipi install, docker,
backup freshness, rclone remotes, and the VPS-security checklist (sshd
effective config, UFW, fail2ban, tailscale) -- and exits non-zero if any
check fails, so you can run it from monitoring.
Run runtipi-companion <group> <command> --help for full options.
Three commands have interactive TUI modes:
backup remotes— add/edit/remove/enable/disable rclone backup remotes in your config file from a menu, including per-schedule retention. Changes are validated before saving.restore runwith no arguments — pick the source (local disk or any configured remote), then the app, then the archive from a list of what actually exists, instead of typing filenames. Defaults to a dry-run preview; add--applyto restore for real.security hardenwith no flags (or--interactive/-i) — checklist menu to pick any combination of SSH / UFW / fail2ban / tailscale-only lockdown. Flags (--ssh,--ufw,--fail2ban,--tailscale-security,--all) still work for scripting and skip the menu.
Automating backups
Either cron (matches the original guide):
sudo crontab -e
0 2 * * 2-7 /usr/local/bin/runtipi-companion backup run --type daily --apply
0 2 * * 1 /usr/local/bin/runtipi-companion backup run --type weekly --apply
0 3 1 * * /usr/local/bin/runtipi-companion backup run --type monthly --apply
Or the bundled systemd timers, which add logging via journalctl and
Persistent=true (a backup missed because the box was off runs as soon as
it's back):
runtipi-companion setup services --apply # daily,weekly,monthly by default
runtipi-companion setup services --schedules daily,weekly,monthly,yearly --apply
Notifications
Set notify.urls in the config to any number of
apprise URLs -- ntfy, Discord, Slack,
email, matrix, and ~80 other services:
notify:
urls:
- ntfy://ntfy.sh/my-topic
- discord://webhook_id/webhook_token
notify_on_success: false # failures always notify by default
notify_on_failure: true
Backup runs notify on failure (and optionally success) with proper
success/failure message types -- ntfy users get priority/color out of the
box. The old single notify.webhook_url (generic JSON POST) still works
but is deprecated.
Verify your channels actually deliver before trusting them with failure alerts:
runtipi-companion notify test
sends a test message to every configured channel and reports each result (regular runs deliberately swallow notification errors; the test command does the opposite). Exits non-zero if any channel fails.
Restore
runtipi-companion backup list # every app's latest backup (shows app ids)
runtipi-companion restore list jellyfin --remote backblaze
runtipi-companion restore run jellyfin jellyfin-daily-2026-07-01.tar.gz --from-remote backblaze --apply
Omit --from-remote to restore from the local backup directory instead.
Restore several apps at once, each from its own newest backup, with
--apps (comma-separated) or the interactive picker's multi-select:
runtipi-companion restore run --apps jellyfin,grafana,grist --apply
runtipi-companion restore run --apps jellyfin,grafana --from-remote backblaze --apply
runtipi-companion restore run # interactive: choose "several apps at once"
One app's failure doesn't cancel the rest of the batch -- failures are
reported in a summary and the command exits non-zero if any app failed.
(An app id that exists under more than one store restores whichever store's
copy is newest; use the single-app form with --store to pick a specific
one.)
Restoring another machine's remote backups (migration path): pass
--host with that machine's label alongside --from-remote, or use the
interactive picker (restore run with no arguments), which lists every host
it finds on the chosen remote. An empty remote listing also prints which
other host labels exist.
runtipi-companion backup list --remote backblaze --host old-vps
runtipi-companion restore run jellyfin jellyfin-daily-2026-07-01.tar.gz \
--from-remote backblaze --host old-vps --apply
Limitations / things to know
- The backup format is this tool's own (tar.gz of
app/app-data/user-config, same layout as the original bash script), notruntipi-cli app backup's native format -- they aren't interchangeable.runtipi-cli app backupis still exposed via theRuntipiCLIwrapper for scripting if you want it. - Remote pruning parses
rclone lsf -Routput; very unusual remote path layouts (colons or the schedule name embedded oddly in a path segment) could confuse the app-name grouping. Test with--dry-runafter your first real sync to a new remote before trusting the retention prune. - Security hardening covers the concrete steps from Runtipi's own docs (SSH keys, UFW, fail2ban). It's not a substitute for reading those docs once yourself.
pre_backup_command/post_backup_command/restore_command(per-app settings) run inside the app's container viadocker exec sh -c, with whatever privileges that container's default user has -- same trust model as everything else in this config file (you write it, it runs as-is, no sandboxing).
Ideas for later (not built yet)
Things worth considering if this becomes your daily driver:
- Backup encryption (age or gpg) before upload, since remotes are third-party cloud storage.
--jsonoutput on every command for monitoring integration (doctor's exit code already works for simple alerting).- Fleet mode: point one config at multiple Runtipi hosts (e.g. via SSH)
for centralized backup/update status across boxes. Provisioning many
boxes is already covered by the ansible playbook in
deploy/ansible/; this would add day-2 status. Already graduated from this list: pre-update snapshots, backup integrity verification,doctor,self-update, and richer notifications (apprise).
Happy to build any of these next -- say the word.
Metadata
Release files for runtipi-companion 2.1.0
For a detailed explanation of source distributions (sdists) and built distributions (wheels), please see the package formats documentation.
Source distribution (sdist)
| File | Size | Uploaded | |
|---|---|---|---|
| runtipi_companion-2.1.0.tar.gz | 109.0 kB | Details |
Built distribution (wheel)
| File | Interpreter | ABI | Platform | Reset |
|---|---|---|---|---|
| runtipi_companion-2.1.0-py3-none-any.whl | Python 3 | none | any | Details |
Total release size: 199.7 kB
Release files / runtipi_companion-2.1.0.tar.gz
| Download URL | runtipi_companion-2.1.0.tar.gz |
|---|---|
| Size | 109.0 kB |
| Tags | Source |
|
SHA-256 checksum How to use checksums |
5845efd9d7e9a17c0ef6c319548a6a427e24faca1cf62ef0cdf0280d08b1e23c
|
|
BLAKE2b-256 checksum How to use checksums |
604271d161e84d8442364b01f9d48e04be259b8694026cd01b0942518a083d24
|
| Upload date | |
|
Uploaded using Trusted Publishing? What is trusted publishing? |
Yes |
| Uploaded via |
twine/7.0.0 CPython/3.13.14
|
Provenance
Provenance describes where a file came from. On PyPI, provenance is shared via attestations, which provide a verifiable record of the build or publishing details. View details, limitations and caveats.
PyPI Publish Attestation
PyPI verified that this artifact, at this checksum, originated from the publisher listed below.
Signed by GitHub Actions, verified by PyPI on Sep 22, 2026.
Transparency logRelease files / runtipi_companion-2.1.0-py3-none-any.whl
| Download URL | runtipi_companion-2.1.0-py3-none-any.whl |
|---|---|
| Size | 90.7 kB |
| Tags | Python 3 |
|
SHA-256 checksum How to use checksums |
b190506fb8ee402133fe18cf86d082390f676183bfb41dd8e1d5d443c6d6aa23
|
|
BLAKE2b-256 checksum How to use checksums |
eca567db7fd63da6d2cf01ee85d64712dd114d5d7421fdd64f46609ec297acfe
|
| Upload date | |
|
Uploaded using Trusted Publishing? What is trusted publishing? |
Yes |
| Uploaded via |
twine/7.0.0 CPython/3.13.14
|
Provenance
Provenance describes where a file came from. On PyPI, provenance is shared via attestations, which provide a verifiable record of the build or publishing details. View details, limitations and caveats.
PyPI Publish Attestation
PyPI verified that this artifact, at this checksum, originated from the publisher listed below.
Signed by GitHub Actions, verified by PyPI on Sep 22, 2026.
Transparency log