BorgBackup and rsync.net on NixOS: Declarative Offsite Backups

6 min read

If you want offsite backups on NixOS without building a whole backup platform around yourself, Borg plus rsync.net is a very good stopping point.

Borg gives you deduplication, compression, authenticated encryption, and a backup format that is pleasant to inspect and restore from. rsync.net gives you a plain SSH-accessible storage account on top of ZFS, which means you are not learning a proprietary API or shoving tarballs into an object store and hoping future-you still remembers the quirks.

The part that makes this especially nice on NixOS is services.borgbackup.jobs. You can start with the raw CLI until the workflow makes sense, then move the whole thing into declarative config with timers, secrets, retention, and systemd ordering.

This post goes in that order: first the five Borg commands you actually need to know, then the NixOS module, then the operational bits that matter in real life.

Install Borg on NixOS

If you want to run manual restore and verification commands on the machine itself, install Borg explicitly:

environment.systemPackages = [ pkgs.borgbackup ];

If you are only using the NixOS module, the backup job will bring Borg along for the service anyway. I still like having the CLI available locally because a backup you cannot inspect manually is not a backup you really trust yet.

rsync.net setup: SSH key and remote Borg path

Before touching NixOS config, make sure the boring pieces work:

  1. Generate an SSH key dedicated to this backup target.
  2. Add the public key to your rsync.net account.
  3. Verify that you can log in and run the Borg binary rsync.net exposes.

For current rsync.net setups, the important quirk is usually not a long hardcoded path like /usr/local/bin/borg1/borg1. Their current docs expose versioned commands such as borg14, so test that directly:

ssh -i /run/secrets/backup_ssh_key 12345@usw-s001.rsync.net borg14 --version

If that works, you can tell Borg to use that remote binary via BORG_REMOTE_PATH=borg14. If rsync.net changes their preferred version later, follow their current docs instead of copying an old path from a blog post.

The Borg crash course

You do not need to memorize all of Borg. For a normal backup workflow, you mostly care about init, create, list, extract, and prune.

I like setting the repository and SSH options up front:

export REPO="12345@usw-s001.rsync.net:backups/my-stuff"
export BORG_RSH='ssh -i /run/secrets/backup_ssh_key'
export BORG_REMOTE_PATH='borg14'

Initialize the repository

This creates the remote repository metadata and chooses the encryption mode:

borg init --encryption=repokey-blake2 "$REPO"

For rsync.net, repokey-blake2 is the sensible default. Your data is encrypted before it leaves the machine, and the repository key lives with the repo but is protected by your passphrase. If you pick encryption=none, you are explicitly choosing simplicity over confidentiality.

Create an archive

This is the actual backup run:

borg create --stats --progress \
  "$REPO::'{hostname}-{now}'" \
  /etc \
  /var/lib/myapp

Every create adds a new archive to the same repository. Because Borg deduplicates chunks across archives, daily backups are not just “tar the whole machine again” with a nicer brand name.

List archives

This shows what is actually in the repository:

borg list "$REPO"

Use this early and often. If you are not checking that archives exist with the names you expect, you are doing backup astrology.

Extract data

This is the command that matters when you are tired and something is already on fire:

mkdir -p /tmp/borg-restore-test
cd /tmp/borg-restore-test
borg extract "$REPO::myhost-2026-04-06T02:00:00" etc/ssh/sshd_config

One important Borg gotcha: extract writes into your current working directory. Do not casually run it from /.

Prune old archives

This is retention:

borg prune --list --dry-run \
  --glob-archives '{hostname}-*' \
  --keep-daily 7 \
  --keep-weekly 4 \
  --keep-monthly 6 \
  "$REPO"

Run the dry run first. Always. prune is how you turn “this looks tidy” into “I deleted the only restore point from two Tuesdays ago.”

Also note that manual pruning and space reclamation are separate steps in Borg. prune decides what to delete; compact reclaims the space afterward.

Moving to the NixOS module

Once the CLI flow makes sense, services.borgbackup.jobs is the nicer way to live with it.

This is a minimal declarative rsync.net job:

{ config, ... }:

{
  sops.secrets.backup_ssh_key = {
    mode = "0400";
    owner = "root";
    group = "root";
  };

  sops.secrets.borg_passphrase = {
    mode = "0400";
    owner = "root";
    group = "root";
  };

  services.borgbackup.jobs.documents = {
    paths = [ "/srv/documents" ];
    repo = "12345@usw-s001.rsync.net:backups/documents";
    doInit = true;

    encryption = {
      mode = "repokey-blake2";
      passCommand = "cat ${config.sops.secrets.borg_passphrase.path}";
    };

    compression = "auto,zstd";
    startAt = "daily";

    environment = {
      BORG_RSH = "ssh -i ${config.sops.secrets.backup_ssh_key.path}";
      BORG_REMOTE_PATH = "borg14";
    };

    prune.keep = {
      daily = 7;
      weekly = 4;
      monthly = 6;
    };
  };
}

That already gets you most of the value:

  • a scheduled backup job
  • automatic repository initialization on first run
  • encrypted backups without hardcoding secrets into the Nix store
  • retention policy in the same place as the backup definition

If you prefer to initialize the repository manually first, you can do that and leave doInit = false;. I still like doInit = true; for fresh hosts because it removes one more piece of “remember to do this by hand later.”

Encryption modes: what to pick

For rsync.net, I would usually choose one of these:

  • mode = "repokey-blake2" if you want the normal encrypted-client-side setup
  • mode = "none" only if you intentionally do not want Borg encryption

The “none” variant is valid, just not my default:

services.borgbackup.jobs.media = {
  paths = [ "/srv/media" ];
  repo = "12345@usw-s001.rsync.net:backups/media";
  encryption.mode = "none";
  compression = "auto,zstd";
  startAt = "daily";
  environment = {
    BORG_RSH = "ssh -i ${config.sops.secrets.backup_ssh_key.path}";
    BORG_REMOTE_PATH = "borg14";
  };
};

If the remote side is outside your trust boundary, use encryption. That is the entire point of Borg being pleasant for offsite backups.

Dump first, back up second

Backing up a live database directory directly is how you get a repository full of consistency-shaped lies. The safer pattern is:

  1. dump the database into a staging directory
  2. make Borg wait for that dump
  3. back up the dump directory

For anything non-trivial, I prefer a dedicated oneshot service over jamming everything into preHook. You get clearer logs, a real unit name, and a cleaner failure boundary.

{ config, pkgs, ... }:

{
  systemd.services.pg-dump-for-borg = {
    description = "Create PostgreSQL dump before Borg backup";
    serviceConfig = {
      Type = "oneshot";
      User = "postgres";
    };
    script = ''
      ${pkgs.coreutils}/bin/install -d -m 0700 /var/backup/postgresql
      ${config.services.postgresql.package}/bin/pg_dumpall \
        --clean \
        --if-exists \
        > /var/backup/postgresql/all.sql
    '';
  };

  systemd.services."borgbackup-job-postgresql" = {
    requires = [ "pg-dump-for-borg.service" ];
    after = [ "pg-dump-for-borg.service" ];
  };

  services.borgbackup.jobs.postgresql = {
    paths = [ "/var/backup/postgresql" ];
    repo = "12345@usw-s001.rsync.net:backups/postgresql";
    doInit = true;

    encryption = {
      mode = "repokey-blake2";
      passCommand = "cat ${config.sops.secrets.borg_passphrase.path}";
    };

    compression = "auto,zstd";
    startAt = "daily";

    environment = {
      BORG_RSH = "ssh -i ${config.sops.secrets.backup_ssh_key.path}";
      BORG_REMOTE_PATH = "borg14";
    };

    prune.keep = {
      daily = 7;
      weekly = 4;
      monthly = 6;
    };
  };
}

The key detail is requires plus after on borgbackup-job-postgresql. Starting the Borg job pulls in the dump unit first, and a dump failure stops the backup instead of quietly archiving stale data from yesterday.

For small prep work, preHook is fine. For database dumps, VM snapshots, or anything else you may need to debug later, a dedicated unit usually ages better.

Secrets with sops-nix

This is where sops-nix earns its keep. The two secrets you usually care about are:

  • the SSH private key used to reach rsync.net
  • the Borg passphrase

Keep both decrypted at activation time, not embedded in Nix expressions:

sops.secrets.backup_ssh_key = {
  mode = "0400";
  owner = "root";
  group = "root";
};

sops.secrets.borg_passphrase = {
  mode = "0400";
  owner = "root";
  group = "root";
};

Then reference the generated paths:

environment = {
  BORG_RSH = "ssh -i ${config.sops.secrets.backup_ssh_key.path}";
  BORG_REMOTE_PATH = "borg14";
};

encryption = {
  mode = "repokey-blake2";
  passCommand = "cat ${config.sops.secrets.borg_passphrase.path}";
};

That keeps the secret material out of the world-readable Nix store, which is the line you do not want to cross just because “it was only a backup key.”

Monitoring and verification

Do not stop at “the timer exists.”

At minimum, check the service and read the logs:

systemctl status borgbackup-job-documents.service
journalctl -u borgbackup-job-documents.service -e

Then verify the repository itself:

export REPO="12345@usw-s001.rsync.net:backups/documents"
export BORG_RSH='ssh -i /run/secrets/backup_ssh_key'
export BORG_REMOTE_PATH='borg14'

borg list "$REPO"
borg info "$REPO"
borg check "$REPO"

And every so often, do the part people skip: restore a file into a throwaway directory and confirm it is actually readable. “The backup job stayed green for six months” is not the same thing as “restore works.”

The shape of a good setup

The nice thing about this stack is that it scales from “I just need offsite copies of a few directories” to “I need a clean, repeatable backup story for stateful services” without changing tools halfway through.

The progression is straightforward:

  1. Learn borg init, create, list, extract, and prune.
  2. Confirm rsync.net access with your SSH key and remote Borg path.
  3. Move the workflow into services.borgbackup.jobs.
  4. Put the SSH key and passphrase behind sops-nix.
  5. Make pre-backup state capture explicit with systemd dependencies.
  6. Test restores like you expect your future self to need them.

Borg does not make backups magical. It just makes them boring in the best possible way. On NixOS, that is exactly what you want.