LVM Snapshots for Live Database Backups: Hot Backups Without Dropping Writes

Your database is 200 GB. pg_dump takes 45 minutes and locks your analytics queries. mysqldump with --single-transaction is fine for InnoDB, except you also have some MyISAM tables left over from 2017 that nobody wants to touch. Your manager wants nightly backups. Your users want zero downtime. Pick one, right?

Wrong. LVM snapshots let you have both.

The trick is that a snapshot isn’t a full copy — it’s a moment frozen in time at the block level, created in milliseconds. You freeze writes for two seconds, snapshot, unfreeze, then take your time backing up the snapshot while the live database runs normally. The backup sees a perfectly consistent filesystem from the moment of the snapshot, not the rolling disaster that a 45-minute dump creates.

This isn’t a new idea — it’s been battle-tested in production environments for over a decade. But the setup is fiddly enough that most guides either skip the database-specific consistency parts or skip the automation. This guide covers both.

How LVM Snapshots Actually Work

LVM snapshots use copy-on-write (CoW). When you create a snapshot of a logical volume, LVM doesn’t copy any data. Instead, it records the state of the metadata and sets up a CoW region. From that moment on, every time a block on the original volume is modified, LVM first copies the original block into the snapshot’s reserved space, then allows the write to proceed on the original.

Your snapshot always reflects the state at creation time because it keeps the old blocks. The original volume keeps running normally.

The catch: snapshot storage. You need to preallocate space for the CoW region. If the database churns heavily and fills that region before you finish the backup, the snapshot becomes invalid. More on sizing this correctly in the Gotchas section.

The other catch: filesystem consistency. LVM doesn’t know about your database. If PostgreSQL is in the middle of writing a WAL record when you snapshot, the snapshot contains a filesystem that’s mid-write. Most databases handle this via crash recovery, but you need the filesystem itself to be in a clean, sync’d state at snapshot time — which is what fsfreeze gives you.

Prerequisites

You need:

  • Your database data directory on an LVM logical volume (not on a plain partition or a RAID device managed outside LVM)
  • Enough free space in the volume group for the snapshot CoW region
  • lvm2 package installed (almost certainly already there)
  • xfsprogs or e2fsprogs for fsfreeze (comes with the filesystem tools)
  • Root or sudo access

Check your setup:

# Confirm the data directory is on LVM
df -T /var/lib/postgresql
# Should show 'ext4' or 'xfs' on /dev/mapper/something

# Find your VG and LV names
lvdisplay /dev/mapper/data-pgdata
# Or just: lsblk -f

# Check free space in the VG
vgdisplay vg_data | grep "Free  PE"

If your data directory is on a plain partition (like /dev/sdb1), you can’t snapshot it without migrating — that’s a separate article. If it’s already LVM, you’re good.

The Freeze-Snapshot-Unfreeze Window

This is the entire trick. The window where writes are blocked is exactly this sequence:

fsfreeze --freeze /var/lib/postgresql   # ~0ms, blocks new writes
lvcreate --snapshot ...                 # ~100-500ms, metadata operation
fsfreeze --unfreeze /var/lib/postgresql # ~0ms, resumes writes

On a modern SSD-backed system, the total freeze is under a second. On spinning disks with a busy database, budget 2-5 seconds. PostgreSQL and MySQL both queue incoming connections during this window — they don’t error out, they just briefly stall. Clients usually don’t even notice.

For PostgreSQL, you can do better by using the WAL backup API instead of fsfreeze. For MySQL InnoDB, FLUSH TABLES WITH READ LOCK combined with the snapshot is the standard approach. We’ll cover both.

Step-by-Step: PostgreSQL

PostgreSQL since version 15 uses pg_backup_start() / pg_backup_stop() (the old pg_start_backup() is deprecated). The backup API tells PostgreSQL to write a checkpoint and start flagging WAL files needed for recovery — this makes the snapshot crash-safe even without a full filesystem freeze.

1. Signal PostgreSQL to enter backup mode

psql -U postgres -c "SELECT pg_backup_start('lvm-snapshot', fast := true);"

The fast := true forces an immediate checkpoint. Without it, PostgreSQL waits for the next scheduled checkpoint, which could be minutes away. The checkpoint ensures all dirty pages are flushed to disk before you snapshot.

2. Freeze the filesystem and snapshot

# Freeze writes at the filesystem level
fsfreeze --freeze /var/lib/postgresql

# Create the snapshot (adjust VG, LV names and size to your setup)
lvcreate \
  --snapshot \
  --name pgdata-snap \
  --size 20G \
  /dev/vg_data/pgdata

# Unfreeze immediately
fsfreeze --unfreeze /var/lib/postgresql

The --size 20G is the CoW region, not the snapshot size. See Gotchas for how to pick this number.

3. Exit backup mode

psql -U postgres -c "SELECT pg_backup_stop(wait_for_archive := true);"

This writes a backup label file and stop WAL position. The wait_for_archive := true waits for WAL archiving to catch up if you have archiving configured — if you don’t, just use false.

4. Mount the snapshot and back it up

mkdir -p /mnt/pgsnap

# Mount read-only — always
mount -o ro,nouuid /dev/vg_data/pgdata-snap /mnt/pgsnap

# Back up using rsync, tar, or whatever you prefer
tar czf /backup/pgdata-$(date +%Y%m%d-%H%M%S).tar.gz \
  --exclude=/mnt/pgsnap/postmaster.pid \
  -C /mnt/pgsnap .

# Unmount and remove the snapshot
umount /mnt/pgsnap
lvremove -f /dev/vg_data/pgdata-snap

The nouuid mount option is required for XFS because XFS embeds the filesystem UUID and refuses to mount two volumes with the same UUID simultaneously.

Step-by-Step: MySQL / MariaDB

MySQL’s story is messier because it depends heavily on which engine you’re using. For pure InnoDB, --single-transaction in mysqldump is actually fine. But the moment you have MyISAM tables (including mysql.* system tables on older versions), you need a real lock.

InnoDB-only setup

# Get a consistent InnoDB state and record binlog position
mysql -u root -e "FLUSH TABLES WITH READ LOCK;"

# In a separate shell immediately after:
mysql -u root -e "SHOW MASTER STATUS\G" > /backup/binlog-pos-$(date +%Y%m%d).txt

# Freeze + snapshot
fsfreeze --freeze /var/lib/mysql
lvcreate --snapshot --name mysql-snap --size 15G /dev/vg_data/mysql
fsfreeze --unfreeze /var/lib/mysql

# Release the lock
mysql -u root -e "UNLOCK TABLES;"

The binlog position file is your point-in-time marker. If you restore the snapshot and replay binlogs from that position, you can recover to any point after the snapshot.

With Percona XtraBackup (the better option for MySQL)

Percona’s XtraBackup does this entire dance internally and handles edge cases you don’t want to think about. If you’re serious about MySQL backups, use it:

apt install percona-xtrabackup-80   # for MySQL 8.0

xtrabackup --backup \
  --target-dir=/backup/mysql-$(date +%Y%m%d) \
  --user=root \
  --socket=/var/run/mysqld/mysqld.sock

xtrabackup --prepare --target-dir=/backup/mysql-$(date +%Y%m%d)

XtraBackup performs its own hot copy without LVM at all. It’s worth knowing when to use which tool: LVM snapshots are storage-agnostic and work regardless of database type; XtraBackup is MySQL/Percona-specific but handles tablespace metadata better.

The Automation Script

Here’s a production-usable script for PostgreSQL. Drop it in /usr/local/bin/pg-lvm-backup.sh, make it executable, and run it from cron or systemd timer.

#!/bin/bash
# pg-lvm-backup.sh — LVM snapshot backup for PostgreSQL
# Requires: lvm2, postgresql-client, rsync/tar

set -euo pipefail

# ── Configuration ────────────────────────────────────────────────────────────
PG_USER="postgres"
PG_HOST="/var/run/postgresql"
LV_PATH="/dev/vg_data/pgdata"       # Source logical volume
SNAP_NAME="pgdata-snap"
SNAP_SIZE="20G"                      # CoW region size (NOT data size)
MOUNT_POINT="/mnt/pgsnap"
BACKUP_DIR="/backup/postgres"
RETENTION_DAYS=7
LOG="/var/log/pg-lvm-backup.log"
# ─────────────────────────────────────────────────────────────────────────────

log() { echo "[$(date '+%Y-%m-%d %H:%M:%S')] $*" | tee -a "$LOG"; }

cleanup() {
    log "Cleaning up..."
    mountpoint -q "$MOUNT_POINT" && umount "$MOUNT_POINT" || true
    lvdisplay "${LV_PATH%/*}/$SNAP_NAME" &>/dev/null && \
        lvremove -f "${LV_PATH%/*}/$SNAP_NAME" || true
}
trap cleanup EXIT

mkdir -p "$MOUNT_POINT" "$BACKUP_DIR"

log "Starting backup"

# Step 1: Checkpoint and enter backup mode
log "Entering PostgreSQL backup mode (forced checkpoint)"
psql -U "$PG_USER" -h "$PG_HOST" -d postgres \
    -c "SELECT pg_backup_start('lvm-snap', fast := true);" -q

# Step 2: Freeze, snapshot, unfreeze — minimize the window
DATA_DIR=$(psql -U "$PG_USER" -h "$PG_HOST" -d postgres \
    -tAc "SHOW data_directory;")

log "Freezing filesystem: $DATA_DIR"
fsfreeze --freeze "$DATA_DIR"

log "Creating LVM snapshot"
lvcreate --snapshot --name "$SNAP_NAME" --size "$SNAP_SIZE" "$LV_PATH"

log "Unfreezing filesystem"
fsfreeze --unfreeze "$DATA_DIR"

# Step 3: Exit backup mode (WAL position recorded here)
log "Exiting PostgreSQL backup mode"
STOP_LSN=$(psql -U "$PG_USER" -h "$PG_HOST" -d postgres \
    -tAc "SELECT lsn FROM pg_backup_stop(wait_for_archive := false);")
log "Backup stop LSN: $STOP_LSN"

# Step 4: Mount and transfer
log "Mounting snapshot read-only"
mount -o ro,nouuid "${LV_PATH%/*}/$SNAP_NAME" "$MOUNT_POINT"

TIMESTAMP=$(date +%Y%m%d-%H%M%S)
DEST="$BACKUP_DIR/pgdata-$TIMESTAMP"

log "Transferring data to $DEST"
rsync -a --delete \
    --exclude='postmaster.pid' \
    --exclude='postmaster.opts' \
    "$MOUNT_POINT/" "$DEST/"

echo "$STOP_LSN" > "$DEST/BACKUP_STOP_LSN"
log "Transfer complete"

# Step 5: Prune old backups
log "Pruning backups older than $RETENTION_DAYS days"
find "$BACKUP_DIR" -maxdepth 1 -name 'pgdata-*' \
    -mtime +"$RETENTION_DAYS" -exec rm -rf {} +

log "Backup finished: $DEST"

Wire it to a systemd timer:

# /etc/systemd/system/pg-lvm-backup.timer
[Unit]
Description=PostgreSQL LVM snapshot backup — nightly at 02:00

[Timer]
OnCalendar=02:00
RandomizedDelaySec=300
Persistent=true

[Install]
WantedBy=timers.target
# /etc/systemd/system/pg-lvm-backup.service
[Unit]
Description=PostgreSQL LVM snapshot backup
After=network.target postgresql.service

[Service]
Type=oneshot
ExecStart=/usr/local/bin/pg-lvm-backup.sh
User=root
StandardOutput=journal
StandardError=journal
systemctl daemon-reload
systemctl enable --now pg-lvm-backup.timer

Gotchas

Snapshot size is not database size. The CoW region needs to hold the delta — the blocks that change on the original volume while your backup is running. For a 200 GB database that does 500 MB/minute of writes, a 2-hour backup needs at minimum 60 GB of CoW space, plus headroom. Size it at 20-30% of the volume if you don’t know your write rate; monitor it with lvs -o snap_percent.

A full snapshot is a dead snapshot. If the CoW region fills up, LVM marks the snapshot invalid. Your backup will contain garbage at that point. Add a check in your script:

SNAP_DEV="${LV_PATH%/*}/$SNAP_NAME"
SNAP_USED=$(lvs --noheadings -o snap_percent "$SNAP_DEV" | tr -d ' ')
if (( $(echo "$SNAP_USED > 80" | bc -l) )); then
    log "WARNING: snapshot at ${SNAP_USED}% — increase SNAP_SIZE"
fi

XFS requires nouuid on mount. Already mentioned, but people forget this and waste 30 minutes debugging. ext4 doesn’t have this problem.

fsfreeze only works on mounted filesystems. You’re freezing the filesystem, not the block device. The path you pass must be a mount point or a directory on a filesystem — not a device path.

PostgreSQL’s pg_backup_start is not a substitute for fsfreeze on its own. pg_backup_start makes the data files consistent from a recovery standpoint, but if you don’t flush the filesystem, you might snapshot mid-write at the OS buffer cache level. On Linux with modern ext4/XFS, the combination of pg_backup_start (forces a checkpoint) and then fsfreeze (flushes page cache and blocks new writes) gives you a fully clean snapshot.

Don’t leave snapshots mounted when you’re done. Snapshots accumulate CoW overhead as long as they exist. An old snapshot sitting around while the database does heavy writes will drain your VG free space and potentially kill the snapshot. The script above handles this with the trap cleanup EXIT.

Replication slaves are easier targets. If you run streaming replication, run your LVM snapshots on a replica, not the primary. The replica still needs the freeze-snapshot-unfreeze window, but you avoid any performance impact on your primary.

Monitoring in Production

Add this to your backup script to get an alert when something fails:

# At the top of the script, after set -euo pipefail
on_failure() {
    local exit_code=$?
    log "BACKUP FAILED with exit code $exit_code"
    # Uncomment to send email:
    # echo "pg-lvm-backup failed at $(hostname)" | mail -s "BACKUP FAILURE" [email protected]
    exit "$exit_code"
}
trap on_failure ERR

Also set up a monitoring check that alerts if no successful backup exists in the last 25 hours — dead backup scripts that silently fail are the #1 reason people discover their backups don’t work during a real outage.

# Check for a backup newer than 25 hours
find /backup/postgres -maxdepth 1 -name 'pgdata-*' -mmin -1500 | grep -q . || \
    echo "CRITICAL: no recent backup found" >&2

Recovery

A recovery procedure that you haven’t tested is not a backup — it’s a hope. Test this:

# Simulate recovery on a test box
rsync -a /backup/postgres/pgdata-20260522-020001/ /var/lib/postgresql/14/main/
chown -R postgres:postgres /var/lib/postgresql/14/main/

# For PostgreSQL: create recovery.conf or recovery signal
touch /var/lib/postgresql/14/main/recovery.signal

systemctl start postgresql
# Watch logs for "database system is ready to accept connections"

For point-in-time recovery using WAL archived after the snapshot, set restore_command in postgresql.conf pointing to your WAL archive. The BACKUP_STOP_LSN file the script writes tells you where WAL replay should start.

LVM snapshots plus WAL archiving is a complete backup strategy: snapshots give you a weekly or nightly base, WAL gives you point-in-time recovery down to the transaction. That’s production-grade coverage without a maintenance window, without a commercial backup product, and without depending on database-specific tooling beyond what ships with PostgreSQL itself.

👁 Views: 112,862 · Unique visitors: 45,459