Skip to content

Latest commit

 

History

61 Commits

Folders and files

NameName
Last commit message
Last commit date
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 

Repository files navigation

kopiaprofile

A configuration wrapper for Kopia, inspired by resticprofile. Single config file, many profiles, inheritance, hooks, locks, secret-aware logging — but for Kopia.

CI Lint Release GitHub release (latest SemVer) License: GPL-3.0

What is this?

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.

Table of contents

Install

Pre-built binary

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 version

kopia itself must be installed separately and be on $PATH (or referenced via the kopia-binary global / per-profile setting).

Linux package managers

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.)

Verifying the download

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 -c

From source

go install github.com/mogic-le/kopiaprofile@latest

Quick start

# 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/kopia

Configuration

The 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: 7

The full schema is documented in docs/configuration.md. Working examples for common setups are in examples/.

Inheritance

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.

List-merge modes

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 omitted

Templates

Any 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 }}"

Password pipeline

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/home

Hooks

Each 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.

Run timeout

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 unset

A 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.

What makes a run fail: error-handling policy

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: true

Each 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.

Retrying a snapshot that never got written

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 unset

A 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.

Locking

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.

Watching a run in progress

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.

CLI reference

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.

Passing kopia's own flags

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-existing

kopiaprofile'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

Schedules

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 install

Supported formats: crontab (default), systemd, launchd.

The cron expression parser supports the common subset: exact values, *, */N and comma-separated lists. Five fields, no seconds.

Monitoring

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: 15s

The 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|0
  • kopia_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 list

S3 Object-Lock

Kopia 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 | never

extend-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 under a retention lock

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.

Multi-Repository copy

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: 4

Run it with:

kopiaprofile home copy

kopiaprofile will:

  1. 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.
  2. Run kopia repository sync-to <target> with the flags derived from the copy: block.

Source and target may have different passwords — both are loaded independently from the secret pipeline.

Caveat: kopia 0.23

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.

Development

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).

Integration tests

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:

  1. Docker (or any OCI runtime that understands docker compose).
  2. kopia on $PATH (e.g. brew install kopia or go install github.com/kopia/kopia@latest).
  3. A free port 9000 (rustfs binds there).
docker compose -f testdata/docker-compose.rustfs.yaml up -d
make build
make integration

Release process

Releases 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.0

The release workflow (.github/workflows/release.yml) will then:

  1. Run goreleaser release --clean to build for every supported OS/arch combination.
  2. Copy the body of the new CHANGELOG.md section into the GitHub release notes.
  3. Publish tarballs, zips, .deb/.rpm packages and a SHA256SUMS file. The release starts as a draft; review it, fix typos, then click "Publish".

Commit message format

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.

License

GPL-3.0 — see the LICENSE file for the full text.

About

A configuration wrapper for Kopia, inspired by https://creativeprojects.github.io/resticprofile/. Single config file, many profiles, inheritance, hooks, locks, secret-aware logging — but for Kopia.

Resources

Stars

0 stars

Watchers

0 watching

Forks

Releases

Packages

Used by

Contributors

Languages