A configuration wrapper for Kopia, inspired by resticprofile. Single config file, many profiles, inheritance, hooks, locks, secret-aware logging — but for Kopia.
Kopia is a powerful backup tool with native S3 Object-Lock support, a web-based server UI and a well-engineered storage layer. It does not, however, ship a "one config file, many profiles" workflow that operators are used to from resticprofile.
kopiaprofile is a thin CLI shim around the kopia binary. It
reads a single configuration file, resolves inheritance, expands
templates, loads secrets, applies hooks, takes a lock, and then
invokes the right kopia subcommand for you.
It does not fork Kopia and does not embed any of Kopia's libraries —
every action is kopia <subcommand> <args>, just with the args
assembled from your config.
- Install
- Quick start
- Configuration
- CLI reference
- Examples
- Schedules
- Monitoring
- S3 Object-Lock
- Multi-Repository copy
- Development
- Release process
- License
Download the latest release for your platform from the
GitHub releases page.
Each release ships a tarball (Linux / macOS / BSD), a zip (Windows)
and .deb / .rpm packages.
Replace <VERSION> with the tag of the release you want (for
example 0.0.1) and <TARGET> with one of:
| OS | <TARGET> (tarball / zip) |
|---|---|
| Linux | Linux_x86_64, Linux_i386, Linux_arm64, Linux_armv6, Linux_armv7 |
| macOS | Darwin_x86_64, Darwin_arm64 |
| Windows | Windows_x86_64, Windows_i386, Windows_arm64 |
| FreeBSD | Freebsd_x86_64, Freebsd_i386, Freebsd_armv6, Freebsd_armv7 |
| OpenBSD | Openbsd_x86_64, Openbsd_i386, Openbsd_arm64 |
| NetBSD | Netbsd_x86_64, Netbsd_arm64, Netbsd_armv6, Netbsd_armv7 |
VERSION=0.0.1
TARGET=Linux_x86_64
curl -fsSL \
"https://github.com/mogic-le/kopiaprofile/releases/download/v${VERSION}/kopiaprofile_${VERSION}_${TARGET}.tar.gz" \
| tar -xz -C /tmp
sudo mv /tmp/kopiaprofile /usr/local/bin/
kopiaprofile versionkopia itself must be installed separately and be on $PATH (or
referenced via the kopia-binary global / per-profile setting).
Each release also produces .deb and .rpm packages that you can
install directly with your distro's package manager:
# Debian / Ubuntu (amd64)
sudo dpkg -i "https://github.com/mogic-le/kopiaprofile/releases/download/v${VERSION}/kopiaprofile_${VERSION}_linux_amd64.deb"
# Fedora / RHEL (x86_64)
sudo rpm -i "https://github.com/mogic-le/kopiaprofile/releases/download/v${VERSION}/kopiaprofile_${VERSION}_linux_amd64.rpm"(Alpine users should run the Linux x86_64 / arm64 tarball —
we don't currently ship a standalone apk package; track the
issue tracker
if you need one.)
Each release ships a kopiaprofile_<VERSION>_SHA256SUMS file
listing the SHA-256 of every artefact. Verify with:
curl -fsSL \
"https://github.com/mogic-le/kopiaprofile/releases/download/v${VERSION}/kopiaprofile_${VERSION}_SHA256SUMS" \
| sha256sum --ignore-missing -cgo install github.com/mogic-le/kopiaprofile@latest# 1. Create a skeleton config in the current directory
kopiaprofile init kopiaprofile.yaml --force
# 2. Edit it to your needs
$EDITOR kopiaprofile.yaml
# 3. Verify the config parses
kopiaprofile display home
kopiaprofile profiles list
# 4. Store the repository password in the OS keyring
kopiaprofile passwd home
# 5. Create the kopia repository
kopiaprofile home init
# 6. Run a backup (sources from the profile)
kopiaprofile home snapshot create
# 7. List snapshots (-- --json for machine-readable output)
kopiaprofile home snapshots
kopiaprofile home snapshots -- --json
# 8. Mount all snapshots to a directory
kopiaprofile home mount /mnt/kopiaThe configuration file format is auto-detected by extension. The following formats are supported:
| Extension | Format |
|---|---|
.yaml / .yml |
YAML |
.toml |
TOML |
.hcl |
HashiCorp Configuration Language |
.json |
JSON |
A minimal example:
version: "1"
profiles:
home:
initialize: true
repository:
type: s3
bucket: my-bucket
region: eu-central-1
password:
source: keyring
backup:
sources: [ /home/alice ]
retention:
keep-latest: 5
keep-daily: 7The full schema is documented in docs/configuration.md.
Working examples for common setups are in examples/.
A profile can inherit from another profile. The child wins on scalar
fields; lists follow the <list>-merge strategy (see below).
profiles:
base:
repository:
type: s3
bucket: shared-bucket
backup:
tags: [ "base" ]
home:
inherit: base
backup:
sources: [ /home/alice ]
tags-merge: append
tags: [ "home" ]Multi-level inheritance (a → b → c) is supported; cycles are
detected and rejected.
backup.sources, backup.exclude and backup.tags are lists that
the child may want to combine with the parent's list. The merge
strategy is chosen per-field via <list>-merge:
| Value | Behaviour |
|---|---|
replace |
(default) child list replaces the parent's |
append |
child list is appended to the parent's |
prepend |
child list is prepended to the parent's |
unique |
union, preserving order and removing duplicates |
backup:
sources: [ /home/alice ]
sources-merge: append
exclude: [ "*.tmp" ]
exclude-merge: unique
tags: [ "env:prod" ]
tags-merge: replace # default, may be omittedAny string value may be a Go template. The following variables are available:
| Variable | Description |
|---|---|
.Hostname |
short hostname of the machine |
.Profile.Name |
profile name |
.Profile.Description |
profile description |
.Env.X |
environment variable X |
.User.Username |
current OS user |
.User.Uid / .Gid |
numeric UID / GID |
.Now |
current time (time.Time) |
profiles:
home:
repository:
prefix: "{{ .Profile.Name }}/{{ .Hostname }}"
env:
BACKUP_TAG: "{{ .Profile.Name }}-{{ .Now.Format \"20060102\" }}"
run-after: "/usr/local/bin/notify.sh {{ .Profile.Name }}"The repository password is loaded by one of four sources, configured
via the password: block of each profile:
| Source | Configuration |
|---|---|
keyring |
OS keyring (default). Use kopiaprofile passwd <prof> to store. |
command |
runs a shell command, first line of stdout is the password |
env |
reads env: KOPIA_PASSWORD |
file |
reads a file |
password:
source: env
env: KOPIA_HOME_PASSWORD
# or
password:
source: file
file: /root/.secrets/kopia-home
# or
password:
source: command
command: pass kopia/homeEach profile can run shell commands at four lifecycle points:
| Hook | When |
|---|---|
run-before |
before kopia is invoked |
run-after |
after a successful kopia run |
run-after-fail |
after a failed kopia run (non-zero exit) |
run-finally |
always, after run-after or run-after-fail |
The hook receives a few environment variables: KOPIAPROFILE_NAME,
KOPIAPROFILE_ACTION, KOPIAPROFILE_EXIT_CODE,
KOPIAPROFILE_DURATION_NS, KOPIAPROFILE_KOPIA_EXIT_CODE.
A single kopia invocation is killed after 24 hours by default, so a wedged run cannot hold its lock forever. A very large initial snapshot can legitimately need longer than that, and being killed at the cap leaves only checkpoint snapshots behind. Raise it per profile:
profiles:
bigdata:
run-timeout: 96h # Go duration; default is 24h when unsetA malformed or non-positive value is an error rather than a silent fallback to the default, so a typo cannot quietly reinstate the 24h cap on a profile that explicitly asked for more.
By default kopia fails a snapshot when it cannot read a file or a directory, and that default is right for a filesystem that holds still. It is wrong for a live object store: such a store keeps every object in its own directory and deletes objects while the snapshot walks them, so each run reports a vanished entry as a fatal error even though the snapshot itself is complete.
profiles:
cdn:
policy:
ignore-dir-errors: false # stay strict everywhere ...
paths:
- path: /opt/minio/data # ... except in the object store
ignore-dir-errors: trueEach entry becomes a kopia policy set pre-command before the snapshot: the
global fields against --global, every path against that path. The fields are
optional; an omitted one emits no flag and leaves the repository's own value
alone, while false is forwarded, because "be strict" is a statement.
Prefer a path over the global switch. Tolerating a vanished file across the
whole host also hides the case you want to hear about. Which of the two
switches applies is decided by kopia, not by the profile: it looks at whether
the failing entry is a directory, so an object store trips ignore-dir-errors
even though the path ends in something that looks like a file. The snapshot
root itself always fails on a read error regardless of the policy, so an empty
snapshot can never be created silently.
The point of declaring this in the profile is durability: a policy set by hand
with kopia policy set lives only inside the repository, never shows up in
review, and is gone the moment the repository is recreated.
A snapshot run can fail on a transient backend error before any data is
written - an S3 backend answering a small metadata read with HTTP 200,
the right Content-Length and an empty body, for instance. Those
windows last minutes to hours, so a second attempt later usually
succeeds, and without one the host simply has no backup for that night.
profiles:
host:
retry:
attempts: 2 # total attempts including the first; 0/1 mean no retry
delay: 1h # Go duration; default 1h when unsetA repeat happens only when all three hold:
- the action is a snapshot,
- the attempt failed, and
- no snapshot reached the repository.
The last condition is what makes this narrow enough to enable by default. Kopia folds a failure of its auto-maintenance into the exit code of the snapshot, so a run whose backup is safely in the repository and whose cleanup failed afterwards also looks like a failed run - repeating it would run the same failing cleanup again and gain nothing. A run that could not get the profile lock is not repeated either: some other run is working on that repository.
The status file records attempts, and start_at stays the first
attempt's, so duration includes the waiting.
A file-based lock prevents concurrent runs of the same profile. The
default lock path is /var/lock/kopiaprofile-<profile>.lock. The
path can be overridden via lock.path; lock.force-inactive: true
ignores any stale lock.
kopiaprofile <profile> watch reports on a currently-running (or just
finished) invocation of that profile, without touching kopia or the
repository at all - it only reads the profile's lock file and a
progress log kept alongside it (a sibling of the lock file, same
directory, .progress.log instead of .lock; kopia's own stdout/
stderr is teed into it for every snapshot/prune/etc. run). Useful
for checking whether a long-running backup (large host, slow network,
initial full upload) is still making progress or appears stuck, from a
second terminal/session without interrupting the run itself:
$ kopiaprofile myhost watch
RUNNING - pid 1610423 on myhost, started 2026-07-28 09:31:23 (running 5h27m)
Progress: 42.10% 85MB/s 4.2 TB / 11 TB 812345 / 1930000 items 0 errors ETA 3:12:00
Last output (12s ago):
[5:27:03] 42.10% 85MB/s 4.2 TB / 11 TB 812345 / 1930000 items 0 errors ETA 3:12:00
If the progress log hasn't been updated in over 15 minutes while the
lock is still held, watch adds a WARNING: no new output in ... - this run may be stuck line. When nothing is running, it instead shows
NOT RUNNING plus the tail of whatever the last run printed (useful
right after a run finishes, or to see how a crashed run ended).
The Progress: line is a best-effort parse of kopia's own progress
output - kopia doesn't guarantee that format, so a version bump on the
kopia side that changes it just means watch falls back to showing
the raw tailed lines without a parsed summary, not an error.
Global flags: --config <file> (-c), --verbose (-v), --quiet.
| Command | Description |
|---|---|
kopiaprofile <profile> <action> [args] |
run kopia <action> for the given profile |
kopiaprofile init <file> [--format=…] |
generate a skeleton configuration file |
kopiaprofile profiles list |
list all profiles (post-inheritance) |
kopiaprofile display [<profile>] |
show the resolved configuration |
kopiaprofile passwd <profile> |
load and store the password in the keyring |
kopiaprofile generate --random-key |
generate a 32-byte hex-encoded random key |
kopiaprofile schedule list [--profile <name>] |
list configured schedules |
kopiaprofile schedule render [--format=…] |
render schedules to crontab/systemd/launchd |
kopiaprofile schedule install |
install the rendered schedule |
kopiaprofile monitor status [-f <file>] |
show the last run's status |
kopiaprofile monitor list |
list known status files |
kopiaprofile version |
print version information |
kopiaprofile completion <shell> |
generate shell completions |
The <action> for <profile> is forwarded to kopia. Common
actions:
| Action | Maps to kopia subcommand |
|---|---|
snapshot / snap |
kopia snapshot create <rest> |
snapshots |
kopia snapshot list --all <rest> |
restore |
kopia snapshot restore <root-id> <target> |
mount |
kopia mount all <mountpoint> |
verify |
kopia snapshot verify |
status |
kopia repository status --json |
prune |
kopia maintenance run --full |
init |
kopia repository create <type> |
connect |
kopia repository connect <type> |
copy |
kopia repository sync-to <target> (see below) |
check-index |
kopia index inspect --all |
watch |
(no kopia invocation - see "Watching a run in progress" above) |
If the action is snapshot create and you don't pass any source
paths on the command line, backup.sources from the profile is
used.
kopiaprofile parses its own flags (--config, --verbose, --quiet)
strictly, so a flag meant for kopia has to be separated with --:
# Wrong: kopiaprofile rejects the flag as its own
kopiaprofile home snapshots --json # error: unknown flag: --json
# Right: everything after -- goes to kopia untouched
kopiaprofile home snapshots -- --json
kopiaprofile home restore -- k12d1a437... /restore/target --skip-existingkopiaprofile's own diagnostics ("kopia exited with code 0 in ...") go
to stderr, so stdout carries only what kopia produced. That makes
kopiaprofile <profile> snapshots -- --json 2>/dev/null directly
pipeable into jq and usable as a backup inventory source: snapshot
manifest ID, rootEntry.obj (the root object ID needed for
restore), source paths, size and file counts, and
retentionReason.
examples/filesystem-local.yaml— minimal local-disk setupexamples/full-demo.yaml— multi-profile setup with inheritance, groups, hooks, object-lock and templatesexamples/s3-object-lock.yaml— S3 with COMPLIANCE/GOVERNANCE Object-Lockexamples/multi-repo-copy.yaml— copying a source repository into a target on every run
Each profile can declare one or more cron-style schedules. They are
rendered to your platform's scheduler by kopiaprofile schedule:
profiles:
home:
schedule:
- name: nightly
at: "0 3 * * *"
action: snapshot
- name: weekly-verify
at: "0 6 * * 0"
action: verify# preview the crontab fragment on stdout
kopiaprofile schedule render --format=crontab
# install it (auto-detects systemd on Linux, launchd on macOS, crontab otherwise)
kopiaprofile schedule installSupported formats: crontab (default), systemd, launchd.
The cron expression parser supports the common subset: exact values,
*, */N and comma-separated lists. Five fields, no seconds.
Each profile can declare a monitor: block that records the result
of every run as a JSON file and (optionally) pushes metrics to a
Prometheus push gateway:
profiles:
home:
monitor:
status-file: ~/.cache/kopiaprofile/home/status.json
push-gateway: http://pushgateway.local:9091
push-labels:
job: kopiaprofile
instance: "{{ .Hostname }}"
timeout: 15sThe status file has the following shape:
{
"profile": "home",
"action": "snapshot",
"started_at": "2026-01-15T03:00:01Z",
"ended_at": "2026-01-15T03:00:42Z",
"duration": 41000000000,
"exit_code": 0,
"ok": true,
"kopia": {
"exit_code": 0,
"stdout": "...",
"stderr": ""
},
"hooks": [
{ "phase": "before", "command": "...", "exit_code": 0, "err": null }
]
}The push-gateway payload (Prometheus text format) contains:
kopia_run{profile, action, success} 1|0kopia_run_duration_seconds{profile, action} <float>kopia_run_errors_total{profile, action, exit_code} <int>
Inspect the recorded status with:
kopiaprofile monitor status
kopiaprofile monitor listKopia supports S3 Object-Lock natively (per-blob retention) but exposes
it through maintenance settings, not via a repository create flag.
kopiaprofile surfaces it as a first-class concept:
profiles:
home:
repository:
type: s3
bucket: my-bucket
object-lock:
mode: compliance # compliance | governance | none
retention-period: 720h # informational
extend-on-maintenance: true # kopia maintenance set --extend-object-locks=<value>
full-maintenance: auto # auto | always | neverextend-on-maintenance is applied before every snapshot as
kopia maintenance set --extend-object-locks=<value>, in both
directions, so the profile stays the single source of truth: setting it
back to false disables extension on the next run instead of leaving a
stale "enabled" on the repository. Check the effective state with
kopia maintenance info (look for "Object Lock Extension").
Think before turning it on. With extension enabled, kopia renews the retention window of every blob under its locking prefixes on each full maintenance cycle - including unreferenced pack blobs that garbage collection wants to reclaim but cannot, because they are still locked. Those blobs then have their expiry pushed out again on every cycle and can never be deleted, so the repository only ever grows. Depending on your kopia version, verify that its maintenance skips reclaimable packs before enabling this on a repository you care about.
full-maintenance controls kopia's full maintenance cycle via
kopia maintenance set --enable-full=<value>, applied before every
snapshot in both directions, like extend-on-maintenance:
| Value | Behaviour |
|---|---|
auto (default) |
Disabled while the repository is younger than its own retention period, enabled from then on |
always |
Always enabled — stock kopia behaviour |
never |
Always disabled |
The point of auto is that full maintenance is pure cost until the
first blobs can expire. It walks the entire repository, marks everything
the snapshot retention has dropped as unreferenced, and then deletes
none of it, because every candidate is still locked. Observed live on a
multi-terabyte repository: snapshot garbage collection grew from five to
over twelve hours within a week, one full cycle reported
Found 2167(18.1 GB) unreferenced pack blobs to delete and deleted 0(0 B)
and a later run outlived its own timeout, was killed, and left the profile lock held long enough to block the next scheduled run. Not one byte of that work could have succeeded.
auto decides per run from the repository itself: it reads the
timestamp of the format blob and adds the retention period, which gives
the point from which blobs can start becoming deletable. Before that,
full maintenance is switched off; from then on it is switched on again
without anyone having to remember a date. Quick maintenance keeps
running throughout and takes care of epochs, index compaction and log
cleanup.
The date is deliberately not exact, and every way it is imprecise errs
in the same direction. Blobs written later expire later. Raising the
retention period after the fact moves the real date further out. And the
format blob is written at repository creation but rewritten whenever
repository parameters change, so its timestamp can be younger than the
repository - seen live on a repository whose oldest snapshot predated
its own format blob by a week, after its retention parameters had been
corrected. All three push the answer later, so auto can defer
reclaiming but never deletes anything early. If the repository's age cannot be read, the
maintenance parameters are left untouched rather than guessed at.
Be aware of the transition: the first full cycle after the window opens has a year's worth of deferred work in front of it and will be long. Plan a window for it rather than being surprised by it.
The S3 bucket must be created with Object-Lock enabled and a
DefaultRetention configured. kopiaprofile cannot do this for you
(it requires s3:PutObjectLockConfiguration on the bucket, which
Kopia itself doesn't expose). Example:
aws s3api create-bucket --bucket my-bucket --object-lock-enabled-for-bucket \
--region eu-central-1 --create-bucket-configuration LocationConstraint=eu-central-1
aws s3api put-object-lock-configuration --bucket my-bucket \
--object-lock-configuration '{
"ObjectLockEnabled": "Enabled",
"Rule": {
"DefaultRetention": {
"Mode": "COMPLIANCE",
"Days": 30
}
}
}'kopiaprofile <profile> init then runs
kopia maintenance set --extend-object-locks=true so that full
maintenance extends per-blob retention automatically.
The copy: block on a profile describes how to mirror a source
Kopia repository into the profile's own (target) repository. The
action is implemented via kopia repository sync-to:
profiles:
home:
repository:
type: s3
bucket: target-bucket
prefix: home
copy:
source:
type: s3
bucket: source-bucket
prefix: legacy
password:
source: env
env: KOPIA_SOURCE_PASSWORD
allow-overwrite: true # maps to kopia --update
parallel: 4Run it with:
kopiaprofile home copykopiaprofile will:
- Connect the source repository using its own kopia.config (under
~/.cache/kopiaprofile/<profile>-src/) and password, so the target's kopia.config is left untouched. - Run
kopia repository sync-to <target>with the flags derived from thecopy:block.
Source and target may have different passwords — both are loaded independently from the secret pipeline.
kopia repository sync-to is a BLOB-level mirror (designed for
cold-storage and DR migration), not a snapshot iterator. It transfers
content BLOBs but not the snapshot manifests. After the sync the
target will have:
- All content BLOBs (data is preserved).
- A working
kopia content list/kopia blob list. - An empty
kopia snapshot list. Restore by snapshot ID is not possible.
Full snapshot cross-repo replication requires a future Kopia version
that exposes kopia snapshot copy for cross-repository targets.
make help # list targets
make build # compile into ./kopiaprofile
make test # short unit tests
make test-ci # tests with race + coverage
make lint # golangci-lint
make fmt # gofmt + goimports
make tidy # go mod tidy
make integration # run the rustfs-backed integration test
make snapshot # goreleaser local snapshot (no publish)Tested with Go 1.25 on macOS, Linux and Windows (best-effort).
make integration runs testdata/integration-test.sh, which spins
up a local rustfs container and
exercises the full end-to-end flow against S3 (filesystem backend,
S3 backend, list-merge, multi-repo-copy, schedules, monitor). This
test is not part of CI on every PR — it takes minutes, depends
on a Docker daemon that the GitHub-hosted runners don't have on
macOS, and would burn through free Actions minutes for an
end-to-end backup you can run in 20s with make integration
locally. The CI workflow (test.yml) instead runs an
end-to-end smoke test against a
kopia filesystem backend — no Docker, no network, ~5s.
To run the full integration test locally you need:
- Docker (or any OCI runtime that understands
docker compose). kopiaon$PATH(e.g.brew install kopiaorgo install github.com/kopia/kopia@latest).- A free port 9000 (rustfs binds there).
docker compose -f testdata/docker-compose.rustfs.yaml up -d
make build
make integrationReleases are driven by [Conventional Commits][cc] (for the PR title
that goes into the release notes) and a hand-maintained
CHANGELOG.md (Keep-a-Changelog style).
[goreleaser][gr] builds and publishes the artefacts. The full
release-checklist lives in
docs/release-process.md; the short
version:
# 1. Move the "Unreleased" section of CHANGELOG.md into a new
# "## [<version>] - <date>" block, and commit it on main.
$EDITOR CHANGELOG.md
git add CHANGELOG.md && git commit -m "docs: release 0.2.0"
# 2. Tag the release.
git tag -a 0.2.0 -m "feat: schedule renderer + monitoring"
git push origin main 0.2.0The release workflow (.github/workflows/release.yml) will then:
- Run
goreleaser release --cleanto build for every supported OS/arch combination. - Copy the body of the new
CHANGELOG.mdsection into the GitHub release notes. - Publish tarballs, zips,
.deb/.rpmpackages and aSHA256SUMSfile. The release starts as a draft; review it, fix typos, then click "Publish".
feat: add multi-repo copy via kopia repository sync-to
fix(schedule): handle 5-field cron with implicit day-of-week
docs: add Multi-Repo-Copy example
refactor(wrapper): split connection-flag emission per subcommand
test(profile): add unit test for hooks
ci: bump golangci-lint to v2.12.2
Breaking changes are denoted with ! and a BREAKING CHANGE: footer.
The PR title is what lands in the release-notes header.
GPL-3.0 — see the LICENSE file for the full text.