Skip to content

About

System backup and disaster recovery for Debian/GNU Linux: saves what most backup tools leave out (hardware, UEFI/BIOS/MBR, partitions, LUKS, LVM, packages, modified /etc files, database schemas, Docker/Podman). Single Bash script.

Topics

Resources

Security policy

Stars

1 star

Watchers

0 watching

Forks

Repository files navigation

NDM Linux Backup

English · Italiano

License: AGPL v3+ Version Shell

NDM Linux Backup is a system backup and disaster recovery tool for Debian/GNU Linux servers and workstations. It saves the parts of a machine you need to rebuild or diagnose it, the ones most backup tools leave out: hardware inventory, boot setup (UEFI/BIOS/MBR, GRUB), partition tables, LUKS and LVM layout, installed packages, modified /etc files, crontabs, MySQL/MariaDB and PostgreSQL schemas, Docker and Podman configuration. GPG encryption and retention of old archives are built in.

It is one Bash script with no extra libraries: it relies on standard Debian tools that apt can install, and runs as root. Every setting lives in the script itself, optionally overridden by a settings file of simple KEY=value lines that is only read, never executed (see External settings file).

Each piece of the backup can be switched on or off individually (see Configuration), so you can trim the script down to only what your machine actually needs.

The backup itself is split into up to three archives: settings (hardware, configs, package lists, database schema, container metadata/config... everything you need to understand or rebuild the setup) is always produced; data (actual database rows, real Docker/Podman volume content, your own documents) and secrets (SSH host keys, TLS private keys, /etc/shadow, network credentials, root's own credentials...) are separate, opt-in archives (see Data backup and Secrets backup). The settings archive never contains secrets-archive material, regardless of whether the secrets archive itself is enabled.

Why

Most backup tools save your files. When a disk dies or a machine has to be rebuilt, what is often missing is everything around them: which disks and partitions there were, how LUKS and LVM were laid out, how the machine booted, which packages were installed by hand, which /etc files were changed, the database schemas, the container setup. NDM Linux Backup collects exactly that, into archives you can read with standard tools.

It does not replace file-level or disk-image tools such as Back In Time, Timeshift or Clonezilla: it complements them, saving the parts of the system they leave out.


Contents


What it backs up

System and hardware:

  • Hardware info
  • Hardware devices list
  • Full system report (inxi)
  • GNU/Linux release
  • Linux Kernel release

Boot and disks:

  • Block device list (lsblk)
  • Block ID of installed devices (root is required)
  • UEFI (root is required)
  • BIOS MBR (root is required)
  • LUKS configuration (root is required) -- reference-only text dump (UUID, cipher, keyslot layout; no key material), not a restorable header. A separate, more sensitive raw header backup that can restore a corrupted LUKS header byte-for-byte -- and that also defeats crypto-shredding for anyone who gets hold of it -- lives in the opt-in secrets archive, off by default even there; see Secrets backup
  • LVM configuration (root is required)
  • Partitions info (root is required)
  • Grub configuration

Software and data:

  • Installed packages
  • Flatpak app list
  • AppImage files found on the system (size + path list, not the files themselves; see Configuration to add search paths)
  • Manually modified system files: any file whose content no longer matches what its package installed (dpkg -V), copied as-is; only catches files already tracked by a package, not brand new unpackaged files
  • Unpackaged /etc files: the complement of the above, files under /etc owned by no installed package at all (so dpkg -V never sees them either) -- e.g. /etc/postgresql/*/main/postgresql.conf, generated by a package's post-install script rather than shipped in it. A default ignore list filters out auto-generated state, tool/editor backup copies, and compiled caches that would otherwise be noise on every run (not files an admin plausibly edited by hand -- /etc/motd, /etc/hosts, /etc/hostname, /etc/machine-id, /etc/network/interfaces and the like are deliberately left in, unlike earlier versions of this list), as well as private key material (SSH host keys, the ssl-cert snakeoil key, /etc/mysql/debian.cnf...) -- that now lives in the opt-in secrets archive instead, see Secrets backup. /etc/fstab//etc/crypttab specifically have their own dedicated copy elsewhere (partitions/fstab.txt, luks/crypttab.txt) instead of showing up here -- except crypttab, which still surfaces here too whenever the LUKS module didn't run, so it is never silently missing from the whole archive
  • Crontab (root is required)
  • MySQL/MariaDB schema (structure only: tables, views, triggers, routines; no row data. Root is required)
  • PostgreSQL schema (same as above)
  • Docker images/containers/volumes/networks info (name/id lists only; images and containers themselves are never backed up, they are meant to be recreated from the Compose/Quadlet files above plus their registry)
  • Compose files found on the system: Docker Compose / podman-compose (searches common locations, see Configuration to add search paths; copied as-is, not just listed)
  • Podman images/containers/volumes/networks info (name/id lists only, same reasoning as Docker above; reads the rootless state of v_podman_user, see Configuration) plus its config and Quadlet (systemd) unit files, both system-wide (/etc/containers) and for v_podman_user (~/.config/containers)
  • More directories chosen by the user (root could be required)

All of this is compressed into a single tar.xz settings archive in a chosen directory. Optionally, the archive directory can also be synchronized to a local or remote destination using rsync, and you can run your own commands before and after the backup. See Data backup for the separate, opt-in data archive.

Safety

  • Writes are confined. The script only ever writes inside the backup destination and its own working directory. Every other path on the system (disks, /boot, /etc, LUKS headers, MySQL...) is only ever read. Every tar/chmod/chown/sha256sum/gpg/rm call is additionally checked at runtime against that same whitelist right before it runs, hard-exiting instead of proceeding if a path ever resolved outside it.
  • Dry run. --dry-run walks through every step for real (reading disks, LUKS headers, MySQL databases, etc., with root privileges) without creating, modifying, or deleting anything, so you can validate your configuration safely before a real run.
  • Overlapping source paths. If v_tempdir/v_tempdir_data is moved (via ndm_linux-backup.conf) to somewhere under an a_source_extra/a_data_source_extra entry, the script warns (without blocking the run) that its content will end up duplicated in the resulting archive -- both end up as separate tar source arguments on the same command line. Move the tempdir back outside every source-extra path to silence it; see CONFIGURE's v_tempdir comment for why the default never overlaps. A v_tempdir/_data/_secrets moved to the same place as, or under, its own v_dest/_data/_secrets is a harder case -- the archive would come out empty -- so this one hard-exits instead of just warning.
  • Dependency check. At every run, the script lists every external tool it can use, reports what's missing, and lets you choose what to do about it: install only what's needed by the backup pieces you've enabled (default) or everything the script could ever use -- either way, the exact apt-get install command is printed before it runs -- install it yourself instead (prints the exact command for Debian, then exits so you can run it and re-launch NDM Linux Backup once done), or skip and continue with those steps failing/skipped. Skipped entirely in --quiet/--dry-run.
  • Database credentials check. If MySQL/MariaDB or PostgreSQL backup is enabled and its client tool is present, the script checks that its credentials file (.my.cnf/.pgpass) exists before doing any work; if not, it first tries a local socket connection (root@localhost for MySQL/MariaDB, peer/ident for PostgreSQL) and only prints what's missing and how to create it, then exits, if that also fails -- it never creates, copies, or edits the file itself (see MySQL/MariaDB backup and PostgreSQL backup). If the file exists but is readable by group/other, the script prints a warning (it contains a plaintext password) without blocking the run -- chmod 600 it to silence the warning.
  • Run log. Every run (except --dry-run) writes a plain-text, world-readable (chmod 644) .log file into the backup destination alongside the settings archive -- the full step-by-step record of what happened, independent of --quiet (which only silences the terminal, not the log), so unattended/cron runs still leave a complete trail to check afterward.
  • Checksum. Every archive (settings, data, secrets) gets a <filename>.tar.xz.sha256 sidecar written right next to it, in standard sha256sum format. Verify with sha256sum -c <filename>.tar.xz.sha256 from inside the destination directory -- worth doing before trusting an old backup for a real restore, since a tar.xz that got silently corrupted (disk issue, an interrupted copy elsewhere) still looks like a normal file until you actually try to read it. A checksum failure is logged as a warning and does not fail the run: the archive itself already succeeded by that point.
  • Retention. Off by default (v_retention_days=0): old archives are kept forever unless you opt in. When enabled, it deletes archives (settings/data/secrets counted separately) older than v_retention_days, always keeping at least v_retention_min_keep of each type regardless of age -- so a machine that sits unused past the retention window never wakes up to zero backups on the next run. Runs right after a new archive of that type is successfully created, before the rsync step (so a mirrored v_dest_rsync reflects the pruned state too), and only ever matches this script's own exact filename pattern.
  • Encryption. Off by default (v_enable_gpg_settings/_data/_secrets=false). chmod 600 protects an archive only as long as it stays on this machine's filesystem; if it's ever copied somewhere less protected (a USB drive, cloud storage, an unencrypted external disk), that protection is gone. When a switch is on, its archive is GPG-encrypted right after it's created (using v_gpg_recipient_file/_data/_secrets, default ${HOME}/pubkey.asc) and the plaintext is deleted -- the private key is never needed on the backup machine, only later, on whatever machine you actually restore from (see Restore). A switch enabled with no recipient key at that path exits the run before any backup work starts (see Encryption); a failed encryption hard-exits the run too, instead of silently leaving a plaintext archive behind.
  • Settings file is never executed. ndm_linux-backup.conf is read line by line as KEY=value: only the settings of the CONFIGURE section are accepted, every value is checked against its type (true/false, number, absolute path...), and nothing in it is expanded or run -- $HOME, $(...) and backticks are refused. An unknown setting or an invalid value stops the script with exit code 2 and names the line. The environment is not read either. The only values that end up being run are the hook commands (v_pre_command, v_post_command, v_on_failure_command), each only once its own v_enable_* switch is on.
  • Script and settings file trust. ndm_linux-backup.sh runs as root the moment you sudo it, and the hook commands in ndm_linux-backup.conf run as root too, with no ownership/permission check on either file. Neither needs to be owned by root: they work fine owned by your normal user, like any other script you sudo. If several people share this machine and you want to be sure only you (or root) can edit them before the next run, lock both down the same way:
    sudo chown root:root ndm_linux-backup.sh ndm_linux-backup.conf
    sudo chmod 755 ndm_linux-backup.sh
    sudo chmod 644 ndm_linux-backup.conf
    
    Entirely optional, and your call to make -- NDM Linux Backup itself never requires or checks for this.
  • Backup destination folder. v_dest (and _data/_secrets) is created at whatever mode your umask produces (typically 755 root:root -- listable, not writable, by other users), and every archive inside it is already individually locked down (chmod 600, see Encryption above) regardless. If you also want the folder itself unlistable to other users on a shared machine:
    sudo chmod 700 /path/to/backup
    
    Optional, same as above -- the individual archive permissions are the real protection either way.

Warning

The settings archive still isn't a secret-free zone, just secret-material-free. /etc and /root are swept wholesale by default (a_source_extra), and system config can still legitimately contain sensitive details even after the known high-risk material (private keys, /etc/shadow, network/cloud credentials -- see Secrets backup) is excluded from it. The .my.cnf/.pgpass files are likewise always excluded from the settings and data archives even if they end up under an a_source_extra/a_data_source_extra path (they authenticate the script to an external database, not machine configuration). Protect the resulting .tar.xz files (file permissions, where you store or rsync them) accordingly; the secrets archive, when enabled, needs that protection even more.

Data backup

Off by default (v_enable_data_backup=false). When enabled, a second tar.xz archive is produced alongside the settings one, with actual data. Deliberately not included here either: Docker/Podman images and containers themselves, since they are meant to be recreated from what the settings archive already has (Compose/Quadlet files, image names/tags) plus the registry, not restored byte-for-byte. What the data archive does contain:

  • MySQL/MariaDB and PostgreSQL row data (their schema is always in the settings archive regardless of this switch, see What it backs up)
  • Real Docker/Podman volume content (v_enable_docker_volumes_data / v_enable_podman_volumes_data), read via each volume's Mountpoint; only works for the default "local" driver. Copying a volume actively written by a running container can produce an inconsistent copy, same caveat as copying a live database data directory directly, so for database volumes prefer the MySQL/PostgreSQL data dumps instead.
  • Any directories in a_data_source_extra (e.g. personal documents, media)

This pass has its own working directory and destination (v_tempdir_data / v_dest_data, default next to the settings ones) and its own archive filename (..._DATA_...), so it can be routed, retained, or scheduled independently of the settings archive. --dry-run/--quiet/--keep apply to it exactly like the settings pass, no separate flags needed.

Secrets backup

Off by default (v_enable_secrets_backup=false). Enable it permanently in CONFIGURE, or pass --secrets on the command line to produce it for a single run without touching CONFIGURE at all (useful for an occasional "grab the secrets too this time" without changing what every future run does by default; it only ever turns the pass on for that run, never off if CONFIGURE already enabled it). Either way, a third tar.xz archive is produced, containing sensitive credential/key material that the settings and data archives never include, regardless of this switch: SSH host private keys, TLS private keys (/etc/ssl/private, Let's Encrypt), /etc/shadow/ /etc/gshadow (plus their - backup copies), NetworkManager/wpa_supplicant/WireGuard network credentials, mail relay and PPP secrets, root's own SSH/cloud-CLI/Docker-registry credentials and bash history, and this script's own .my.cnf/.pgpass (unlike everywhere else, the secrets archive is where those belong, since it exists specifically to hold restore-time credentials). The exact list is a_secrets_source in CONFIGURE, freely overridable; a missing path is skipped with a notice, not an error, since not every machine has all of these.

This pass has its own working directory and destination (v_tempdir_secrets / v_dest_secrets, default next to the settings ones) and its own archive filename (..._SECRETS_...), so it can be routed, retained, or protected independently of the settings/data archives -- e.g. a tighter destination permission, no rsync to a shared location, a shorter retention. --dry-run/--quiet/--keep apply to it exactly like the settings pass, no separate flags needed.

Warning

Protect this archive at least as well as you would protect /root and /etc/shadow themselves: whoever has it can impersonate this machine, decrypt its TLS traffic, and use whatever cloud/registry credentials root had configured.

Raw LUKS header backup (v_enable_luks_header_backup, off by default even when the rest of this pass is on): a real cryptsetup luksHeaderBackup image per a_luks entry, landing in luks_headers/ inside this same archive. This is different from -- and more sensitive than -- the human-readable luksDump/luksUUID text already in the settings archive: it contains the actual keyslots (the master key encrypted with each passphrase's derived key), so it can restore a corrupted LUKS header byte-for-byte.

Caution

This also defeats crypto-shredding -- destroying only the on-disk header to make a volume permanently unrecoverable, a technique some admins rely on when decommissioning a disk -- for anyone who also gets hold of it. Turn it on only if you specifically want the header-recovery guarantee above and accept this trade-off; the script prints and logs an explicit warning whenever it's active, separate from the general secrets-backup notice.

Encryption

Off by default for all three archives (v_enable_gpg_settings/_data/_secrets, see Configuration and Safety) -- v_enable_gpg_data/_secrets mirror v_enable_gpg_settings unless overridden independently, same pattern as v_dest_secrets/v_tempdir_secrets: turn on just v_enable_gpg_settings to encrypt all three alike, or leave it off and turn on only v_enable_gpg_secrets to encrypt just the secrets archive. Each archive's actual recipient key is v_gpg_recipient_file/_data/_secrets, defaulting to ${HOME}/pubkey.asc (root's, same place .my.cnf/.pgpass already live) even while encryption itself is off -- the path being set is harmless on its own, only the matching v_enable_gpg_* switch turns encryption on.

One-time setup, before turning a switch on:

  1. Generate a GPG keypair. Doesn't need to happen on the backup machine -- in fact it's better if it doesn't, see step 4:
    gpg --full-generate-key
    
  2. Export just the public key to a plain file:
    gpg --armor --export <keyid-or-email> > pubkey.asc
    
  3. Copy pubkey.asc to the backup machine, at the path v_gpg_recipient_file/_data/_secrets already points to (${HOME}/pubkey.asc unless you changed it) -- or already have a public key from whoever will restore this backup? Copy that one there instead, no need to generate your own. Different keys per archive for tiered access, e.g. a wider-access key for settings and a restricted one for secrets? Point each variable at its own file instead of sharing the default -- see Configuration for why settings is not necessarily "secret-free" either before deciding to leave it plain.
  4. Keep the private key off the backup machine entirely. It is never needed there -- only pubkey.asc is, to encrypt. The private key is needed later, only on whatever machine you actually restore to (see Restore); store it like you would any other master credential -- a password manager, an offline USB drive, a hardware token -- not on the machine being backed up.
  5. Turn on the switch(es) for the archives you want encrypted: v_enable_gpg_settings/_data/_secrets=true.

If a switch is on but its recipient key is missing at the moment NDM Linux Backup runs, it exits before doing any work and prints exactly what's missing and how to fix it (generate a keypair, export the public key, or copy an existing one into place) -- it never creates or edits key material itself, same "check and report, never automate the fix" approach as the MySQL/PostgreSQL credentials check above. Whether each archive ends up encrypted or plain is also stated at the start of every run, right next to the secrets-backup notice.

From the next run onward, every archive whose switch is on is encrypted automatically and its plaintext deleted; nothing else changes about how the script runs.

Requirements

  • Debian GNU/Linux: tested on Debian 13 (Trixie). Derivatives may work but are not tested.
  • Bash and root privileges (sudo or su): the script refuses to run otherwise.
  • The tools of the backup pieces you enable. At every run the script lists what is missing and offers to install it with apt-get (see Safety, "Dependency check"). A tool needed by an enabled piece must be there, otherwise the run stops before doing any work; the ones marked optional only skip their own piece when missing.
Debian package Commands Needed for
tar, coreutils, fdisk tar, sha256sum, fdisk every run required
lshw lshw hardware info if enabled
pciutils, usbutils lspci, lsusb hardware devices list if enabled
inxi inxi full system report if enabled
util-linux lsblk, blkid block devices if enabled
coreutils dd, uname MBR, kernel release if enabled
lsb-release lsb_release GNU/Linux release if enabled
fdisk sfdisk partition tables if enabled
dpkg, apt dpkg, apt-mark installed packages, modified system files if enabled
cryptsetup cryptsetup LUKS, raw LUKS header if enabled
cron crontab crontab if enabled
rsync rsync copy to a second destination if enabled
gnupg gpg Encryption if enabled
efibootmgr efibootmgr UEFI boot entries optional
lvm2 lvmdump, pvdisplay, vgdisplay, lvdisplay LVM optional
default-mysql-client mysql, mysqldump MySQL/MariaDB optional
postgresql-client psql, pg_dump, pg_dumpall PostgreSQL optional
docker.io, podman, flatpak docker, podman, flatpak containers, Flatpak apps optional
sudo sudo Podman of a user other than root (v_podman_user) optional

./ndm_linux-backup.sh --requirements (no root needed) lists the tools your settings need, which are missing and the apt-get command to install them. sudo ./ndm_linux-backup.sh --analyze shows, for your settings and this machine, which pieces would run and which are blocked (see Analyze).

Installation

sudo apt install git
mkdir -p ~/bin && cd ~/bin
git clone https://github.com/nidamanx/NDM_Linux-Backup.git
cd NDM_Linux-Backup
chmod +x ndm_linux-backup.sh
sudo ./ndm_linux-backup.sh --analyze      # what your current settings would do; nothing is written

Optional: make it available everywhere through a symbolic link. The script still finds ndm_linux-backup.conf and its backup/ folder next to its real location.

sudo ln -s "$PWD/ndm_linux-backup.sh" /usr/local/bin/ndm_linux-backup

The examples in this README assume the clone is in ~/bin/NDM_Linux-Backup.

Only the script

Downloading only ndm_linux-backup.sh is enough for everything, including per-machine settings (see Configuration: no extra file is needed for that). The only exception is v_enable_mysql/v_enable_postgresql, which need my.cnf.example/pgpass.example respectively: clone the full project instead if you plan to use either.

Updating

cd ~/bin/NDM_Linux-Backup
git pull --ff-only
./ndm_linux-backup.sh --version

Updating never touches your ndm_linux-backup.conf, your credential files or the archives in backup/: they are not part of the repository. Keep your settings in ndm_linux-backup.conf rather than editing the CONFIGURE section of the script, so that git pull never conflicts with them.

If you downloaded only the script, download the new one from the latest release and replace the old file.

Upgrading from 2.x (published as NDM Backup on GitLab):

  1. rename ndm_backup.conf to ndm_linux-backup.conf;
  2. in it, write full paths instead of ${HOME} and remove anything that is not a KEY=value setting: the file is now only read, never executed (see External settings file). --requirements reports the first line to fix, if any;
  3. if you set v_datetime_suffix, use v_datetime_format instead (a date format); archives are now named ndm_linux-backup_<host>_... and staged under /home/ndm_linux-backup/: set v_backup_name=NDM_BK to keep the old names (and the retention of the old archives), delete the old /home/ndm_backup/, and use NDM_ARCHIVE_PREFIX instead of NDM_BACKUP_NAME in your hook commands;
  4. update the path of the script in your symbolic link and crontab.

See CHANGELOG.md for every change.

Usage

./ndm_linux-backup.sh --requirements          # the tools your settings need (no root)
sudo ./ndm_linux-backup.sh --analyze          # what would be backed up; nothing is written
sudo ./ndm_linux-backup.sh --start --dry-run  # simulate a full run; nothing is written
sudo ./ndm_linux-backup.sh --start            # back up
sudo ./ndm_linux-backup.sh --start --secrets  # also the secrets archive, for this run only

This script needs root privileges to back up most parts of your GNU/Linux machine:

sudo ndm_linux-backup --start or su -c 'ndm_linux-backup --start'

Before a real run, you can test your configuration safely with sudo ndm_linux-backup --start --dry-run (see Safety), or get an instant report of what your current settings would actually do with sudo ndm_linux-backup --analyze (see Analyze).

Flag Long form Meaning
-s --start start the backup
-a --analyze report what would be backed up, skipped, or blocked, then exit -- see Analyze
-n --dry-run simulate every step; nothing is written anywhere
-q --quiet don't ask questions and don't show any progress
-k --keep don't delete the temporary directory after a quiet run
--secrets also produce the secrets archive for this run, even if v_enable_secrets_backup=false in CONFIGURE (does not disable it if already true; see Secrets backup)
-h --help show the full help
--requirements show the tools your settings need, which are missing and how to install them (no root needed)
-u --usage show a short usage message
-v --version show the software version

Short and long forms are interchangeable and can be combined, e.g. ndm_linux-backup -s -q -k or ndm_linux-backup -sqk.

Full help: ndm_linux-backup -h or ndm_linux-backup --help.

Configuration

All parameters live in the CONFIGURE section at the top of ndm_linux-backup.sh. You can edit them in place, or override any of them from ndm_linux-backup.conf (see External settings file) so future script updates don't clobber your per-machine settings. The most relevant ones:

  • v_dest / v_tempdir / v_dest_rsync: where the archive is written, the temporary working directory used while collecting data, and an optional rsync destination; set v_enable_rsync=false to skip the rsync step without clearing v_dest_rsync. The sync copies v_dest itself (no trailing slash) into v_dest_rsync, so archives end up nested one level down, e.g. v_dest_rsync/backup/*.tar.xz under the stock CONFIGURE, not flattened into v_dest_rsync/*.tar.xz -- standard rsync behavior, not a bug, but easy to assume the opposite. v_tempdir defaults to /home/ndm_linux-backup/${v_backup_name} instead of /tmp: everything the archive will contain sits there unprotected until the final tar.xz exists, so it stays off a predictable, world-writable shared path. /home over /root: both are root-owned/non-shared, but /root commonly sits on the (often small) root filesystem, while /home is more often its own, larger partition -- relevant since this directory can hold a full uncompressed data-pass staging tree before it's even compressed. It can't default under /root, /etc, or the script's own directory either way: all three are already swept into the settings archive by a_source_extra, and v_tempdir is a direct tar source itself, not just an excluded path like v_dest -- nesting it inside anything a_source_extra sweeps either duplicates its content in the archive or, if excluded to avoid that, drops it from its own direct inclusion too. v_tempdir_data/v_tempdir_secrets are siblings of it (${v_backup_name}_data/${v_backup_name}_secrets), so all three -- and only these -- live under one shared /home/ndm_linux-backup container instead of scattering separate top-level directories. That container is validated and, the first time it's needed, created at 700 right along with each leaf (f_prepare_tempdir in main()), so it never ends up at whatever looser permissions its first creator happened to leave it at:
    /home/ndm_linux-backup/        700 root:root  (container, created once, checked every run)
    ├── ndm_linux-backup/          700 root:root  (v_tempdir -- settings staging)
    │   ├── crontab.txt            600 root:root
    │   └── ...
    ├── ndm_linux-backup_data/     700 root:root  (v_tempdir_data -- only if the data pass is on)
    │   └── ...
    └── ndm_linux-backup_secrets/  700 root:root  (v_tempdir_secrets -- only if the secrets pass is on)
        └── files/
            └── etc/shadow         600 root:root
    
  • a_storage / a_luks: which disks (e.g. sda, nvme0n1) and LUKS partitions to inspect. Set either to ( "auto" ) to auto-detect every physical disk / LUKS partition on the system instead of listing them by hand.
  • v_enable_*: one on/off switch per backup piece (hardware, EFI, MBR, LUKS, LVM, partitions, installed packages, flatpak, AppImages, modified system files, unpackaged /etc files, cron, MySQL, PostgreSQL, Docker, Compose files, Podman...). Set any to false to skip that piece entirely, without touching the rest of the script.
  • a_source_extra: extra directories to include in the archive as-is (default: /root, /etc, /usr/local/bin, /opt, /srv, /usr/local/etc, /var/spool/cron/crontabs, and the script's own directory -- captures how this NDM Linux Backup deployment itself is set up; .git and the MySQL/PostgreSQL credential files are excluded from the archive even if they fall under one of these paths, see Safety; missing paths are skipped with a warning instead of failing the whole archive); set v_enable_source_extra=false to skip it without clearing the array.
  • a_appimages_extra_paths: extra directories to search for AppImages, in addition to the built-in defaults (/home, /root, /opt, /usr/local/bin).
  • a_docker_compose_extra_paths: extra directories to search for Compose files (Docker Compose / podman-compose), in addition to the built-in defaults (/home, /root, /opt, /srv).
  • v_mysql_cnf_path: path to the .my.cnf credentials file for MySQL/MariaDB dumps, default ${HOME}/.my.cnf. If missing, NDM Linux Backup first tries root@localhost socket auth before asking for the file -- no separate switch, it's just a fallback (see MySQL/MariaDB backup).
  • v_postgresql_user / v_postgresql_pgpass_path: user to connect as and path to the .pgpass credentials file for PostgreSQL dumps, default ${HOME}/.pgpass (see PostgreSQL backup). If .pgpass is missing, NDM Linux Backup first tries local peer/ident auth over the Unix socket before asking for the file -- no separate switch, it's just a fallback (see PostgreSQL backup).
  • v_podman_user: which user's rootless Podman instance to inspect (defaults to the same auto-detected user as v_dest). Podman is normally rootless and per-user, so reading it as root only shows root's own state. For a non-root user, run loginctl enable-linger <user> once (as root) so their runtime directory exists even without an active login session.
  • v_pre_command / v_post_command: your own commands to run before/after the backup (e.g. stop/start services, send a notification); off by default even once set -- also set v_enable_pre_command / v_enable_post_command to true to actually run it. Both get ${NDM_HOSTNAME}/${NDM_ARCHIVE_PREFIX}/${NDM_LOG_PATH}/ ${NDM_USER} (the human user behind sudo, same as v_username) exported for them to use; v_post_command additionally gets ${NDM_EXIT_CODE} (always 0 here, see v_on_failure_command below for failures) and, for each archive actually produced this run, its full path and human-readable size: ${NDM_SETTINGS_ARCHIVE}/${NDM_SETTINGS_ARCHIVE_SIZE}, ${NDM_DATA_ARCHIVE}/${NDM_DATA_ARCHIVE_SIZE}, ${NDM_SECRETS_ARCHIVE}/${NDM_SECRETS_ARCHIVE_SIZE} -- empty for whichever pass did not run this session. See Notifications for a ready-to-use ntfy example covering both.
  • v_on_failure_command: your own command to run if the run fails (send an email, hit a webhook...) -- the actual pain point for unattended/cron use, where a failed run otherwise only shows up in the log/exit code that nobody is watching live. Fires from a single EXIT trap covering every hard-exit inside main() (missing dependency, missing DB credentials, a failed tar/GPG step...) plus Ctrl-C/SIGTERM, but not failures before main() starts (a malformed ndm_linux-backup.conf, an invalid CLI flag) -- those are setup-time problems you'd notice on the very first run. ${NDM_EXIT_CODE}/${NDM_LOG_PATH}/${NDM_ARCHIVE_PREFIX}/${NDM_HOSTNAME}/ ${NDM_USER} are exported for it to use; off by default even once set -- also set v_enable_on_failure_command=true to actually run it.
  • v_enable_data_backup / v_dest_data / v_tempdir_data: enable the separate data archive and where it's written (see Data backup).
  • v_enable_docker_volumes_data / v_enable_podman_volumes_data: back up the actual content of Docker/Podman volumes, not just the metadata list.
  • a_data_source_extra: extra directories with real data to include in the data archive; off by default even once set -- also set v_enable_data_source_extra=true to include it (also requires v_enable_data_backup=true, like the other data-pass switches).
  • v_enable_secrets_backup / v_dest_secrets / v_tempdir_secrets: enable the separate secrets archive and where it's written (see Secrets backup).
  • a_secrets_source / a_secrets_source_extra: which paths are swept into the secrets archive; the former is a curated built-in list (SSH host keys, /etc/shadow, network credentials, root's own credentials...), freely overridable, the latter is for anything extra specific to your machine.
  • v_enable_gpg_settings / _data / _secrets: GPG-encrypt the matching archive, off by default (see Encryption); _data/_secrets mirror v_enable_gpg_settings unless overridden independently. v_gpg_recipient_file / _data / _secrets: path to that archive's recipient public key, default ${HOME}/pubkey.asc for all three unless overridden.
  • v_tar_owner: chown the settings/data archives to the invoking user right after creation, default true (they hold no secret material by default -- see Safety). v_tar_owner_secrets: same, for the secrets archive specifically, default false and deliberately independent -- turning on v_tar_owner never also hands the secrets archive to a non-root user; that needs its own explicit true.
  • v_datetime_format: the date and time at the end of every archive, checksum and log name, as a date format, default %Y%m%d-%H%M%S (local time). Only letters, digits, ., _ and - may come out of it; %A alone gives one archive per weekday, overwritten every week.

External settings file

ndm_linux-backup.conf, next to the script (its real location, also when run through a symbolic link), overrides the CONFIGURE section of the script. It is optional: without it the built-in defaults are used, so a single downloaded ndm_linux-backup.sh keeps working on its own.

cd ~/bin/NDM_Linux-Backup
cp -n ndm_linux-backup.conf.example ndm_linux-backup.conf
editor ndm_linux-backup.conf

Keep only the lines you want to change: a single line like v_enable_mysql=true is a valid file. The file is only read, never executed (see Safety):

v_enable_mysql=true                       # one KEY=value per line, # starts a comment
v_dest="/mnt/my backups"                  # quote values with spaces
v_gpg_recipient_file=/root/pubkey.asc     # full paths: $HOME and $(...) are not expanded
a_luks=( "sda3" "nvme0n1p3" )             # lists
a_source_extra=(                          # lists over several lines replace the built-in one
  "/etc"
  "/srv/my data"
)
v_post_command='curl -fsS -d "Backup done on ${NDM_HOSTNAME}" https://ntfy.sh/REPLACE_WITH_YOUR_OWN_RANDOM_TOPIC'

Only the three hook commands (v_pre_command, v_post_command, v_on_failure_command) may contain $: they are kept as written and run later, as root, by the script (see Notifications). An unknown setting or an invalid value stops the script with exit code 2 and says which line is wrong; --help and --version still work.

No ownership change is needed: ndm_linux-backup.conf stays owned by you, same as ndm_linux-backup.sh itself. See Safety for optionally locking both files down on a shared machine, and the CONFIGURE section of the script for every setting and its default.

MySQL/MariaDB backup

v_mysql_cnf_path defaults to ${HOME}/.my.cnf (root's, since the script requires root) -- the same file the mysql/mysqldump client tools read automatically:

  1. sudo cp -n ~/bin/NDM_Linux-Backup/my.cnf.example /root/.my.cnf
  2. sudo editor /root/.my.cnf (set your mysql user/pass under [client])
  3. sudo chmod 600 /root/.my.cnf

Note: you can point v_mysql_cnf_path at a custom location instead (e.g. a copy kept alongside the script). If it's missing when v_enable_mysql=true and mysqldump is installed, NDM Linux Backup first tries root@localhost socket auth (see below); only if that also fails does it report the missing file and exit before doing any work -- it never creates or edits this file itself. Unlike ndm_linux-backup.conf, there's no separate ownership step: this file is only ever read as plain data (never sourced/executed), so root reads it fine regardless of who owns it -- sudo cp above already makes it root's from the start anyway. my.cnf.example itself documents all of this inline, as # comments (MySQL option files support them).

Avoiding a password/file entirely -- Debian's default root@localhost account usually already allows this:

  1. Verify: sudo mysql --no-defaults -u root -e "SELECT 1" -- prints 1 with no password prompt? Already set, skip .my.cnf entirely.
  2. Still asks for a password? Check the plugin: sudo mysql -e "SELECT user, plugin FROM mysql.user WHERE user='root';" -- unix_socket/auth_socket is the passwordless one; switch to it if it's something else.

.my.cnf, if present, always takes precedence. If it's missing, NDM Linux Backup runs step 1 itself automatically (time-capped, so an unreachable server can't hang an unattended run) and uses --no-defaults -u root instead.

PostgreSQL backup

v_postgresql_pgpass_path defaults to ${HOME}/.pgpass (root's) -- the same file the PostgreSQL client tools read automatically:

  1. sudo cp -n ~/bin/NDM_Linux-Backup/pgpass.example /root/.pgpass
  2. sudo editor /root/.pgpass (set host:port:database:username:password, e.g. localhost:5432:*:postgres:secret)
  3. sudo chmod 600 /root/.pgpass

Note: PostgreSQL client tools silently ignore .pgpass if it isn't chmod 600. You can point v_postgresql_pgpass_path at a custom location instead; also set v_postgresql_user in CONFIGURE if it doesn't match the username in .pgpass. Same as above: NDM Linux Backup only checks that the file exists and tells you how to create it if not, it never creates or edits it itself, and there's no separate ownership step either, same reasoning as .my.cnf above. pgpass.example itself documents all of this inline, as # comments (PostgreSQL skips lines starting with #, same as blank lines).

Avoiding a password/file entirely -- unlike MySQL, this needs setup: PostgreSQL's role name must match the connecting OS user exactly, and this script always runs as root, not postgres:

  1. pg_hba.conf (e.g. /etc/postgresql/<version>/main/pg_hba.conf), the local line for the role in v_postgresql_user: local all postgres peer map=ndm_linux-backup
  2. pg_ident.conf: ndm_linux-backup root postgres (maps OS root to PostgreSQL role postgres)
  3. sudo systemctl reload postgresql
  4. Verify: sudo psql -U postgres -tAc "SELECT 1" -- prints 1 with no password prompt? Done, skip .pgpass entirely.

.pgpass, if present, always takes precedence. If it's missing, NDM Linux Backup runs step 4's check itself automatically (time-capped, so an unreachable server can't hang an unattended run) and falls back to requiring .pgpass if it fails.

Notifications

NDM Linux Backup has no notification feature of its own -- it reuses v_pre_command/v_post_command/ v_on_failure_command (see Configuration), each just an arbitrary command string, eval'd with a handful of NDM_* variables exported for it to use. Any push service reachable with a single curl call works the same way; ntfy (simple, self-hostable) is used as the example below.

Warning

An unreserved ntfy.sh topic is public: anyone who knows or guesses its name can read it. Pick a long, random topic name (or self-host) instead of the placeholder below.

Keep any access token the same way .my.cnf/.pgpass are kept -- in its own root-only file, never inline inside v_pre_command/v_post_command/v_on_failure_command themselves (those live in CONFIGURE, or in ndm_linux-backup.conf, and end up in this machine's own settings archive via a_source_extra):

echo 'tk_xxxxxxxxxxxxxxxxxxxxxxxxxxxxxxx' > ${HOME}/.ntfy_token
chmod 600 ${HOME}/.ntfy_token
v_enable_pre_command=true
v_pre_command='curl -fsS -H "Authorization: Bearer $(cat ${HOME}/.ntfy_token)" -H "Title: NDM Linux Backup" -d "$(date "+%F %T") - Starting backup ${NDM_USER}@${NDM_HOSTNAME}" https://ntfy.sh/REPLACE_WITH_YOUR_OWN_RANDOM_TOPIC'

v_enable_post_command=true
v_post_command='curl -fsS -H "Authorization: Bearer $(cat ${HOME}/.ntfy_token)" -H "Title: NDM Linux Backup" -d "$(date "+%F %T") - Backup done ${NDM_USER}@${NDM_HOSTNAME}: settings ${NDM_SETTINGS_ARCHIVE_SIZE}${NDM_DATA_ARCHIVE:+, data ${NDM_DATA_ARCHIVE_SIZE}}${NDM_SECRETS_ARCHIVE:+, secrets ${NDM_SECRETS_ARCHIVE_SIZE}}" https://ntfy.sh/REPLACE_WITH_YOUR_OWN_RANDOM_TOPIC'

v_enable_on_failure_command=true
v_on_failure_command='curl -fsS -H "Authorization: Bearer $(cat ${HOME}/.ntfy_token)" -H "Title: NDM Linux Backup FAILED" -H "Priority: urgent" -d "NDM Linux Backup failed on ${NDM_USER}@${NDM_HOSTNAME} (exit ${NDM_EXIT_CODE}) -- see ${NDM_LOG_PATH}" https://ntfy.sh/REPLACE_WITH_YOUR_OWN_RANDOM_TOPIC'

All three use single quotes: in the CONFIGURE section of the script they keep ${NDM_*} unexpanded until eval runs each command at its actual trigger point; in ndm_linux-backup.conf nothing is expanded on reading anyway, so the same lines work there as they are. v_post_command's example above skips the , data .../, secrets ... clause entirely whenever that archive did not run this session (${NDM_DATA_ARCHIVE:+...} -- empty unless NDM_DATA_ARCHIVE is set), so the same one-liner works unmodified whether or not you have the data/secrets passes enabled.

Cron

--quiet (-q) is what makes an unattended run possible: it skips every confirmation prompt (whether to run at all, whether to reuse an existing working directory, whether to delete it afterward -- see Safety and Usage), so the whole run needs no interaction at all, start to finish. Verified live: --start --quiet with stdin closed (< /dev/null, exactly how cron itself runs a job) completes and exits 0 without ever touching stdin.

Example, run daily at 3:00 AM as root:

0 3 * * * /usr/local/bin/ndm_linux-backup --start --quiet

Edit root's crontab with sudo crontab -e (root's own, since the script requires root -- see Usage). Points worth knowing:

  • Without --keep (-k), the working directory (v_tempdir) is deleted automatically after a quiet run, same as answering "yes" to the interactive prompt -- nothing left behind between runs.
  • cron mails a job's stdout/stderr to MAILTO (or the crontab owner) by default, on top of the run's own .log file already written into the backup destination (see Safety, "Run log" -- that one is complete and unconditional regardless of --quiet). If you don't want both, redirect cron's own copy instead: ... --start --quiet >> /var/log/ndm_linux-backup_cron.log 2>&1.
  • A failed run only shows up in the log/exit code by default, which nobody is watching live in an unattended setup -- this is exactly the case v_on_failure_command exists for (see Configuration and Notifications): set it once and get a real notification (email, push, webhook) instead of finding out days later.
  • cron's own PATH is minimal (no shell profile is sourced), so use the script's full path in the crontab line, not just ndm_linux-backup, even if you symlinked it into your normal PATH (see Installation).

Analyze

sudo ndm_linux-backup --analyze reports, for your current CONFIGURE settings, which modules will actually be backed up, which are enabled but blocked by something (a missing tool, a missing .my.cnf/.pgpass, no LUKS partitions found, v_dest_rsync not set...), which are simply disabled but would work if turned on, and which are disabled and would still be blocked even if turned on because this machine lacks the matching hardware/firmware (no UEFI, no MBR-partitioned disks, no LUKS partitions, no LVM) -- then exits. Grouped into a section per archive (settings/data/secrets) plus a cross-cutting one for rsync/hooks, so it's clear at a glance what each archive would actually contain. Each archive's section also states its working directory (v_tempdir/_data/ _secrets -- where the archive is staged before tar) and its destination (v_dest/_data/_secrets -- where the final .tar.xz lands), so you can confirm the paths a real run would use before it uses them. Unlike --dry-run, it never runs a single real backup command (no lshw, no schema dumps, nothing): it only checks applicability and prints the already-configured paths, without creating or touching any of them -- no v_tempdir, no v_dest, no log file actually gets written or created, so it's near-instant and reads/writes nothing beyond the check itself, regardless of --quiet/--keep/--dry-run (all irrelevant to it) or whether it's combined with --start (--analyze always wins and no backup runs). --secrets is honored, so it reflects whether the secrets archive would actually be produced for that run, and it also reports which of the archives that will actually be produced end up GPG-encrypted or stay plain -- the same check a real run does, see Encryption.

Note

The MySQL/PostgreSQL credentials check is one exception to "no real command ever runs": --analyze never attempts the live root@localhost socket/peer-auth connection a real --start does (see MySQL/MariaDB backup and PostgreSQL backup), so it can report .my.cnf/.pgpass as missing even on a machine where a real run would still succeed via that fallback. The reported reason always names the fallback so this doesn't read as a hard blocker.

Restore

There is deliberately no single "restore everything" command. NDM Linux Backup collects; putting things back on a real machine is a judgment call every time (matching disk, matching package versions, whether the target is even the same machine) that shouldn't be automated blindly. Restoring is: extract the archive you need, read what's in it, apply the specific piece you actually need by hand.

Before relying on an old archive, verify it against its checksum sidecar (see Safety) -- against whatever file is actually on disk, .tar.xz or .tar.xz.gpg if encryption is on, since the checksum covers the final artifact, not necessarily the plaintext:

sha256sum -c ndm_linux-backup_myhost_20260815-120000.tar.xz.sha256

If the archive is encrypted (filename ends in .tar.xz.gpg, see Encryption), decrypt it first, on whatever machine you're actually restoring from -- the private key was deliberately never needed on the backup machine itself:

gpg --output ndm_linux-backup_myhost_20260815-120000.tar.xz --decrypt ndm_linux-backup_myhost_20260815-120000.tar.xz.gpg

This works as-is if the matching private key is already in your own keyring (gpg --list-secret-keys). If it isn't -- kept instead in a file, or on a removable USB drive, specifically so it never sits on a regularly-connected machine -- import it into an isolated, throwaway keyring for just this operation instead of merging it into your everyday ~/.gnupg:

export GNUPGHOME="$(mktemp -d)"
gpg --import /media/usb/ndm-linux-backup-private-key.asc
gpg --output ndm_linux-backup_myhost_20260815-120000.tar.xz --decrypt ndm_linux-backup_myhost_20260815-120000.tar.xz.gpg
rm -rf "${GNUPGHOME}"
unset GNUPGHOME

The rm -rf "${GNUPGHOME}" at the end discards the temporary keyring (and the imported private key with it) -- the USB drive or key file stays the only permanent copy, exactly the point of keeping it offline in the first place.

Every archive extracts to a folder named after itself:

tar -xJf ndm_linux-backup_myhost_20260815-120000.tar.xz -C /path/to/extract

Every path below (software/, modified_files/, partitions/, mbr/, grub/...) is relative to v_tempdir inside that folder -- by default home/ndm_linux-backup/ndm_linux-backup/, since v_tempdir is an absolute path and tar recreates it in full. The actual report lives there; etc/, root/, opt/... at the top level is the raw a_source_extra sweep, not the report itself.

Settings archive

Per-item restore reference (click to expand)
  • Packages (software/apt_manual.txt, apt_auto.txt): reinstall with sudo apt-get install $(cat apt_manual.txt). This only restores package names, not pinned versions -- fine after a clean reinstall on the same Debian release, not a guarantee of byte-identical package versions.
  • Modified/unpackaged files (modified_files/, unpackaged_files/, both mirroring the real /etc layout): review each file and copy back individually (diff against the live file first). Never extract this tree directly over a live /etc -- it was captured at one point in time and may conflict with changes made since. unpackaged_files/etc/machine-id specifically: only restore this to the same machine you backed it up from (e.g. after a clean reinstall) -- /etc/machine-id is meant to be globally unique per install, so copying it onto a different machine can confuse D-Bus, systemd-journal, and DHCP client identifiers derived from it.
  • Partition table (partitions/sfdisk_<disk>.txt): sfdisk /dev/<disk> < sfdisk_<disk>.txt recreates the partition layout -- destructive, and only meaningful on a disk of the same size/geometry as the original. partitions/fdisk_<disk>.txt is the human-readable counterpart, reference only.
  • MBR/boot sector (mbr/<disk>_mbr.bin): dd if=<disk>_mbr.bin of=/dev/<disk> bs=1M count=1 -- same caveat, only on the exact right disk, only if you understand this overwrites the current boot sector.
  • UEFI (efi/efibootmgr.txt, efi/boot_efi/): efibootmgr.txt is reference only, recreate NVRAM boot entries by hand with efibootmgr --create if needed. boot_efi/ is a real copy of the EFI System Partition content and can be copied back (cp -a boot_efi/. /boot/efi/) -- only onto the exact same or an equivalently-partitioned UEFI system, after mounting the real ESP yourself, never guessing the mountpoint. Getting this wrong on a live ESP can leave the system unable to boot.
  • LUKS (luks/luksDump_<disk>.txt, luksUUID_<disk>.txt, crypttab.txt): reference only, not a restorable header backup -- this is cryptsetup luksDump's human-readable text output, not a raw luksHeaderBackup binary dump. If a LUKS header actually gets corrupted, this tells you the cipher/key-slot layout to recreate an equivalent container by hand, but it cannot reconstruct the original header/keyslots byte-for-byte. For that, see the raw LUKS header backup in the secrets archive below.
  • LVM (lvm/pvdisplay.txt, vgdisplay.txt, lvdisplay.txt, lvmdump/): reference only, not a restorable backup either -- human-readable pvdisplay/vgdisplay/lvdisplay output plus an lvmdump diagnostic bundle, not raw metadata. Use them to recreate the PV/VG/LV layout by hand (pvcreate/vgcreate/ lvcreate matching the recorded names/sizes) on a fresh disk; unlike LUKS, there is no raw-metadata backup option for this.
  • Grub (grub/boot_grub/, grub/default_grub.txt): copy boot_grub/ back to /boot/grub and default_grub.txt to /etc/default/grub, then run update-grub -- prefer regenerating over blindly trusting the copied files if the kernel versions differ from the original. grub-install /dev/<disk> only if the boot sector itself needs reinstalling too, only on the same or an equivalent disk: a wrong disk/version combination can leave the system unable to boot.
  • MySQL/PostgreSQL schema (mysql_schema/schema_<db>.sql or schema_all.sql; postgresql_schema/schema_<db>.sql/schema_all.sql): mysql --defaults-file=.my.cnf < schema_db.sql / psql -f schema_db.sql dbname recreates structure (tables/views/triggers/routines), no rows. Restore schema before data (see data archive below) -- data restore assumes the tables already exist.
  • Docker/Podman (docker/, podman/, docker_compose_files/): the metadata files are reference only; actual recovery is docker compose up -d (or podman-compose/Quadlet) from the copied compose/unit files, which pulls fresh images from the registry -- images/containers themselves were never backed up, see What it backs up.
  • Crontab (crontab.txt): crontab crontab.txt restores root's schedule. Low risk -- review the file first if it references paths that may no longer exist on the target.
  • Flatpak (flatpak.txt): reference only, reinstall listed apps with flatpak install <app-id>.
  • a_source_extra directories: copied as-is under their own path inside the archive, restore by copying back to their original location. Same caution as Modified/unpackaged files above applies if any of them fall under /etc or /root: review before extracting over a live system.

Data archive

Same as the settings archive above: every path below is relative to v_tempdir_data inside the extracted folder -- by default home/ndm_linux-backup/ndm_linux-backup_data/.

Per-item restore reference (click to expand)
  • MySQL/PostgreSQL row data (mysql/data_<db>.sql or data_all.sql; postgresql/data_<db>.sql/ data_all.sql): import after the matching schema exists (from the settings archive, or recreated by hand): mysql --defaults-file=.my.cnf dbname < data_db.sql / psql -f data_db.sql dbname.
  • Docker/Podman volume content (docker_volumes/<name>/, podman_volumes/<name>/): create a fresh, empty volume first (docker volume create <name>), find its real Mountpoint (docker volume inspect <name>), then copy the backed-up content into it -- cp -a docker_volumes/<name>/. <mountpoint>/. Check ownership/ permissions afterward: they need to match what the container expects, which cp -a alone won't guarantee if UIDs differ between machines.
  • a_data_source_extra directories: copied as-is, restore by copying back to their original path.

Secrets archive

files/ itself is relative to v_tempdir_secrets inside the extracted folder -- by default home/ndm_linux-backup/ndm_linux-backup_secrets/files/. Every entry under it keeps its original absolute path (e.g. files/etc/ssh/ssh_host_rsa_key, files/root/.ssh/id_ed25519), so restoring one item is copying it back to that same path and fixing ownership/permissions (SSH host keys: 600 root:root; /etc/shadow//etc/gshadow: 640 root:shadow).

Per-item restore reference (click to expand)
  • SSH host keys: restoring the original key (not regenerating a new one) means clients that already trust this machine's fingerprint won't see a "REMOTE HOST IDENTIFICATION HAS CHANGED" warning after a reinstall -- often the actual reason to restore these specifically. chmod 600, chown root:root, restart sshd.
  • /root/.ssh (root's own keys, distinct from the SSH host keys above): 600 on private keys, 644 on authorized_keys/known_hosts, owned by root. Restoring an old authorized_keys can silently reinstate access for a key you meant to revoke -- review its contents before restoring, don't copy it back blindly.
  • /etc/shadow//etc/gshadow: do not extract these over a live system's files. A mismatched or malformed /etc/shadow can lock out every account or break PAM outright. If you need a specific account's password hash back, restore it surgically (e.g. usermod -p '<hash>' username) instead of replacing the whole file; only replace the file wholesale from an offline context (recovery media/chroot), never on a running system.
  • TLS private keys / Let's Encrypt / network credentials / cloud CLI configs: copy back to their original path, restart the service that uses them.
  • Mail relay / PPP secrets (exim4/passwd.client, postfix/sasl_passwd(.db), ppp/chap-secrets, ppp/pap-secrets): copy back to their original path, restart the matching service. For Postfix specifically, regenerate sasl_passwd.db from the plaintext file with postmap instead of trusting the copied .db binary matches the target machine's Postfix version.
  • /root/.gnupg: restoring this onto a keyring that already has other keys can produce duplicate or conflicting trust settings. Restore onto a fresh /root/.gnupg, or gpg --import just the specific key you need, rather than overwriting an in-use keyring.
  • /root/.docker/config.json: copy back to restore saved registry logins. Contains registry credentials (plaintext or base64-encoded depending on the registry) -- handle the extracted file with the same care as the rest of this archive.
  • .my.cnf/.pgpass (this script's own database credentials): copy back to v_mysql_cnf_path/ v_postgresql_pgpass_path, 600 root:root. Only useful if the target machine's database host/user match what's inside -- otherwise NDM Linux Backup just fails to connect on the next run, same as a missing credentials file (see Safety).
  • /etc/mysql/debian.cnf: MariaDB/MySQL's own internal maintenance credentials, copy back verbatim -- do not edit it, package maintenance scripts (logrotate, mysql_upgrade...) expect its exact original contents.
  • /root/.netrc: plaintext auto-login credentials for whatever tools read it (curl, ftp...), copy back with 600 root:root -- same handling as any other plaintext-credential file in this archive.
  • Raw LUKS header (luks_headers/<disk>_luksHeaderBackup.img, only present if v_enable_luks_header_backup=true): cryptsetup luksHeaderRestore /dev/<disk> --header-backup-file <disk>_luksHeaderBackup.img writes it back -- only when you are certain the target really is the same volume (matching UUID, checked with cryptsetup luksUUID); restoring a header onto the wrong device makes it unreadable, it doesn't merely fail safely.

Warning

Regardless of which archive: never extract any of them directly onto / of a live system. Extract to a scratch directory first, review what you actually need, then apply it by hand.

Sample output

sudo ./ndm_linux-backup.sh --analyze on a small Debian 13 virtual machine (BIOS, one disk, no LUKS or LVM) with the default settings; host name and paths are generic:

Your system is based on: bios
Your partition type for /dev/vda is: dos

NDM Linux Backup 3.0.1 -- analysis for root@myhost (run --start to actually back up)

=== SETTINGS ARCHIVE (always produced) ===
Working directory (src): /home/ndm_linux-backup/ndm_linux-backup
Destination (dest): /home/user/bin/NDM_Linux-Backup/backup
Enabled: hardware, devices, lsblk, blkid, inxi, mbr, os_release, kernel, partitions, grub, packages, modified_files, unpackaged_files, cron, source_extra
Enabled but blocked:
  efi                  -- not a UEFI system
Disabled (could enable in the settings): flatpak, appimages, mysql, postgresql, docker, docker_compose, podman
Not applicable to this machine:
  luks                 -- no LUKS partitions found
  lvm                  -- no LVM volumes detected

=== DATA ARCHIVE (opt-in) ===
Working directory (src): /home/ndm_linux-backup/ndm_linux-backup_data
Destination (dest): /home/user/bin/NDM_Linux-Backup/backup
Master switch (v_enable_data_backup): disabled
Enabled: (none)
Disabled (could enable in the settings): docker_volumes_data, podman_volumes_data, data_source_extra

=== SECRETS ARCHIVE (opt-in) ===
Working directory (src): /home/ndm_linux-backup/ndm_linux-backup_secrets
Destination (dest): /home/user/bin/NDM_Linux-Backup/backup
Master switch (v_enable_secrets_backup): disabled (pass --secrets to include it for this run)
Enabled: (none)
Not applicable to this machine:
  luks_header_backup   -- no LUKS partitions found

=== OTHER (cross-cutting) ===
Enabled: (none)
Disabled (could enable in the settings): rsync, pre_command, post_command, on_failure_command

Every archive has its own section: what will be backed up, what is switched on but cannot run on this machine (and why), what is switched off, and what this machine does not have. Nothing is written and no backup command runs (see Analyze).

External services contacted

None on its own: a backup run works offline and sends nothing anywhere. The network is used only by what you choose to enable:

Service When What it receives
Debian package mirrors (apt-get) Only if you accept installing missing tools The usual package requests
Your v_dest_rsync destination Only with v_enable_rsync=true and a remote destination The archives
Your database servers Only with MySQL/MariaDB or PostgreSQL enabled (normally localhost) The credentials in .my.cnf/.pgpass
Whatever your hook commands call Only with v_pre_command/v_post_command/v_on_failure_command enabled (see Notifications) What you put in the command

--analyze, --dry-run, --requirements, --help, --usage and --version contact nothing.

Exit codes

Code Meaning
0 The backup completed; also --analyze, --requirements, --help, --usage, --version, no arguments, and a prompt answered No or Cancel
1 An error: not run as root, missing required tool, missing database credentials or GPG key, unsafe working directory, failed archive or encryption step
2 Invalid command-line usage (unknown option, unexpected argument, -n/-q/-k/--secrets without --start or --analyze), or an invalid setting in ndm_linux-backup.conf
130 Interrupted (Ctrl-C): files already collected are kept for the next run
143 Terminated (SIGTERM): the same

For unattended runs, v_on_failure_command reports failures as they happen (see Configuration and Notifications).

Project layout

NDM_Linux-Backup/
├── ndm_linux-backup.sh            the script
├── ndm_linux-backup.conf.example  settings file template (versioned)
├── ndm_linux-backup.conf          your settings (personal, ignored by Git)
├── my.cnf.example                 MySQL/MariaDB credentials template
├── pgpass.example                 PostgreSQL credentials template
├── backup/                        default destination of the archives (contents ignored by Git)
│   └── .gitkeep                   keeps the empty folder in the repository
├── .github/workflows/ci.yml       automatic checks (ShellCheck, checkbashisms, version, smoke tests)
├── .github/dependabot.yml         monthly updates of the GitHub Actions used by CI
├── .gitattributes                 keeps Linux line endings
├── .gitignore                     keeps your settings, credentials and archives out of Git
├── LICENSE                        GNU AGPL v3
├── CHANGELOG.md                   changes in each version
├── CITATION.cff                   how to cite this software (used by GitHub)
├── SECURITY.md                    how to report a vulnerability
├── README.md                      documentation in English
└── README.it.md                   documentation in Italian

Versioning

The project follows Semantic Versioning. Version and release date are in the APP INFO block at the very top of ndm_linux-backup.sh, with name, description and author; --version shows them. Every release has a Git tag vX.Y.Z, and the changes of each version are listed in CHANGELOG.md. Versions up to 2.0.10 were published as NDM Backup on GitLab.

License and disclaimer

Copyright (C) 2020-2026 Nicola Davide Mannarelli (@nidamanx)

This program is free software: you can redistribute it and/or modify it under the terms of the GNU Affero General Public License as published by the Free Software Foundation, either version 3 of the License, or (at your option) any later version (SPDX-License-Identifier: AGPL-3.0-or-later). See the LICENSE file.

Credit. If you use this tool, or parts of its code, in your work or in another project, please credit NDM Linux Backup by Nicola Davide Mannarelli and link to this repository. The Cite this repository button on GitHub, generated from CITATION.cff, gives a ready-made citation. The license already requires redistributed and modified copies to keep the copyright notice.

Disclaimer. This software is provided "as is", without warranty of any kind, express or implied. In no event shall the author be liable for any claim, damages or other liability, including loss of data, backups that are incomplete or cannot be restored, and systems that no longer boot after a restore, arising from the use or misuse of this software. You alone are responsible for how you use it, for keeping and protecting the archives it creates, and for checking that they can be restored.

About

System backup and disaster recovery for Debian/GNU Linux: saves what most backup tools leave out (hardware, UEFI/BIOS/MBR, partitions, LUKS, LVM, packages, modified /etc files, database schemas, Docker/Podman). Single Bash script.

Topics

Resources

Security policy

Stars

1 star

Watchers

0 watching

Forks

Releases

Used by

Contributors

Languages