On this page (11)
  1. Side-by-side measured table
  2. Features compared (researched)
  3. When to pick restic
  4. When to pick Borg
  5. When to pick Kopia
  6. Scheduling a backup
  7. Migration notes
  8. Test method
  9. Compose files
  10. What we did not test
  11. Related

Restic vs Borg vs Kopia on one 334 MB dataset: all three restored every file with a matching SHA-256 in all nine runs, so pick on RAM, repository size and storage targets. Pick Borg for the least RAM (about 90 MiB peak) if a machine reachable over SSH is enough as a target. Pick restic for the smallest repository (210 MB, compression on). Pick Kopia for the fastest repeat backups (0.64 s against restic’s 1.09 s on the same CPU) and an official GUI (researched). For S3-style object storage, restic and Kopia both list native backends and Borg does not (researched). Borg ran on a different CPU, so we do not rank its times. Cloud backends were not tested.

Measured readings

Test box: restic backup benchmark 0.19.1

Measured in our lab on : 3 full runs, each from a fresh stack. The numbers come from the raw result file.

This lab measures commands, not a running service. A script runs the tool inside the lab’s containers, so Docker’s idle and peak RAM for the stack would describe those containers (sleeping helpers, the file cache), not the tool. They are not shown here. The tool’s own peak memory for each step is in the measured table on this page and in the raw result file (peak_rss_mib).

App version
0.19.1
Tested
, 3 runs (compose file verified the same day)
How the figures are made
Each run starts a fresh stack and goes through setup, settling, the idle window and the load step. We publish the median over the runs and the lowest and highest value, because the lab host and some apps vary from run to run. Runs, median and range
Lab host
INTEL(R) XEON(R) PLATINUM 8573C, 2 cores, 7.8 GiB RAM, x86_64.Ubuntu 24.04.5 LTS, Docker 28.0.4.
Disk for the images
About 70 MB with the containerd image store, the default on fresh installs of Docker Engine 29 and later (our estimate: unpacked plus compressed size, plus 10%; not measured on a containerd host). About 46 MB with the older overlay2 store (the unpacked column, as measured on this lab host). Plan for the larger figure. How we size disk
What the benchmark ran
The same fixed dataset for all three tools, run by one shell script inside the tool's own container (docker exec), so the backup is in the cgroup that docker stats measures. Steps: check /data/src against the day-1 SHA-256 manifest; create an encrypted local repository on a Docker volume (init); first backup of the 334 MB; the same backup into a second repository with compression off (restic and Borg; Kopia's default is already off); apply the day-2 change set (about 5 % of the files modified, 41 added, 20 deleted) and check the source against the day-2 manifest; second backup (incremental); a third backup with nothing changed; each tool's default integrity check and its deepest one (restic check [--read-data], borg check [--verify-data], kopia snapshot verify [--verify-files-percent=100]); restore the latest snapshot into an empty directory and compare the SHA-256 of every restored file and every symlink with the manifest. Each timed step is sampled every 0.1 s for the peak resident memory (VmHWM) and CPU time of the tool's processes. Local repository only: no network, cloud, S3, B2 or SFTP.
Image sizes for restic backup benchmark
ContainerImage, compressed (download)Image, unpacked (disk, overlay2 store)
restic18 MB46 MB
Every run (3)
CPU model of each run of restic backup benchmark
RunLab host CPU
1INTEL(R) XEON(R) PLATINUM 8573C
2INTEL(R) XEON(R) PLATINUM 8573C
3INTEL(R) XEON(R) PLATINUM 8573C

Image sizes are MB (106 bytes). The unpacked column is what our lab host's Docker reported; see Disk for the images for other setups. RAM figures are MiB (220 bytes), in the article’s measured table.

Lab notes (8)
  • Host: a GitHub Actions runner (ubuntu-latest, x86_64, 2 vCPU, 8 GB RAM; the CPU model varies between runs and each run records its own, with its AES and SHA-NI flags in load.reported_by_script). Times and sizes are valid for that host only.
  • Image: restic/restic:0.19.1, an Alpine image whose entrypoint is replaced by `sleep infinity` (it is a command line tool; readers run it from cron or a systemd timer). The container's idle RAM is that of `sleep`.
  • What is measured (load.reported_by_script, one value per run; the harness does not take medians of these keys, take them over the runs by hand): `init`, `first_backup`, `first_backup_no_compression`, `incremental`, `unchanged_rerun`, `check_default`, `check_deep` and `restore`, each with `seconds` (from /proc/uptime, 0.01 s resolution), `ok`, `exit_code`, `peak_rss_mib` (highest sum of VmHWM of the tool's processes, sampled every 0.1 s; it does not include page cache) and `cpu_seconds` (user + system time of those processes, sampled the same way); the backup phases also `repo_bytes` (sum of the sizes of all files in the repository), `repo_disk_bytes` (disk blocks used, `du`), `repo_files`, `files` and `source_bytes`; `incremental` adds `repo_growth_bytes`, `changed_files`, `changed_bytes_whole_files`; `restore` has `files_verified`, `files_expected`, `sha256_match_all`, `symlinks_match`. `tool`, `version` (read from the binary), `cli_startup_seconds` (`--version`), `nproc`, `cpu_model`, `cpu_aes`, `cpu_sha_ni`. The setup step reports the dataset (`dataset`: counts, bytes, duplicate bytes, day-2 changes, delta_bytes_new_content).
  • RAM: use `peak_rss_mib` for how much memory the tool needs. The harness figures for the container (`load.total`, `peak_mem_mib`) are docker stats, which count the page cache of the files read and written by the backup that is not yet reclaimable, so they are higher; `peak_memory_stat` in each run splits the container's memory into anon and file.
  • Fairness: the same files (generated, deterministic, SHA-256 manifests made by the generator, not by a tool), the same machine, the same order of phases, a fresh container and empty volumes for every run, one tool per run. All three read the dataset from the same kind of Docker volume (page cache warm after the generator wrote it, so reads are not disk-bound) and write the repository to a Docker volume on the same disk. File mtimes are fixed so change detection (size + mtime) behaves identically in every run.
  • Not measured: remote repositories (SFTP, S3, B2, REST), network cost, prune/forget/compact/maintenance time (Kopia runs its quick maintenance automatically after snapshots, as it does by default, and `repository create` runs a first maintenance; both are inside the timed commands), mount/FUSE access, multiple clients, a repository that has grown over months, cold caches (a restart of the container deletes restic's and Borg's cache directories; this test keeps them for the whole run), file ownership and extended attributes, very large single files, parallel jobs. `check_default` is not the same depth in the three tools: restic check reads repository structure and metadata, borg check verifies repository and archive metadata, kopia snapshot verify checks that every object exists without downloading file contents; `check_deep` reads and verifies all data in all three.
  • restic 0.19.1 (Docker Hub restic/restic:0.19.1, GitHub release of 5 Jul 2026, `latest` has the same digest). Repository format version 2 (the default 'stable'), so compression is on: --compression auto, the default (zstd; data that does not compress is stored as it is). Encryption is always on in restic: AES-256 in counter mode with Poly1305-AES authentication, key derived from the password with scrypt (the cost of `init`). Chunker: content-defined, 512 KiB-8 MiB, average 1 MiB. Default parallelism (GOMAXPROCS). `first_backup_no_compression` uses --compression off on a second repository.
  • Commands (all from bench.sh): `restic init -r /repo`; `restic backup -r /repo /data/src`; `restic backup -r ALT --compression off /data/src`; `restic check -r /repo` and `restic check -r /repo --read-data`; `restic restore latest -r /repo --target /restore`. restic keeps a local cache in the container (not in the repository size).

Measured readings

Test box: BorgBackup backup benchmark 1.4.5

Measured in our lab on : 3 full runs, each from a fresh stack. The numbers come from the raw result file.

This lab measures commands, not a running service. A script runs the tool inside the lab’s containers, so Docker’s idle and peak RAM for the stack would describe those containers (sleeping helpers, the file cache), not the tool. They are not shown here. The tool’s own peak memory for each step is in the measured table on this page and in the raw result file (peak_rss_mib).

App version
1.4.5
Tested
, 3 runs (compose file verified the same day)
How the figures are made
Each run starts a fresh stack and goes through setup, settling, the idle window and the load step. We publish the median over the runs and the lowest and highest value, because the lab host and some apps vary from run to run. Runs, median and range
Lab host
AMD EPYC 9V74 80-Core Processor, 2 cores, 7.8 GiB RAM, x86_64.Ubuntu 24.04.5 LTS, Docker 28.0.4.
Disk for the images
About 179 MB with the containerd image store, the default on fresh installs of Docker Engine 29 and later (our estimate: unpacked plus compressed size, plus 10%; not measured on a containerd host). About 119 MB with the older overlay2 store (the unpacked column, as measured on this lab host). Plan for the larger figure. How we size disk
What the benchmark ran
The same fixed dataset for all three tools, run by one shell script inside the tool's own container (docker exec), so the backup is in the cgroup that docker stats measures. Steps: check /data/src against the day-1 SHA-256 manifest; create an encrypted local repository on a Docker volume (init); first backup of the 334 MB; the same backup into a second repository with compression off (restic and Borg; Kopia's default is already off); apply the day-2 change set (about 5 % of the files modified, 41 added, 20 deleted) and check the source against the day-2 manifest; second backup (incremental); a third backup with nothing changed; each tool's default integrity check and its deepest one (restic check [--read-data], borg check [--verify-data], kopia snapshot verify [--verify-files-percent=100]); restore the latest snapshot into an empty directory and compare the SHA-256 of every restored file and every symlink with the manifest. Each timed step is sampled every 0.1 s for the peak resident memory (VmHWM) and CPU time of the tool's processes. Local repository only: no network, cloud, S3, B2 or SFTP.
Image sizes for BorgBackup backup benchmark
ContainerImage, compressed (download)Image, unpacked (disk, overlay2 store)
borg43 MB119 MB
Every run (3)
CPU model of each run of BorgBackup backup benchmark
RunLab host CPU
1AMD EPYC 9V74 80-Core Processor
2AMD EPYC 9V74 80-Core Processor
3AMD EPYC 9V74 80-Core Processor

Image sizes are MB (106 bytes). The unpacked column is what our lab host's Docker reported; see Disk for the images for other setups. RAM figures are MiB (220 bytes), in the article’s measured table.

Lab notes (8)
  • Host: a GitHub Actions runner (ubuntu-latest, x86_64, 2 vCPU, 8 GB RAM; the CPU model varies between runs and each run records its own, with its AES and SHA-NI flags in load.reported_by_script). Times and sizes are valid for that host only.
  • Image: python:3.12-slim, entrypoint replaced by a short script that downloads and verifies the Borg binary, then `sleep infinity`. The container's idle RAM is that of `sleep`; its startup time includes the download (about 28 MB).
  • What is measured (load.reported_by_script, one value per run; the harness does not take medians of these keys, take them over the runs by hand): `init`, `first_backup`, `first_backup_no_compression`, `incremental`, `unchanged_rerun`, `check_default`, `check_deep` and `restore`, each with `seconds` (from /proc/uptime, 0.01 s resolution), `ok`, `exit_code`, `peak_rss_mib` (highest sum of VmHWM of the tool's processes, sampled every 0.1 s; it does not include page cache) and `cpu_seconds` (user + system time of those processes, sampled the same way); the backup phases also `repo_bytes` (sum of the sizes of all files in the repository), `repo_disk_bytes` (disk blocks used, `du`), `repo_files`, `files` and `source_bytes`; `incremental` adds `repo_growth_bytes`, `changed_files`, `changed_bytes_whole_files`; `restore` has `files_verified`, `files_expected`, `sha256_match_all`, `symlinks_match`. `tool`, `version` (read from the binary), `cli_startup_seconds` (`--version`), `nproc`, `cpu_model`, `cpu_aes`, `cpu_sha_ni`. The setup step reports the dataset (`dataset`: counts, bytes, duplicate bytes, day-2 changes, delta_bytes_new_content).
  • RAM: use `peak_rss_mib` for how much memory the tool needs. The harness figures for the container (`load.total`, `peak_mem_mib`) are docker stats, which count the page cache of the files read and written by the backup that is not yet reclaimable, so they are higher; `peak_memory_stat` in each run splits the container's memory into anon and file.
  • Fairness: the same files (generated, deterministic, SHA-256 manifests made by the generator, not by a tool), the same machine, the same order of phases, a fresh container and empty volumes for every run, one tool per run. All three read the dataset from the same kind of Docker volume (page cache warm after the generator wrote it, so reads are not disk-bound) and write the repository to a Docker volume on the same disk. File mtimes are fixed so change detection (size + mtime) behaves identically in every run.
  • Not measured: remote repositories (SFTP, S3, B2, REST), network cost, prune/forget/compact/maintenance time (Kopia runs its quick maintenance automatically after snapshots, as it does by default, and `repository create` runs a first maintenance; both are inside the timed commands), mount/FUSE access, multiple clients, a repository that has grown over months, cold caches (a restart of the container deletes restic's and Borg's cache directories; this test keeps them for the whole run), file ownership and extended attributes, very large single files, parallel jobs. `check_default` is not the same depth in the three tools: restic check reads repository structure and metadata, borg check verifies repository and archive metadata, kopia snapshot verify checks that every object exists without downloading file contents; `check_deep` reads and verifies all data in all three.
  • BorgBackup 1.4.5 (stable 1.4 series, PyPI and GitHub release of 18 Jul 2026 marked Latest; Borg 2.0 is in beta, 2.0.0b25 on 27 Sep 2026, and is not tested). There is no official image: the container is python:3.12-slim (Debian 13) with the project's standalone binary borg-linux-glibc231-x86_64 from the release page, downloaded and checked against the SHA-256 shown there (c8457f70...98fb) every time the container starts. The binary unpacks itself into /tmp on every start (`cli_startup_seconds` shows the cost); a packaged Borg does not. Peak RSS counts the processes named borg (the PyInstaller launcher and the Python process). Encryption: `borg init --encryption=repokey-blake2` (AES-256 in counter mode with an HMAC-BLAKE2b authentication tag; the key is stored in the repository, locked by BORG_PASSPHRASE). The Borg 1.4 docs say repokey is as good, and prefer repokey-blake2 only on a CPU without SHA-256 hardware, so the mode is one fixed choice (`cpu_sha_ni` in the result shows which kind of CPU the run had). Compression: Borg's default, lz4 ('Compression is lz4 by default', `borg help compression` of 1.4.5). Chunker: buzhash 19,23,21,4095 (average about 2 MiB). `first_backup_no_compression` uses --compression none on a second repository.
  • Commands (all from bench.sh): `borg init --encryption=repokey-blake2 /repo`; `borg create --stats /repo::day1 /data/src` (then ::day2, ::day2-again); `borg create --stats --compression none ALT::day1 /data/src`; `borg check /repo` and `borg check --verify-data /repo`; `cd /restore && borg extract /repo::day2-again`. Borg keeps a files cache and chunk index under ~/.cache/borg in the container (not in the repository size).

Measured readings

Test box: Kopia backup benchmark 0.23.1

Measured in our lab on : 3 full runs, each from a fresh stack. The numbers come from the raw result file.

This lab measures commands, not a running service. A script runs the tool inside the lab’s containers, so Docker’s idle and peak RAM for the stack would describe those containers (sleeping helpers, the file cache), not the tool. They are not shown here. The tool’s own peak memory for each step is in the measured table on this page and in the raw result file (peak_rss_mib).

App version
0.23.1
Tested
, 3 runs (compose file verified the same day)
How the figures are made
Each run starts a fresh stack and goes through setup, settling, the idle window and the load step. We publish the median over the runs and the lowest and highest value, because the lab host and some apps vary from run to run. Runs, median and range
Lab host
INTEL(R) XEON(R) PLATINUM 8573C, 2 cores, 7.8 GiB RAM, x86_64.Ubuntu 24.04.5 LTS, Docker 28.0.4.
Disk for the images
About 328 MB with the containerd image store, the default on fresh installs of Docker Engine 29 and later (our estimate: unpacked plus compressed size, plus 10%; not measured on a containerd host). About 219 MB with the older overlay2 store (the unpacked column, as measured on this lab host). Plan for the larger figure. How we size disk
What the benchmark ran
The same fixed dataset for all three tools, run by one shell script inside the tool's own container (docker exec), so the backup is in the cgroup that docker stats measures. Steps: check /data/src against the day-1 SHA-256 manifest; create an encrypted local repository on a Docker volume (init); first backup of the 334 MB; the same backup into a second repository with compression off (restic and Borg; Kopia's default is already off); apply the day-2 change set (about 5 % of the files modified, 41 added, 20 deleted) and check the source against the day-2 manifest; second backup (incremental); a third backup with nothing changed; each tool's default integrity check and its deepest one (restic check [--read-data], borg check [--verify-data], kopia snapshot verify [--verify-files-percent=100]); restore the latest snapshot into an empty directory and compare the SHA-256 of every restored file and every symlink with the manifest. Each timed step is sampled every 0.1 s for the peak resident memory (VmHWM) and CPU time of the tool's processes. Local repository only: no network, cloud, S3, B2 or SFTP.
Image sizes for Kopia backup benchmark
ContainerImage, compressed (download)Image, unpacked (disk, overlay2 store)
kopia79 MB219 MB
Every run (3)
CPU model of each run of Kopia backup benchmark
RunLab host CPU
1INTEL(R) XEON(R) PLATINUM 8573C
2INTEL(R) XEON(R) PLATINUM 8573C
3INTEL(R) XEON(R) PLATINUM 8573C

Image sizes are MB (106 bytes). The unpacked column is what our lab host's Docker reported; see Disk for the images for other setups. RAM figures are MiB (220 bytes), in the article’s measured table.

Lab notes (8)
  • Host: a GitHub Actions runner (ubuntu-latest, x86_64, 2 vCPU, 8 GB RAM; the CPU model varies between runs and each run records its own, with its AES and SHA-NI flags in load.reported_by_script). Times and sizes are valid for that host only.
  • Image: kopia/kopia:0.23.1 (Ubuntu 22.04 base), entrypoint replaced by `sleep infinity`. The image's own label `org.opencontainers.image.version` is '22.04', the base image, so the version is read from the binary. The container's idle RAM is that of `sleep`.
  • What is measured (load.reported_by_script, one value per run; the harness does not take medians of these keys, take them over the runs by hand): `init`, `first_backup`, `first_backup_no_compression`, `incremental`, `unchanged_rerun`, `check_default`, `check_deep` and `restore`, each with `seconds` (from /proc/uptime, 0.01 s resolution), `ok`, `exit_code`, `peak_rss_mib` (highest sum of VmHWM of the tool's processes, sampled every 0.1 s; it does not include page cache) and `cpu_seconds` (user + system time of those processes, sampled the same way); the backup phases also `repo_bytes` (sum of the sizes of all files in the repository), `repo_disk_bytes` (disk blocks used, `du`), `repo_files`, `files` and `source_bytes`; `incremental` adds `repo_growth_bytes`, `changed_files`, `changed_bytes_whole_files`; `restore` has `files_verified`, `files_expected`, `sha256_match_all`, `symlinks_match`. `tool`, `version` (read from the binary), `cli_startup_seconds` (`--version`), `nproc`, `cpu_model`, `cpu_aes`, `cpu_sha_ni`. The setup step reports the dataset (`dataset`: counts, bytes, duplicate bytes, day-2 changes, delta_bytes_new_content).
  • RAM: use `peak_rss_mib` for how much memory the tool needs. The harness figures for the container (`load.total`, `peak_mem_mib`) are docker stats, which count the page cache of the files read and written by the backup that is not yet reclaimable, so they are higher; `peak_memory_stat` in each run splits the container's memory into anon and file.
  • Fairness: the same files (generated, deterministic, SHA-256 manifests made by the generator, not by a tool), the same machine, the same order of phases, a fresh container and empty volumes for every run, one tool per run. All three read the dataset from the same kind of Docker volume (page cache warm after the generator wrote it, so reads are not disk-bound) and write the repository to a Docker volume on the same disk. File mtimes are fixed so change detection (size + mtime) behaves identically in every run.
  • Not measured: remote repositories (SFTP, S3, B2, REST), network cost, prune/forget/compact/maintenance time (Kopia runs its quick maintenance automatically after snapshots, as it does by default, and `repository create` runs a first maintenance; both are inside the timed commands), mount/FUSE access, multiple clients, a repository that has grown over months, cold caches (a restart of the container deletes restic's and Borg's cache directories; this test keeps them for the whole run), file ownership and extended attributes, very large single files, parallel jobs. `check_default` is not the same depth in the three tools: restic check reads repository structure and metadata, borg check verifies repository and archive metadata, kopia snapshot verify checks that every object exists without downloading file contents; `check_deep` reads and verifies all data in all three.
  • Kopia 0.23.1 (Docker Hub kopia/kopia:0.23.1, release of 16 Jun 2026, `latest` has the same digest; the `unstable` and date tags are nightly builds and are not used). Repository created with `kopia repository create filesystem --path /repo` and all defaults: encryption AES256-GCM-HMAC-SHA256, hash BLAKE2B-256-128, splitter DYNAMIC-4M-BUZHASH (content-defined, average 4 MiB), format version 3. Compression: NONE; Kopia's global policy has compression disabled until you set one (`kopia policy set --global --compression=zstd`), so Kopia's repository holds uncompressed data here, unlike restic (compression auto) and Borg (lz4). Do not read the repository sizes without that: `first_backup_no_compression` is therefore `null` for Kopia (its first_backup already is the no-compression figure). Default parallelism (the global policy's). The image writes debug-level log files to /app/logs by default; that is part of the measured time.
  • Commands (all from bench.sh): `kopia repository create filesystem --path /repo`; `kopia snapshot create /data/src`; `kopia snapshot verify` and `kopia snapshot verify --verify-files-percent=100`; `kopia snapshot restore <latest manifest id> /restore`. Kopia keeps a cache in /app/cache and runs its quick maintenance automatically (every hour) and a full one every 24 h when it runs as the owner; a `repository create` and the snapshots in the benchmark may trigger it.

Side-by-side measured table

Source for every row: Tested, from backup-restic.json, backup-borg.json and backup-kopia.json (runs of Oct 19, 2026), with the arithmetic in lab/notes/backup-2026-10-20.md. Each figure is the median of three runs, lowest to highest in brackets. RAM is the peak resident memory (RSS) of the tool’s own processes, sampled every 0.1 s by the benchmark script, so a spike shorter than that is missed.

Every run backed up the same generated dataset from one Docker volume to a local repository on another: 3,348 files, 334.2 MB. It holds 103 random-byte files (194.5 MB), 3,000 compressible text files (21.3 MB), exact duplicates (68.4 MB, including a copy of a 50 MB file), one near-duplicate of a 50 MB file, odd file names and two symlinks. The “day 2” change set modified 167 files (about 5%), added 41, deleted 20 and brought 13.8 MB of new content. Each tool ran in its own GitHub Actions job (2 vCPU, 7.8 GiB RAM, Ubuntu 24.04.5, Docker 28.0.4) with default settings, except that Borg makes you pick an encryption mode at init (we used repokey-blake2).

Row restic 0.19.1 Borg 1.4.5 Kopia 0.23.1
Lab host CPU (2 vCPU) Intel Xeon Platinum 8573C AMD EPYC 9V74 Intel Xeon Platinum 8573C
Compression in the default run auto (zstd) lz4 none
Start-up of --version, s 0.01 (0.01 to 0.01) 0.51 (0.51 to 0.52) 0.01 (0.01 to 0.02)
init, s 2.54 (2.45 to 2.57) 0.64 (0.63 to 0.64) 0.49 (0.48 to 0.53)
First backup, 334.2 MB, s 3.29 (3.13 to 3.38) 4.15 (3.60 to 4.29) 3.44 (3.43 to 3.52)
First backup, CPU time (user plus system), s 3.08 (3.02 to 3.11) 1.94 (1.93 to 2.02) 1.76 (1.70 to 1.76)
First backup, peak RSS, MiB 121.1 (117.5 to 126.9) 83.5 (82.4 to 90.6) 176.1 (174.6 to 176.4)
Repo after first backup, default compression, MB 210.2 (209.0 to 210.6) 221.3 (221.3 to 226.9) 236.0 (236.0 to 236.0)
Repo after first backup, compression off, MB 224.4 (223.0 to 226.1) 235.2 (230.9 to 236.2) 236.0 (its default is off)
Incremental backup (day 2), s 1.09 (1.05 to 1.25) 1.21 (1.18 to 1.32) 0.64 (0.64 to 0.64)
Incremental backup, peak RSS, MiB 105.4 (97.2 to 111.4) 89.7 (87.0 to 90.0) 168.2 (168.1 to 168.6)
Repo growth from the incremental backup, MB 16.3 (15.7 to 19.2) 23.5 (20.1 to 25.0) 21.3 (21.3 to 21.3)
Re-run with nothing changed, s 0.74 (0.74 to 0.75) 0.79 (0.79 to 0.80) 0.21 (0.21 to 0.21)
Repo growth from that re-run, bytes 397 (396 to 399) 1,462 (1,392 to 1,534) 5,409 (5,403 to 5,414)
Default integrity check, s (not the same depth) 0.72 (0.71 to 0.73) 1.64 (1.61 to 1.79) 0.21 (0.20 to 0.21)
Deepest integrity check, reads all data, s 1.14 (1.13 to 1.15) 2.34 (2.34 to 2.44) 3.97 (3.87 to 4.31)
Deepest check, peak RSS, MiB 83.6 (79.9 to 89.3) 82.4 (82.3 to 82.4) 238.8 (227.7 to 250.8)
Restore of the latest snapshot, s 1.22 (1.18 to 1.26) 2.38 (2.33 to 2.48) 1.17 (1.13 to 1.17)
Restore, peak RSS, MiB 135.8 (133.0 to 140.0) 86.7 (86.7 to 89.0) 279.8 (241.8 to 324.2)
Files verified by SHA-256 after restore, plus 2 symlinks 3,369 of 3,369, all 3 runs 3,369 of 3,369, all 3 runs 3,369 of 3,369, all 3 runs

How to read it.

  • Restores were correct everywhere. The script hashed every restored file against a manifest the dataset generator wrote (not one made by the tools) and compared both symlinks. None of the nine runs had a missing, different or unexpected file.
  • When we call a gap real. For times, run ranges must not overlap and the gap must exceed 10%, because three runs on a shared CI runner spread by up to 18% (restic’s incremental backup, 1.05 to 1.25 s). First backup (restic 3.29 s, Kopia 3.44 s, 4.6% apart) and restore (1.22 s, 1.17 s, 4.3%) are ties.
  • Large gaps, same CPU. Kopia’s incremental backup (0.64 s) took 59% of restic’s time and its unchanged re-run (0.21 s) 28% of restic’s (0.74 s). Its deepest check (3.97 s) took 3.5 times restic’s (1.14 s). restic’s init (2.54 s) took five times Kopia’s (0.49 s); we did not isolate why. On the first backup restic used 3.08 s of CPU time against Kopia’s 1.76 s in about the same wall-clock time.
  • Borg’s times (first backup 4.15 s, restore 2.38 s) are the highest of the three, partly from binary start-up and partly from the other CPU. We cannot say how much, so we do not rank Borg on time.
  • The default checks are not equals. restic’s check reads repository structure and metadata, Borg’s verifies repository and archive metadata, and Kopia’s snapshot verify checks that every object exists and reads no file contents (--verify-files-percent defaults to 0). The deepest check reads and verifies all data in all three (restic check --read-data, borg check --verify-data, kopia snapshot verify --verify-files-percent=100), so compare that row.
  • RAM. The highest median in any phase was 135.8 MiB for restic (restore), 90.2 MiB for Borg (the compression-off first backup) and 279.8 MiB for Kopia (restore, range 241.8 to 324.2); Kopia’s init alone peaked at 202.9 MiB. Borg’s figure sums every process named borg, which for the standalone binary includes its launcher. The dataset is small, and Borg’s documentation says memory use depends on the number of chunks in the repository (chunker parameters), so do not scale these figures up. We did not test a large repository.

Repo size: compression and deduplication

The default repositories are not like for like: restic compresses, Borg uses lz4, and Kopia leaves file content uncompressed until you set a policy. To take compression out, the lab ran restic and Borg a second time with compression off; Kopia’s default run already is that. What remains in those repos is chunking, deduplication and each tool’s metadata and packing overhead, which we did not separate.

Repo after the first backup restic Borg Kopia
Default compression, MB (% of the 334.2 MB source) 210.2 (62.9%) 221.3 (66.2%) 236.0 (70.6%), none applied
Compression off, MB (% of source) 224.4 (67.1%) 235.2 (70.4%) 236.0 (70.6%)
Saved by default compression, MB (% of the compression-off repo) 14.2 (6.3%) 13.9 (5.9%) none applied
  • Compression saved about 14 MB in both tools that use it. The dataset is mostly incompressible (194.5 MB of random bytes), and the 21.3 MB of text compresses about 3.15 to 1 with zlib level 6 (measured by the generator). A text-heavy dataset would show a bigger gap; we did not test one.
  • Without compression, restic’s repo was smaller. Borg’s was 4.8% larger and Kopia’s 5.2% larger; Borg and Kopia differ by 0.3% with overlapping ranges. Compression-off repos varied by 1.4% (restic) and 2.3% (Borg) between runs, and restic’s range (223.0 to 226.1 MB) does not overlap Borg’s (230.9 to 236.2), so we call restic smaller on this dataset. Chunk sizes differ (restic aims for 1 MiB blobs, Kopia printed DYNAMIC-4M-BUZHASH, Borg’s default is 19,23,21,4095), which is a candidate cause, not a finding.
  • All three stored less than the 265.8 MB of unique file content, even without compression (224.4 to 236.0 MB), so each shared chunks beyond whole-file duplicates. The near-duplicate 50 MB file is the overlap we built in; the text files may share chunks too. We did not separate the two.
  • Kopia can compress, and does compress its metadata. Its features page lists pgzip, s2 and zstd and the policy set reference has a --compression flag. We did not turn it on. In Kopia 0.23.1’s source the default policy sets file content to none and metadata to zstd-fastest, so 236.0 MB is “file content uncompressed”, not what Kopia would store with a compression policy.
  • Growth from the incremental backup was 16.3 MB (restic), 23.5 MB (Borg) and 21.3 MB (Kopia) for 13.8 MB of new content (64.4 MB if the changed and added files were taken whole). restic and Borg compressed the new data and Kopia did not, so we do not rank the three. The unchanged re-run added 397 bytes to restic’s repo, 1,462 to Borg’s and 5,409 to Kopia’s.

Features compared (researched)

We did not test this table. Each row is Researched from the project’s own documentation or source, read Oct 20, 2026.

Feature restic Borg 1.4 Kopia Sources
Storage backends Local, SFTP, REST server, S3 and S3-compatible, Swift, B2, Azure, Google Cloud Storage, rclone Local path, or any host reachable over SSH S3 and S3-compatible, Azure, B2, Google Cloud Storage, WebDAV, SFTP, local or network storage, a Kopia repository server, rclone (documented as experimental) restic, Borg, Kopia
Backblaze B2 Native b2: backend; the docs recommend B2’s S3-compatible API instead Not listed (SSH only) Native B2 engine; object locks on B2 need the S3 engine restic, Kopia
Encryption Always on; AES-256 in counter mode with Poly1305-AES Chosen at init (-e is required); none exists and the docs say to avoid it. Our run: repokey-blake2 Always on (“does not allow creating unencrypted backups”); AES-256 or ChaCha20. Our run: AES256-GCM-HMAC-SHA256 restic, Borg, Kopia
Compression Repository format 2 compresses new data; --compression takes auto, off, fastest, better or max, and auto is the default none, lz4 (default), zstd, zlib, lzma, auto and obfuscate modes pgzip, s2, zstd among others, set by policy; file content uncompressed by default restic, restic source, Borg, Kopia
Graphical interface No official GUI in the docs we read No official GUI in the docs we read Official GUI, KopiaUI (Windows, macOS, Linux) Kopia
Server mode rest-server, a separate project, serves repositories borg serve runs on the remote end of an SSH connection Optional repository server with an API; each user sees only their own snapshots and policies Borg, Kopia
Append-only rest-server --append-only allows new backups and prevents deleting or modifying existing ones borg init --append-only or borg serve --append-only; the docs say it only protects the low-level repository structure, and prune and delete are still allowed No append-only switch on the pages we read; the ransomware page describes keys without delete permission and object locks on S3, Azure and Google rest-server, Borg, Kopia
Operating systems Linux, macOS, Windows, FreeBSD, OpenBSD Binaries for Linux, macOS, FreeBSD; Windows only through WSL or Cygwin, both experimental Windows, macOS, Linux (amd64, armhf, arm64); FreeBSD and OpenBSD through ports restic, Borg, Kopia
License BSD 2-Clause BSD 3-Clause Apache 2.0 restic, Borg, Kopia

Not tested in our lab: every row above, apart from the encryption and compression settings our runs used, which the result files record. We never talked to a cloud backend, an SFTP server, a REST server or Kopia’s repository server, and we did not start KopiaUI.

When to pick restic

  • You want the smallest repo without changing settings. 210.2 MB against 221.3 MB (Borg) and 236.0 MB (Kopia) with default compression; 224.4 against 235.2 and 236.0 MB with it off.
  • Restores and full integrity checks matter. On the same CPU its restore (1.22 s) tied Kopia’s (1.17 s), and its deepest check (1.14 s) took under a third of Kopia’s (3.97 s).
  • You store backups in object storage or on several kinds of target (researched, not tested). It has native backends for S3-compatible storage, B2, Azure, Google Cloud Storage, SFTP, Swift and a REST server. Kopia lists most of the same targets.
  • You want one binary on Windows, macOS and Linux, or append-only through rest-server (researched, not tested).

What it cost in our runs: 121.1 MiB peak RSS for the first backup and 135.8 MiB for the restore (Borg: 83.5 and 86.7), and the slowest init of the three (2.54 s).

When to pick Borg

  • RAM is the limit. Peak RSS was 83.5 MiB for the first backup and about 90 MiB in the highest phase, against 121 to 136 MiB (restic) and 176 to 280 MiB (Kopia). RAM carries across hosts better than time, so this is the Borg result we trust most. It is a small dataset; see the note on chunk counts above.
  • Your target is a machine you reach over SSH. Borg’s remote repositories are SSH only in the documentation we read, and borg serve --append-only is documented for stopping a compromised client from permanently deleting backups, within the limits the docs state (researched, not tested).
  • You run Linux, macOS or FreeBSD. On Windows it is WSL or Cygwin, both experimental.
  • You can accept an unranked time. Its first backup (4.15 s) and restore (2.38 s) were the slowest of the three, but on another CPU and with binary start-up in each command, so we cannot say how a packaged Borg on the same CPU compares.

We tested the 1.4 series. Borg 2.0 was still a beta on Oct 19, 2026 (2.0.0b25, uploaded Sep 27, 2026, per the PyPI release history), and we did not test it.

When to pick Kopia

  • You back up often and most files are unchanged. On the same CPU as restic its incremental backup took 0.64 s against 1.09 s, its unchanged re-run 0.21 s against 0.74 s, and its first backup used less CPU time (1.76 s against 3.08 s).
  • You want a GUI, a server for several machines, policies, snapshot mounting or error correction (researched, not tested; our runs used the command line only).
  • You accept more RAM. 176.1 MiB for the first backup, 279.8 MiB for the restore (range 241.8 to 324.2) and 202.9 MiB just to create the repository.
  • You will switch compression on. We did not measure a compression policy.

What it cost in our runs: its deepest check was the slowest of the three (3.97 s against restic’s 1.14 s on the same CPU), because it reads and verifies all data.

Scheduling a backup

All three are commands, so scheduling means cron or a systemd timer. The lab ran each tool by hand from one script, so nothing in this section was run in our lab, and none of it covers retention (restic forget, Borg prune, Kopia policies), which we also did not test.

A restic service, /etc/systemd/system/restic-backup.service, and its environment file /etc/restic/restic.env (readable by root only), with the repository on another disk.

Not run in our lab.

ini
[Unit]
Description=restic backup
Wants=network-online.target
After=network-online.target

[Service]
Type=oneshot
EnvironmentFile=/etc/restic/restic.env
ExecStart=/usr/bin/restic backup /srv/data

Not run in our lab.

ini
RESTIC_REPOSITORY=/mnt/backup-disk/restic-repo
RESTIC_PASSWORD_FILE=/etc/restic/password

The timer, /etc/systemd/system/restic-backup.timer, and the commands to enable it.

Not run in our lab.

ini
[Unit]
Description=Run restic backup every night

[Timer]
OnCalendar=*-*-* 03:30:00
RandomizedDelaySec=15m
Persistent=true

[Install]
WantedBy=timers.target

Not run in our lab.

bash
sudo systemctl daemon-reload
sudo systemctl enable --now restic-backup.timer

For Borg or Kopia, keep the unit and timer and change the ExecStart line and the environment file. Not run in our lab: for Borg, ExecStart=/usr/bin/borg create --stats ::{hostname}-{now} /srv/data with BORG_REPO and BORG_PASSCOMMAND in the environment file. For Kopia, run kopia repository connect once as the same user, then ExecStart=/usr/bin/kopia snapshot create /srv/data with KOPIA_PASSWORD in the environment file. KopiaUI can also schedule snapshots through its policies (researched, installation page); we did not test that.

Migration notes

Not run in our lab: we did not move backups between tools or look for converters. The three repository formats are separate and each tool reads only its own. The safe pattern is to run the new tool next to the old one, keep the old repository for as long as your retention needs it, and restore from the new repository before you delete the old one. That is our advice, not a tested procedure. The one habit the lab did verify is the restore check against an independent manifest; our Vaultwarden restore drill shows it for one app.

Test method

One script, three tools. A Python generator (standard library, seed 20261019) wrote the dataset and the SHA-256 manifests for both days. One shell script ran inside each tool’s own container: check the source against the day-1 manifest, init, first backup, the same backup into a second repository with compression off (restic and Borg), apply the day-2 change set and check the source against the day-2 manifest, incremental backup, a third backup with nothing changed, the default and deepest checks, then a restore of the latest snapshot into an empty directory with a SHA-256 comparison of every file. Every run used fresh containers and volumes, file modification times were fixed so change detection behaves the same, and the dataset sat in a warm page cache, so reads were not bound by the disk. Local repositories only.

Settings. Defaults, except Borg’s required encryption mode. restic 0.19.1 used repository format 2 (compression auto), Borg 1.4.5 used repokey-blake2 with lz4, and Kopia 0.23.1 used AES256-GCM-HMAC-SHA256, BLAKE2B-256-128 and no file-content compression. The lab notes in the result files say that Kopia runs its automatic maintenance by default and that its image writes debug logs by default, and that both fall inside the timed commands. We did not measure how much time they take.

The commands the lab ran, in order. restic:

bash
restic init -r /repo
restic backup -r /repo /data/src
restic init -r /tmp/bench/repo-no-compression
restic backup -r /tmp/bench/repo-no-compression --compression off /data/src
restic backup -r /repo /data/src
restic backup -r /repo /data/src
restic check -r /repo
restic check -r /repo --read-data
restic restore latest -r /repo --target /restore

Borg:

bash
borg init --encryption=repokey-blake2 /repo
borg create --stats /repo::day1 /data/src
borg init --encryption=repokey-blake2 /tmp/bench/repo-no-compression
borg create --stats --compression none /tmp/bench/repo-no-compression::day1 /data/src
borg create --stats /repo::day2 /data/src
borg create --stats /repo::day2-again /data/src
borg check /repo
borg check --verify-data /repo
cd /restore
borg extract /repo::day2-again

Kopia:

bash
kopia repository create filesystem --path /repo
kopia snapshot create /data/src
kopia snapshot create /data/src
kopia snapshot create /data/src
kopia snapshot verify
kopia snapshot verify --verify-files-percent=100
kid=$(kopia snapshot list /data/src --manifest-id --show-identical | grep 'manifest:' | tail -n 1 | sed -n 's/.*manifest:\([0-9a-f]*\).*/\1/p')
kopia snapshot restore "$kid" /restore

The day-2 change set was applied after the first backup (and the compression-off one) and before the second. The kid line reads the newest snapshot’s manifest ID from the list, which kopia snapshot restore accepts as its source. Passwords came from RESTIC_PASSWORD, BORG_PASSPHRASE and KOPIA_PASSWORD in each container’s environment.

Compose files

The three files below are the ones the lab ran. Most people run these tools as binaries on the host, and none is a service. The containers exist so the numbers on this page can be reproduced with nothing but Docker: each starts, then sleeps, and the lab ran the benchmark inside it with docker exec. Do not treat them as a recommended way to run backups. The restic and Kopia files use the official images with the entrypoint replaced by sleep infinity. Borg has no official image, so its file starts a plain Python image and downloads the 1.4.5 standalone binary, checking it against the SHA-256 on the release page; the GPG signature is not checked.

Each file reads its password from a .env file next to it, and Compose refuses to start without one. The lab’s passwords are public, so the published .env files have the line commented out: remove the # and set your own long value.

compose.yamlVerified 2026-10-19 · restic backup benchmark 0.19.1Download compose.yaml for restic backup benchmark
# Verified compose for restic backup benchmark, tested 2026-10-19 by the SelfHostBench lab (3 runs; the figures are the median).
# Lab host: INTEL(R) XEON(R) PLATINUM 8573C, 2 vCPU, 7.8 GiB RAM, x86_64, Docker 28.0.4.
# Floating image tags below were replaced by the version resolved at test time;
# the registry digest is in the comment on each image line.
# Measurements: backup-restic.json (schema 3).
# Companion env file: backup-restic.env (save it as .env next to this file).
# Secrets are not published: the lab's values are public. In backup-restic.env:
#   RESTIC_PASSWORD is commented out. Uncomment it and set your own value (the comment above it explains how).

# restic 0.19.1: a backup program for the command line. This is the container the SelfHostBench backup benchmark runs restic in, with a local repository on a Docker volume.
# restic is a command, not a service: there is nothing to keep running. The container only stays up (sleep) so that you can run `docker exec restic restic ...`, or point a cron job or
# a systemd timer at it. Most people run restic straight on the host (apt/dnf install restic, or the single binary from github.com/restic/restic/releases) from a timer; this file exists so
# that the numbers on the site can be reproduced with nothing but Docker.
#
# Follows the official image's documentation (restic/restic on Docker Hub, restic.readthedocs.io). Differences, all marked below: a pinned tag, the entrypoint replaced by `sleep infinity`,
# the repository password from .env, and three named volumes (your data, the repository, a place to restore to).
#
# Quick start (after putting a long random RESTIC_PASSWORD in .env):
#   docker compose up -d
#   docker compose exec restic restic init                    # once; creates the repository in /repo (repository format v2, encrypted)
#   docker compose exec restic restic backup /data/src        # first backup, then again whenever you like: only changes are stored
#   docker compose exec restic restic restore latest --target /restore
# RESTIC_REPOSITORY and RESTIC_PASSWORD below are what `restic` reads when you give no -r flag.
# Local repository only. A repository on the same disk as your data is not a backup: point RESTIC_REPOSITORY at another disk, another machine (sftp:, rest:) or object storage (s3:, b2:).
name: restic

services:
  restic:
    image: restic/restic:0.19.1  # sha256:136600b6ff6843d61d355f7f71f460a166429f35de6fd11b568fece3c9a4d510  (version 0.19.1, from exec)
    # The image's entrypoint is `restic` itself. Replaced here so the container stays up; remove this line to run `docker run --rm restic/restic ...` one command at a time.
    entrypoint: ["sleep", "infinity"]
    # restic records the host name in every snapshot and uses it, with the path, to find the previous snapshot (that is how a later backup skips unchanged files).
    # A fixed name keeps that stable when the container is recreated.
    hostname: backup-host
    restart: unless-stopped
    environment:
      RESTIC_REPOSITORY: /repo
      # Encrypts the repository. Set in .env. If you lose it the backups cannot be opened: store it somewhere that is NOT the backed-up machine (a password manager).
      RESTIC_PASSWORD: "${RESTIC_PASSWORD:?set RESTIC_PASSWORD in .env}"
    volumes:
      # What to back up. In real use mount your own data read-only instead, for example `- /srv/photos:/data/photos:ro`. (The lab test changes files in this volume to simulate a second day.)
      - data:/data
      # The repository. In real use this should be on another disk or machine.
      - repo:/repo
      # Where `restic restore` writes.
      - restore:/restore
    healthcheck:
      # restic has no server, so this only checks that the binary is there; the container is "healthy" a few seconds after the start.
      test: ["CMD", "test", "-x", "/usr/bin/restic"]
      interval: 5s
      timeout: 3s
      start_period: 2s
      retries: 3

volumes:
  data:
  repo:
  restore:
.envVerified 2026-10-19 · restic backup benchmark 0.19.1Download .env for restic backup benchmark
# The password of the restic repository. Choose your own, long: it encrypts every backup, and without it the backups cannot be opened.
# A placeholder would become a real, known password, so the lab's value is not published and this line is commented out.
# Generate one with:  openssl rand -hex 24
# RESTIC_PASSWORD=
compose.yamlVerified 2026-10-19 · BorgBackup backup benchmark 1.4.5Download compose.yaml for BorgBackup backup benchmark
# Verified compose for BorgBackup backup benchmark, tested 2026-10-19 by the SelfHostBench lab (3 runs; the figures are the median).
# Lab host: AMD EPYC 9V74 80-Core Processor, 2 vCPU, 7.8 GiB RAM, x86_64, Docker 28.0.4.
# Floating image tags below were replaced by the version resolved at test time;
# the registry digest is in the comment on each image line.
# Measurements: backup-borg.json (schema 3).
# Companion env file: backup-borg.env (save it as .env next to this file).
# Secrets are not published: the lab's values are public. In backup-borg.env:
#   BORG_PASSPHRASE is commented out. Uncomment it and set your own value (the comment above it explains how).

# BorgBackup 1.4.5 (stable 1.4 series; Borg 2.0 is still in beta and is not used): a deduplicating backup program for the command line. This is the container the SelfHostBench backup
# benchmark runs Borg in, with a local repository on a Docker volume. Borg is a command, not a service: the container only stays up (sleep) so that you can run `docker exec borg borg ...`,
# or point a cron job or a systemd timer at it. Most people run Borg straight on the host (apt install borgbackup, or the standalone binary) from a timer, or through borgmatic.
#
# There is no official Borg image. This file starts a plain Debian-based image (python:3.12-slim, glibc 2.41, used here only because it has Python to do the download below) and puts the
# project's own standalone binary on it: borg-linux-glibc231-x86_64 from the 1.4.5 release on github.com/borgbackup/borg (assets of release 1.4.5, 18 Jul 2026). The download is checked
# against the SHA-256 the release page shows for that file before it is made executable (the project also publishes GPG signatures, .asc files, that this file does not check). For an arm64
# machine use borg-linux-glibc235-arm64-gh and its checksum. A packaged Borg (apt, dnf, apk) is the usual alternative and starts a little faster: the standalone binary unpacks itself
# into /tmp every time it starts.
#
# Quick start (after putting a long random BORG_PASSPHRASE in .env):
#   docker compose up -d
#   docker compose exec borg borg init --encryption=repokey-blake2 /repo        # once; the key is stored in the repository, locked with BORG_PASSPHRASE
#   docker compose exec borg borg create --stats /repo::day1 /data/src          # then a new archive name each time (add {now} for a time stamp): only changes are stored
#   docker compose exec borg sh -c 'cd /restore && borg extract /repo::day1'
# Back up the key too (`borg key export /repo`): with repokey modes it is inside the repository, so if the repository is damaged and you lose the passphrase, there is no way back.
# Local repository only. A repository on the same disk as your data is not a backup: use another disk, another machine over ssh (ssh://user@host/path) or a Borg hosting service.
name: borg

services:
  borg:
    image: python:3.12-slim  # sha256:a6e34c598f2467ed0e9a8d349809fcd8b5c603269512df273a0bb1784edc11b1  (version 1.4.5, from exec)
    entrypoint:
      - sh
      - -c
      - |
        set -e
        python3 - <<'PY'
        import hashlib, os, urllib.request
        url = "https://github.com/borgbackup/borg/releases/download/1.4.5/borg-linux-glibc231-x86_64"
        want = "c8457f70660064d0f45b38283ab4cc65b342970012013201d3aec713a75898fb"
        data = urllib.request.urlopen(url, timeout=120).read()
        if hashlib.sha256(data).hexdigest() != want:
            raise SystemExit("borg download: sha256 does not match")
        with open("/usr/local/bin/borg.part", "wb") as fh:
            fh.write(data)
        os.chmod("/usr/local/bin/borg.part", 0o755)
        os.rename("/usr/local/bin/borg.part", "/usr/local/bin/borg")
        PY
        exec sleep infinity
    # Borg records the host name in every archive. A fixed name keeps the history together when the container is recreated.
    hostname: backup-host
    restart: unless-stopped
    environment:
      # Unlocks the repository key. Set in .env. If you lose it the backups cannot be opened: store it somewhere that is NOT the backed-up machine (a password manager).
      BORG_PASSPHRASE: "${BORG_PASSPHRASE:?set BORG_PASSPHRASE in .env}"
    volumes:
      # What to back up. In real use mount your own data read-only instead, for example `- /srv/photos:/data/photos:ro`. (The lab test changes files in this volume to simulate a second day.)
      - data:/data
      # The repository. In real use this should be on another disk or machine.
      - repo:/repo
      # Where `borg extract` writes.
      - restore:/restore
    healthcheck:
      # Healthy once the download above has been checked and the binary is in place.
      test: ["CMD", "test", "-x", "/usr/local/bin/borg"]
      interval: 5s
      timeout: 3s
      start_period: 2s
      retries: 60

volumes:
  data:
  repo:
  restore:
.envVerified 2026-10-19 · BorgBackup backup benchmark 1.4.5Download .env for BorgBackup backup benchmark
# The passphrase that locks the Borg repository key. Choose your own, long: without it the backups cannot be opened.
# A placeholder would become a real, known passphrase, so the lab's value is not published and this line is commented out.
# Generate one with:  openssl rand -hex 24
# BORG_PASSPHRASE=
compose.yamlVerified 2026-10-19 · Kopia backup benchmark 0.23.1Download compose.yaml for Kopia backup benchmark
# Verified compose for Kopia backup benchmark, tested 2026-10-19 by the SelfHostBench lab (3 runs; the figures are the median).
# Lab host: INTEL(R) XEON(R) PLATINUM 8573C, 2 vCPU, 7.8 GiB RAM, x86_64, Docker 28.0.4.
# Floating image tags below were replaced by the version resolved at test time;
# the registry digest is in the comment on each image line.
# Measurements: backup-kopia.json (schema 3).
# Companion env file: backup-kopia.env (save it as .env next to this file).
# Secrets are not published: the lab's values are public. In backup-kopia.env:
#   KOPIA_PASSWORD is commented out. Uncomment it and set your own value (the comment above it explains how).

# Kopia 0.23.1: a backup program with a command line and a web interface. This is the container the SelfHostBench backup benchmark runs Kopia in, with a local repository on a Docker volume.
# The benchmark uses the command line (`kopia snapshot create`), not `kopia server`. The container only stays up (sleep) so that you can run `docker exec kopia kopia ...`, or point a cron job or a
# systemd timer at it. Kopia can also run its own scheduler (`kopia server start`, see kopia.io/docs/installation/#docker-images); that is not what is measured here.
#
# Follows the official image's documentation (kopia.io/docs/installation/#docker-images): the image, the three directories /app/config, /app/cache and /app/logs, KOPIA_PASSWORD. Differences,
# all marked below: a pinned tag, the entrypoint replaced by `sleep infinity`, named volumes instead of ./paths, the password from .env.
#
# Quick start (after putting a long random KOPIA_PASSWORD in .env):
#   docker compose up -d
#   docker compose exec kopia kopia repository create filesystem --path /repo     # once; AES256-GCM-HMAC-SHA256, no compression unless you set a policy
#   docker compose exec kopia kopia snapshot create /data/src                     # then again whenever you like: only changes are stored
#   docker compose exec kopia kopia snapshot list /data/src --manifest-id         # the manifest id is what you give to restore
#   docker compose exec kopia kopia snapshot restore <manifest id> /restore
# Local repository only. A repository on the same disk as your data is not a backup: use another disk, another machine (sftp, webdav, rclone) or object storage (s3, b2, gcs, azure).
name: kopia

services:
  kopia:
    image: kopia/kopia:0.23.1  # sha256:89fd95ee2942880ca00eae964266958a394421ddbdf69bca62e38afc55f5900e  (version 0.23.1, from exec)
    # The image's entrypoint is `kopia` itself. Replaced here so the container stays up; remove this line to run `docker run --rm kopia/kopia ...` one command at a time.
    entrypoint: ["sleep", "infinity"]
    # Kopia identifies a snapshot source as user@host:/path. A fixed host name keeps the history of a source together when the container is recreated.
    hostname: backup-host
    restart: unless-stopped
    environment:
      # Encrypts the repository. Set in .env. If you lose it the backups cannot be opened: store it somewhere that is NOT the backed-up machine (a password manager).
      KOPIA_PASSWORD: "${KOPIA_PASSWORD:?set KOPIA_PASSWORD in .env}"
      # The image already sets KOPIA_CONFIG_PATH=/app/config/repository.config, KOPIA_CACHE_DIRECTORY=/app/cache, KOPIA_LOG_DIR=/app/logs and KOPIA_CHECK_FOR_UPDATES=false.
    volumes:
      # What to back up. In real use mount your own data read-only instead, for example `- /srv/photos:/data/photos:ro`. (The lab test changes files in this volume to simulate a second day.)
      - data:/data
      # The repository. In real use this should be on another disk or machine.
      - repo:/repo
      # Where `kopia snapshot restore` writes.
      - restore:/restore
      # The connection to the repository (which repository, and the settings), the local cache and the logs, as in the official documentation.
      - kopia-config:/app/config
      - kopia-cache:/app/cache
      - kopia-logs:/app/logs
    healthcheck:
      # Kopia is a command, not a service, so this only checks that the binary is there; the container is "healthy" a few seconds after the start.
      test: ["CMD", "test", "-x", "/bin/kopia"]
      interval: 5s
      timeout: 3s
      start_period: 2s
      retries: 3

volumes:
  data:
  repo:
  restore:
  kopia-config:
  kopia-cache:
  kopia-logs:
.envVerified 2026-10-19 · Kopia backup benchmark 0.23.1Download .env for Kopia backup benchmark
# The password of the Kopia repository. Choose your own, long: it encrypts every backup, and without it the backups cannot be opened.
# A placeholder would become a real, known password, so the lab's value is not published and this line is commented out.
# Generate one with:  openssl rand -hex 24
# KOPIA_PASSWORD=

What we did not test

  • Cloud and remote backends (B2, other S3 services, SFTP, REST servers). The repositories were local Docker volumes on the same disk as the source, so we have no figure for upload speed, requests, retries or egress.
  • Prune, forget, compact and maintenance. No timing for deleting old snapshots in any tool.
  • Scale. One 334 MB dataset of 3,348 files and three snapshots per repository. Hundreds of gigabytes, millions of files or a year of snapshots are unknown.
  • Other platforms. One x86-64 Linux runner type; no Windows, macOS or Arm.
  • Borg 2.0 (beta) and a packaged Borg instead of the standalone binary.
  • Server features and GUIs: restic’s REST server, borg serve, Kopia’s repository server and KopiaUI.
  • Cold caches. The lab kept restic’s and Borg’s local caches for the whole run; a first backup after a container restart may be slower.
  • Damaged repositories, file ownership, extended attributes, very large single files, parallel jobs, several clients and mounting snapshots.
  • Other compression settings: Kopia with a compression policy, restic max and Borg’s zstd.
  • Backing up one app before these tools copy it offsite: how to back up self-hosted Bitwarden (Vaultwarden) times three ways to back up a Vaultwarden vault, shows what a plain file copy loses and says which files a restore needs. We did not run restic, Borg or Kopia on it.
  • Another app whose data directory you can back up and restore with a stopped container: Uptime Kuma Docker Compose (a SQLite database in a volume; the page shows a tar backup restored into a new volume). We did not run restic, Borg or Kopia on it.
  • Restore drills on app data, the same habit as the SHA-256 check here: Vaultwarden (a SQLite vault restored into a separate Compose project), Nextcloud (a restore that failed with only a database dump), Immich, Home Assistant and Jellyfin.
  • How the lab takes medians and ranges over runs: methodology. More guides on the plumbing around self-hosted apps: the Stack hub.

Update log

  • Linked the new Uptime Kuma Docker Compose page from Related (a small SQLite app with a tested tar backup and restore). No measurements changed.
  • Linked the new Vaultwarden backup page from Related (a backup of one app that these tools can copy offsite). No measurements changed.
  • First published. restic 0.19.1, BorgBackup 1.4.5 and Kopia 0.23.1, three runs each on Oct 19, 2026, backing up one generated 334 MB dataset to a local repository. The feature table is researched from each project's documentation and source, read Oct 20, 2026. Cloud backends were not tested.