Guide

Backing up a Linux server without backing up the whole disk

Provider snapshots die with the account and a full disk image wastes most of its bytes. What to actually capture on a VPS, what to skip, and how to prove a rebuild works.

7 min readBackup Data

Two default strategies, both worse than they look.

Provider snapshots. DigitalOcean, Hetzner, Linode and Vultr will all image your droplet nightly for a few dollars. This is genuinely useful and you should probably keep it on. It is also stored in the same account as the server, deleted by the same compromised credentials, and gone if the provider suspends you over a billing dispute. It protects against you breaking the server. It does not protect against losing the account.

A full disk image. Comprehensive, and mostly waste. A 40 GB Ubuntu server holds maybe 2 GB you could not rebuild from a package manager in ten minutes. You are paying to store /usr/lib over and over, and the restore path — write an image to a new disk — is slower and more brittle than reinstalling and restoring the parts that mattered.

The useful question is not "how do I copy the whole disk" but "what on this machine could I not recreate?" On most servers that list is short:

  1. Configuration you edited by hand
  2. Data your users created
  3. The list of what is installed
  4. Credentials and certificates
  5. Scheduled jobs

Everything else is a package download.

1. Work out what is actually on the box

Before writing a script, look:

# Biggest directories, excluding the OS
du -h --max-depth=2 /var /opt /srv /home 2>/dev/null | sort -rh | head -20

# What is listening, so you find services you forgot about
ss -tlnp

# What starts on boot
systemctl list-unit-files --state=enabled --type=service

That third command finds things people miss for years — a Redis nobody documented, a cron container, a stray Postgres from a proof of concept that is now load-bearing.

2. Capture the five things

mkdir -p ./server-backup/{etc,data,meta,dumps}

Configuration:

tar cf ./server-backup/etc/etc.tar -C / etc

/etc is small, changes rarely, and is where every hand-edit lives: nginx vhosts, sshd config, systemd units, fstab, netplan. Uncompressed tar so deduplication works — gzip output changes completely after the first altered byte, which makes a barely-changed archive look brand new to a chunker.

Data:

tar cf ./server-backup/data/www.tar   -C / var/www
tar cf ./server-backup/data/home.tar  -C / home
tar cf ./server-backup/data/apps.tar  -C / opt/myapp srv

Adjust to what step 1 actually found.

The install list, not the installs:

# Debian / Ubuntu
apt-mark showmanual > ./server-backup/meta/packages.txt
# RHEL / Fedora / Rocky
# dnf repoquery --userinstalled --qf '%{name}' > ./server-backup/meta/packages.txt

# Third-party repos, or half your packages will not install on restore
tar cf ./server-backup/meta/apt-sources.tar -C / etc/apt/sources.list.d

apt-mark showmanual lists what someone deliberately installed rather than every transitive dependency. Restoring is then one command instead of a 40 GB image.

Scheduled jobs:

for u in $(cut -f1 -d: /etc/passwd); do
  crontab -u "$u" -l 2>/dev/null | sed "s/^/# user: $u\n/" 
done > ./server-backup/meta/crontabs.txt

systemctl list-timers --all > ./server-backup/meta/timers.txt

User crontabs live in /var/spool/cron and are not in /etc. This is the single most commonly missed item on a server rebuild: everything works, and then a week later someone notices the nightly report stopped arriving in March.

Databases — dump, never copy:

pg_dump --format=custom --username=postgres app_db > ./server-backup/dumps/app_db.dump
mysqldump --single-transaction --user=root app_db > ./server-backup/dumps/app_db.sql

Copying /var/lib/postgresql from a running server gives you a torn file. See the Postgres and MySQL guides for the consistency rules.

3. What to deliberately skip

/proc  /sys  /dev  /run       virtual, not real files
/tmp  /var/tmp                by definition disposable
/var/cache  /var/lib/apt      re-downloadable
/swapfile                     gigabytes of nothing
/var/lib/docker               images rebuild; volumes need their own handling
/var/lib/mysql  /var/lib/postgresql   dump these instead, never copy live

/var/lib/docker deserves its own note: backing it up wholesale captures image layers you can pull again and volume data in an inconsistent state. Handle volumes properly — see Docker volume backups.

4. Get it off the server

Everything so far lands in ./server-backup, still on the machine you are protecting. Push it somewhere with credentials the server's provider does not control.

Sign in at Backup Data, claim the free 5 GB workspace, mint an API key scoped backup:write, backup:read, snapshots:read, then:

npm install @lighthouse-web3/baas-js-sdk
export LH_API_KEY="lh_xxxxxxxxxxxxxxxxxxxxxxxx"
export LH_WORKSPACE_ID="your-workspace-uuid"
import { BackupClient, generateKeyfile } from "@lighthouse-web3/baas-js-sdk";

const client = new BackupClient({
  apiKey: process.env.LH_API_KEY,
  workspaceId: process.env.LH_WORKSPACE_ID,
});

const snapshot = await client.backup(["./server-backup"], {
  description: "nightly server backup",
  tags: { type: "server", host: "web-01", env: "prod" },
  encryption: {
    keyfilePath: "/secure/lh.keyfile",
    passphrase: process.env.LH_KEYFILE_PASSPHRASE,
  },
});

console.log(`snapshotId=${snapshot.snapshotId} totalSize=${snapshot.totalSize}`);

(A Go SDK offers the same surface.)

Encrypt, because /etc is full of secrets/etc/shadow, TLS private keys under /etc/ssl and /etc/letsencrypt, SSH host keys, application config with database passwords in it. AES-GCM on your machine; the server stores only ciphertext.

You hold the keys. Lose the keyfile and passphrase and the data is unrecoverable, with no server-side reset. Store the keyfile somewhere other than the server it protects — a keyfile on the box that died is a keyfile you do not have.

Because /etc and the package list barely change, deduplication means a nightly run of this uploads almost nothing after the first.

5. Prove you can rebuild

The test for a server backup is not "did the files come back." It is "can I stand up a working replacement from nothing but this?"

import { mkdtempSync } from "node:fs";
import { tmpdir } from "node:os";
import { join } from "node:path";

const target = mkdtempSync(join(tmpdir(), "server-restore-drill-"));
await client.restore(snapshot.snapshotId, target, {
  onProgress: (e) => console.log(`[${e.phase}] ${e.current}/${e.total}`),
});

Every restore verifies chunk integrity against expected checksums before reassembly.

Then, on a fresh VPS or a local VM with the same OS version:

# 1. Packages
xargs -a "$TARGET/server-backup/meta/packages.txt" apt-get install -y

# 2. Config — selectively. Do NOT untar the whole /etc over a fresh system.
tar xf "$TARGET/server-backup/etc/etc.tar" -C /tmp/etc-restore
cp -r /tmp/etc-restore/etc/nginx/sites-available/* /etc/nginx/sites-available/
cp -r /tmp/etc-restore/etc/letsencrypt /etc/

# 3. Data
tar xf "$TARGET/server-backup/data/www.tar" -C /

# 4. Database
pg_restore --username=postgres --dbname=app_db "$TARGET/server-backup/dumps/app_db.dump"

# 5. Jobs — read crontabs.txt and reinstall them

Restore /etc selectively, not wholesale. Untarring an old /etc over a fresh install overwrites /etc/fstab with UUIDs for disks that do not exist, replaces /etc/passwd with UIDs that do not match, and can leave a machine that will not boot. Copy the directories you actually edited.

Time the whole rebuild with a stopwatch. That wall-clock number is your real RTO — see RPO vs RTO for why the measured number and the assumed number are usually hours apart.

The drill checklist:

  • ✅ Rebuild on a fresh machine, never over the live one
  • ✅ Install from the package list, do not restore /usr
  • ✅ Copy /etc selectively — never overwrite fstab, passwd, shadow
  • ✅ Reinstall crontabs and timers, and confirm they fire
  • ✅ Check file ownership after untarring as root
  • ✅ Load a real page over HTTPS, so certificates are tested too
  • ✅ Record wall-clock time, keep a log across drills
  • ✅ Destroy the test VPS when done, so it stops costing money

6. Schedule it

# /etc/cron.d/server-backup (nightly at 02:00)
0 2 * * * root /opt/backup/server-backup.sh >> /var/log/server-backup.log 2>&1

And set retention, so storage stays bounded:

await client.pruneSnapshots({
  sourceId: snapshot.sourceId,
  keepLatest: 14,
  before: new Date(Date.now() - 30 * 86400_000).toISOString(),
  dryRun: true,                        // drop to false once the preview looks right
});

Then quarterly, do the rebuild drill. Not the file restore — the full rebuild. It is the only thing that tells you whether the list in section 2 is missing something, and it always is the first time.

The pipeline, complete

CapturedWhy not the whole disk
/etcThe only config that was hand-edited
/var/www, /home, /opt, /srvThe only data you cannot re-download
Package listOne command rebuilds /usr, at 1/1000th the bytes
Crontabs and timersNot in /etc, and the most-missed item
Database dumpsA live file copy is a torn file
Off-provider copyProvider snapshots die with the account

Keep the provider snapshots. They are the fastest way back from "I broke the server at 2am." They just are not the answer to "the account is gone," and those are different questions with different answers.

Start with the free tier: backupdata.io has 5 GB free, no card, and the 10-minute quickstart gets you to a first verified snapshot today.

Start with the free tier

5 GB free, no card required. Point your existing dump at Backup Data and get to a first verified snapshot in about ten minutes.

Read the quickstart

Keep reading

Guide · 7 minAutomating backups with GitHub Actions (and when not to)Guide · 6 minBack up model checkpoints before your spot instance disappearsGuide · 6 minBacking up Supabase, Neon and PlanetScale