Debian based container image, running openssh-daemon only accessable by user named "borg" using SSH-Publickey Auth & "borgbackup" as client. Backup-Repositoriees, client's SSH-Keys & SSHd's Hostkeys will be stored in persistent storage. For every ssh-key added, a own borg-repository will be created.
NOTE: I will assume that you know, what a ssh-key is and how to generate & use it. If not, you might want to start here: Arch Wiki
Here is a quick example how to configure & run this image:
$ mkdir -p borg/sshkeys/clients
Make sure that the permissions are right on the sshkey folder:
$ chown 1000:1000 borg/sshkeys
Remember: Filename = Borg-repository name!
$ cp ~/.ssh/my_machine.pub borg/sshkeys/clients/my_machine
The OpenSSH-Deamon will expose on port 22/tcp - so you will most likely want to redirect it to a different port. Like in this example:
docker run -td \
-p 2222:22 \
--volume ./borg/sshkeys:/sshkeys \
--volume ./borg/backup:/backup \
ghcr.io/ls1admin/borgserver:latest
- Place Borg-Clients SSH-PublicKeys in persistent storage
- Client backup-directories will be named by the filename found in /sshkeys/clients/
Use this variable if you want to set special options for the "borg serve"-command, which is used internally.
See the the documentation for all available arguments: borgbackup.readthedocs.io
docker run --rm -e BORG_SERVE_ARGS="--progress --debug" (...) ghcr.io/ls1admin/borgserver
If you want your client to be only able to append & not prune anything from their repo, set this variable to "yes".
When BORG_APPEND_ONLY is active, no client is able to prune it's repo. Since you might want to cleanup the repos at some point, you can declare one client to be the borg "admin".
This client will have full access to all repos of any client! So he's able to add/prune/... what ever he wants.
To declare a client as admin, set this variable to the name of the client/sshkey you've added to the /sshkeys/clients directory.
docker run --rm -e BORG_APPEND_ONLY="yes" -e BORG_ADMIN="nolds_notebook" (...) ghcr.io/ls1admin/borgserver
To prune repos from another client, you have to add the path to the repository in the clients directory:
borg prune --keep-last 100 --keep-weekly 1 (...) borgserver:/clientA/clientA
Used to set the user id of the borg user inside the container. This can be useful when the container has to access resources on the host with a specific user id.
Used to set the group id of the borg group inside the container. This can be useful when the container has to access resources on the host with a specific group id.
Controls how ownership of /backup is fixed at startup.
auto(default) — recursively chown/backuponly when its top-level ownership does not match theborguser, e.g. on first start or after changing PUID/PGID. Newly created client directories are always chowned. This avoids walking a multi-terabyte repository on every start.always— recursively chown/backupon every start (the old behaviour).never— never chown anything; you manage ownership yourself.
We will need two persistent storage directories for our borgserver to be usefull.
This directory has two subdirectories:
Here we will put all SSH public keys from our borg clients, we want to backup. Every key must be it's own file. The name of the file will become the name of the borg repository, we need for our client to connect.
That means every client get's it's own repository. So you might want to use the hostname of the client as the name of the sshkey file.
Filenames must consist only of letters, digits, dots, underscores and dashes ([A-Za-z0-9._-]).
The filename becomes a directory name and part of the forced SSH command, so anything else is
rejected and the key is skipped with a warning rather than imported.
Migrating an existing /sshkeys/clients directory: if any filename contains a character outside
that set — e.g. root@web01 or a + — rename it first. Such a file is now skipped with a warning
instead of being imported, and if every file in the directory is rejected the container exits with
an error instead of starting.
Each file contributes exactly one key: the first non-comment line, which must be a bare public key.
A line carrying its own authorized_keys options is rejected, and any additional keys in the file
are ignored — otherwise a second line could grant access without the forced command that confines a
client to its own repository.
Hidden files & files inside of hidden directories will be ignored!
e.g. /sshkeys/clients/webserver.mydomain.com
Than your client would have to initiat the borg repository like this:
webserver.mydomain.com ~$ borg init ssh://borg@borgserver-container/backup/webserver.mydomain.com/my_first_repo
!IMPORTANT!: The container wouldn't start the SSH-Deamon until there is at least one ssh-keyfile in this directory!
This directory will be automaticly created on first start. Also run.sh will copy the SSH-Hostkeys here, so your clients can verify it's borgservers ssh-hostkey.
In this directory will borg write all the client data to. It's best to start with an empty directory.
Here is a quick example, how to run borgserver using docker-compose: docker-compose.yml
With this configuration (on your borg client) you can easily connect to your borgserver.
Host backup
Hostname my.docker.host
Port 2222
User borg
Now initiate a borg-repository like this:
$ borg init backup:my_first_borg_repo
And create your first backup!
$ borg create backup:my_first_borg_repo::documents-2017-11-01 /home/user/MyImportentDocs
Because the image is rebuilt daily and only republished when something actually changed (see
Rebuild policy), a nightly pull is cheap: most nights the digest is identical,
nothing is downloaded and the container keeps running. On the nights a security fix does land, you
get it without touching anything.
Two ways to set that up:
a) watchtower, as part of the compose stack. The
docker-compose.yml ships a
commented-out watchtower service — uncomment it to enable. It checks at 04:00 nightly, and pulls
and restarts only if the digest changed. --label-enable scopes it to containers carrying
com.centurylinklabs.watchtower.enable=true, which is already set on the borgserver service, so
it will not touch anything else on the host.
Note that this mounts /var/run/docker.sock, which gives the watchtower container root-equivalent
control of the host. That is a real trade-off on a machine whose job is holding your backups.
b) A host cron job or systemd timer. No extra container and no exposed docker socket:
0 4 * * * cd /srv/borgserver && docker compose pull -q && docker compose up -d
docker compose up -d is a no-op when the image digest has not changed, so this only restarts the
container on a real update.
Pick the window carefully. Either approach restarts the container, which drops any backup that
is in flight — the client's SSH connection dies mid-transfer. Borg is designed to survive this (the
next run resumes; no archive is left half-committed), but the repository can be left holding a stale
lock that the client clears with borg break-lock. Schedule the update so it cannot overlap your
clients' backup runs.
This only works while you track a moving tag such as latest, 1.4 or 1. If you pin to a digest,
updates are deliberately your call and neither approach will do anything.
Images are published to the GitHub Container Registry only:
| Tag | Contents |
|---|---|
latest, 1.4, 1, trixie |
Debian trixie + borgbackup 1.4.x — use this one |
2.0, 2 |
Debian trixie + borgbackup2 2.0.0bNN — upstream beta, see the warning below |
ghcr.io/ls1admin/borgserver:latest
The borg major version is pinned by the Debian suite: trixie carries the 1.4 line for the life of
the release, so :1.4 cannot silently become a different major version. The build fails rather than
publishing an image whose tag disagrees with the borg version inside it.
Platform: images are built for linux/amd64 only. The previous Woodpecker CI also published
linux/386, linux/arm/v7 and linux/arm64/v8; this workflow does not, so pulling on an arm64 host
(e.g. a Raspberry Pi) will fail with a "no matching manifest" error rather than silently pulling amd64.
The image is rebuilt daily and republished only when something actually changed — a new base image,
a new version of any package in the dependency closure, or a change to this repository. Debian
backports security fixes into trixie's packages, so a daily rebuild puts an openssh or borg CVE fix
in the published image within 24 hours. If nothing changed, no new image is pushed and the digest
stays stable, so docker pull is a no-op.
Every published image is scanned with Trivy, ships an SBOM and provenance attestation, and is signed with cosign. To verify:
cosign verify ghcr.io/ls1admin/borgserver:latest \
--certificate-identity-regexp '^https://github\.com/ls1admin/borgserver/' \
--certificate-oidc-issuer https://token.actions.githubusercontent.com
Borg 2.x is upstream beta software and its repository format is incompatible with 1.x. A 2.x
server only serves 2.x clients, and 1.x repositories cannot be used with it without an explicit
borg transfer. It is published for testing; latest will never point at it while upstream calls
it beta.
Borg 2.x interprets SSH URLs differently from borg 1.x when using relative vs. absolute paths:
- borg 2.x: A URL with a double slash (
ssh://user@host//abs/path) is absolute; a single slash (ssh://user@host/rel/path) is relative to the server's working directory. - borg 1.x: A single slash always means absolute.
Because this server's forced command changes the working directory (cd /backup/<client>), a 2.x
client must use the double-slash form for absolute paths. For example, to create a repository
at /backup/myclient/myrepo, use:
borg2 repo-create --repo ssh://borg@backupserver//backup/myclient/myrepo
Borg 1.x clients continue to use the single-slash form and are unaffected by this difference.
A 1.4 server serves older borg 1.x clients — including the 1.2.8 shipped by Ubuntu 24.04 — because
borg does nearly all work client-side and keeps borg serve compatible across the 1.x series. Use
:latest regardless of your clients' 1.x version.