How To

Bash Script To Automate Linux Directories Backups

A well-written bash script covers 90% of Linux backup needs without installing anything. tar, gzip, and a systemd timer are already on every server. No agents, no daemons, no package repositories to configure.

Original content from computingforgeeks.com - post 82630

The script in this guide handles multiple source directories, three compression options (gzip, bzip2, xz), disk space checks before each run, retention cleanup, lock files to prevent overlapping runs, and config file overrides for different backup profiles. For readers who need encryption or deduplication, Restic with S3 storage and BorgBackup with Borgmatic are better fits. If the goal is syncing files to a remote server on a schedule, the rsync with systemd timer guide covers that.

Tested March 2026 | Ubuntu 24.04.4 LTS and Rocky Linux 10.1. Works on any Linux distribution with bash and systemd.

Prerequisites

  • Any Linux distribution with bash and tar (tested on Ubuntu 24.04.4 LTS and Rocky Linux 10.1)
  • Root or sudo access
  • A backup destination with enough free space: local disk, mounted NFS share, or USB drive

The Backup Script

This script archives multiple directories into a single compressed tarball with a timestamped filename that includes the hostname. Before running, it checks available disk space and acquires a lock file so two instances cannot run simultaneously. After the backup completes, it removes archives older than the configured retention period. Every action is logged with timestamps.

Create the script file:

sudo vi /usr/local/bin/system-backup.sh

Add the following content:

#!/bin/bash

# =====================================================================
# Linux System Backup Script
# =====================================================================
# Creates compressed, timestamped backups of specified directories.
# Supports gzip/bzip2/xz compression, disk space checks, retention
# cleanup, and optional external config files.
#
# Usage: system-backup.sh [config_file]
# =====================================================================

set -euo pipefail

# =====================================================================
# Configuration (override via config file passed as $1)
# =====================================================================
BACKUP_DIR="/backup"
DIRECTORIES_TO_BACKUP=("/etc" "/home" "/root" "/opt")
EXCLUDE_PATTERNS=("*.tmp" "*.swp" "*.swx" ".cache" "__pycache__" "node_modules" "/home/*/Downloads" "/home/*/Trash" "/home/*/.local/share/Trash")
RETENTION_DAYS=30
LOG_FILE="/var/log/system-backup.log"
DATE_FORMAT="%Y-%m-%d_%H-%M-%S"
COMPRESSION_TYPE="gzip"   # gzip, bzip2, or xz
REQUIRED_SPACE_GB=1
LOCK_FILE="/var/run/system-backup.lock"

# =====================================================================
# Functions
# =====================================================================

log() {
    local level="$1"
    shift
    local msg="$*"
    local ts
    ts=$(date "+%Y-%m-%d %H:%M:%S")
    echo "[$ts] [$level] $msg" | tee -a "$LOG_FILE"
}

load_config() {
    local cfg="$1"
    if [ -f "$cfg" ]; then
        log "INFO" "Loading configuration from $cfg"
        # shellcheck source=/dev/null
        source "$cfg"
    else
        log "WARNING" "Config file $cfg not found, using defaults"
    fi
}

check_root() {
    if [ "$(id -u)" -ne 0 ]; then
        log "ERROR" "This script must be run as root"
        exit 1
    fi
}

acquire_lock() {
    exec 9>"$LOCK_FILE"
    if ! flock -n 9; then
        log "ERROR" "Another backup is already running (lock: $LOCK_FILE)"
        exit 1
    fi
}

check_disk_space() {
    local required_kb=$((REQUIRED_SPACE_GB * 1048576))
    local available_kb
    available_kb=$(df -k "$BACKUP_DIR" | awk 'NR==2 {print $4}')
    local available_gb
    available_gb=$(awk "BEGIN {printf \"%.1f\", $available_kb / 1048576}")

    if [ "$available_kb" -lt "$required_kb" ]; then
        log "ERROR" "Not enough disk space. Required: ${REQUIRED_SPACE_GB}GB, Available: ${available_gb}GB"
        exit 1
    fi
    log "INFO" "Disk space available: ${available_gb}GB (need ${REQUIRED_SPACE_GB}GB)"
}

create_backup() {
    local ts
    ts=$(date +"$DATE_FORMAT")
    local host
    host=$(hostname -s)
    local backup_file="${BACKUP_DIR}/backup_${host}_${ts}.tar"
    local compression_opt=""

    case "$COMPRESSION_TYPE" in
        gzip)  backup_file="${backup_file}.gz";  compression_opt="-z" ;;
        bzip2) backup_file="${backup_file}.bz2"; compression_opt="-j" ;;
        xz)    backup_file="${backup_file}.xz";  compression_opt="-J" ;;
        *)     backup_file="${backup_file}.gz";   compression_opt="-z" ;;
    esac

    # Build exclude flags
    local exclude_args=()
    for pattern in "${EXCLUDE_PATTERNS[@]}"; do
        exclude_args+=("--exclude=$pattern")
    done

    # Filter to directories that actually exist
    local valid_sources=()
    for src in "${DIRECTORIES_TO_BACKUP[@]}"; do
        if [ -d "$src" ]; then
            valid_sources+=("$src")
        else
            log "WARNING" "Skipping $src (does not exist)"
        fi
    done

    if [ ${#valid_sources[@]} -eq 0 ]; then
        log "ERROR" "No valid source directories to back up"
        exit 1
    fi

    log "INFO" "Backing up: ${valid_sources[*]}"
    log "INFO" "Destination: $backup_file"

    tar "$compression_opt" -cf "$backup_file" \
        "${exclude_args[@]}" \
        "${valid_sources[@]}" 2>/tmp/backup_errors.log

    local rc=$?
    if [ "$rc" -eq 0 ] || [ "$rc" -eq 1 ]; then
        local size
        size=$(du -h "$backup_file" | cut -f1)
        log "INFO" "Backup complete: $backup_file ($size)"

        if [ "$rc" -eq 1 ]; then
            log "WARNING" "Some files changed during archiving (tar exit 1, non-fatal)"
        fi
    else
        log "ERROR" "Backup failed (tar exit code $rc)"
        if [ -s /tmp/backup_errors.log ]; then
            log "ERROR" "$(head -5 /tmp/backup_errors.log)"
        fi
        exit 1
    fi
}

clean_old_backups() {
    if [ "$RETENTION_DAYS" -le 0 ]; then
        log "INFO" "Retention disabled, skipping cleanup"
        return
    fi

    local count
    count=$(find "$BACKUP_DIR" -maxdepth 1 -name "backup_*.tar.*" -mtime +"$RETENTION_DAYS" | wc -l)

    if [ "$count" -gt 0 ]; then
        find "$BACKUP_DIR" -maxdepth 1 -name "backup_*.tar.*" -mtime +"$RETENTION_DAYS" -delete
        log "INFO" "Removed $count backup(s) older than $RETENTION_DAYS days"
    else
        log "INFO" "No backups older than $RETENTION_DAYS days to remove"
    fi
}

# =====================================================================
# Main
# =====================================================================

if [ $# -gt 0 ]; then
    load_config "$1"
fi

check_root
acquire_lock
mkdir -p "$BACKUP_DIR"

log "INFO" "========== BACKUP STARTED =========="

START=$(date +%s)

check_disk_space
create_backup
clean_old_backups

END=$(date +%s)
DURATION=$((END - START))
log "INFO" "Total runtime: ${DURATION}s"
log "INFO" "========== BACKUP FINISHED =========="

Make the script executable:

sudo chmod +x /usr/local/bin/system-backup.sh

Configuration Options

All variables are set at the top of the script. Any of them can be overridden by passing a config file (covered in the next section).

VariableDefaultDescription
BACKUP_DIR/backupDestination for backup archives
DIRECTORIES_TO_BACKUP/etc /home /root /optArray of directories to include
EXCLUDE_PATTERNS*.tmp, *.swp, .cache, etc.Patterns to skip during archiving
RETENTION_DAYS30Remove backups older than this many days
COMPRESSION_TYPEgzipOptions: gzip, bzip2, xz
REQUIRED_SPACE_GB1Minimum free space (GB) before running
LOG_FILE/var/log/system-backup.logWhere log messages are written
LOCK_FILE/var/run/system-backup.lockPrevents concurrent runs

The exclude patterns filter out temporary files, editor swap files, browser caches, and Python bytecode directories. Adjust these to match your workload. For example, if you run Node.js applications, keeping node_modules excluded saves significant archive size since those dependencies can be reinstalled from package.json.

Run the First Backup

Execute the script with sudo to back up the default directories (/etc, /home, /root, /opt):

sudo /usr/local/bin/system-backup.sh

The output shows each stage of the backup process:

[2026-03-30 23:54:39] [INFO] ========== BACKUP STARTED ==========
[2026-03-30 23:54:39] [INFO] Disk space available: 16.7GB (need 1GB)
[2026-03-30 23:54:39] [INFO] Backing up: /etc /home /root /opt
[2026-03-30 23:54:39] [INFO] Destination: /backup/backup_web01_2026-03-30_23-54-39.tar.gz
[2026-03-30 23:54:39] [INFO] Backup complete: /backup/backup_web01_2026-03-30_23-54-39.tar.gz (2.6M)
[2026-03-30 23:54:39] [INFO] No backups older than 30 days to remove
[2026-03-30 23:54:39] [INFO] Total runtime: 0s
[2026-03-30 23:54:39] [INFO] ========== BACKUP FINISHED ==========

Verify the archive exists in the backup directory:

ls -lh /backup/

You should see the timestamped archive:

total 2.6M
-rw-r--r-- 1 root root 2.6M Mar 30 23:54 backup_web01_2026-03-30_23-54-39.tar.gz

The log file at /var/log/system-backup.log contains the same output for later review. Each run appends to this file, giving you a full history of every backup.

Use a Config File

Instead of editing the script every time you need different settings, pass a config file as the first argument. The config file is a plain bash file that overrides any of the default variables.

Create a config file for a smaller, xz-compressed backup with 7-day retention:

sudo vi /etc/backup.conf

Add the following configuration:

BACKUP_DIR="/backup"
DIRECTORIES_TO_BACKUP=("/etc" "/opt/myapp")
COMPRESSION_TYPE="xz"
RETENTION_DAYS=7
REQUIRED_SPACE_GB=1

Run the script with the config file:

sudo /usr/local/bin/system-backup.sh /etc/backup.conf

The script loads the config and uses xz compression:

[2026-03-30 23:55:02] [INFO] Loading configuration from /etc/backup.conf
[2026-03-30 23:55:02] [INFO] ========== BACKUP STARTED ==========
[2026-03-30 23:55:02] [INFO] Disk space available: 16.7GB (need 1GB)
[2026-03-30 23:55:02] [INFO] Backing up: /etc /opt/myapp
[2026-03-30 23:55:02] [INFO] Destination: /backup/backup_web01_2026-03-30_23-55-02.tar.xz
[2026-03-30 23:55:04] [INFO] Backup complete: /backup/backup_web01_2026-03-30_23-55-02.tar.xz (2.5M)
[2026-03-30 23:55:04] [INFO] No backups older than 7 days to remove
[2026-03-30 23:55:04] [INFO] Total runtime: 2s
[2026-03-30 23:55:04] [INFO] ========== BACKUP FINISHED ==========

Notice the xz archive is slightly smaller (2.5M vs 2.6M for gzip) but took 2 seconds instead of under 1. This tradeoff matters more on larger datasets. You can create multiple config files for different backup profiles and call them from separate systemd timers.

Restore from Backup

A backup is only useful if you can restore from it. Test this by extracting the archive into a temporary directory.

Create a test directory and extract the full archive:

mkdir -p /tmp/restore-test
cd /tmp/restore-test
tar xf /backup/backup_web01_2026-03-30_23-54-39.tar.gz

Check the restored directory structure:

find /tmp/restore-test -maxdepth 2 -type d | head -20

The output confirms the original directory hierarchy is preserved:

/tmp/restore-test
/tmp/restore-test/etc
/tmp/restore-test/etc/ssh
/tmp/restore-test/etc/apt
/tmp/restore-test/etc/default
/tmp/restore-test/etc/sysctl.d
/tmp/restore-test/etc/systemd
/tmp/restore-test/etc/network
/tmp/restore-test/etc/security
/tmp/restore-test/home
/tmp/restore-test/root
/tmp/restore-test/opt

To extract a single file from the archive (for example, /etc/hostname):

tar xf /backup/backup_web01_2026-03-30_23-54-39.tar.gz etc/hostname

Note the path inside the archive does not start with a leading slash. tar strips it during creation, which prevents accidental overwrites when extracting. To restore a file to its original location, use the -C / flag:

sudo tar xf /backup/backup_web01_2026-03-30_23-54-39.tar.gz -C / etc/hostname

This extracts etc/hostname directly to /etc/hostname, overwriting the current file. Use this carefully on a running system.

Clean up the test directory when done:

rm -rf /tmp/restore-test

Automate with Systemd Timer

A cron job works, but systemd timers give you better logging, dependency management, and built-in randomized delays so multiple servers don’t all hit an NFS share at the exact same time. For more on systemd timers and scheduling, see the scheduling jobs with cron and systemd timers guide.

Create the service unit:

sudo vi /etc/systemd/system/system-backup.service

Add the following content:

[Unit]
Description=System directory backup
After=network-online.target
Wants=network-online.target

[Service]
Type=oneshot
ExecStart=/usr/local/bin/system-backup.sh /etc/backup.conf
Nice=19
IOSchedulingClass=idle

The Nice=19 and IOSchedulingClass=idle settings ensure the backup runs at the lowest CPU and I/O priority, so it won’t interfere with production workloads.

Create the timer unit:

sudo vi /etc/systemd/system/system-backup.timer

Add the timer configuration:

[Unit]
Description=Run system backup daily at 2:00 AM

[Timer]
OnCalendar=*-*-* 02:00:00
RandomizedDelaySec=900
Persistent=true

[Install]
WantedBy=timers.target

RandomizedDelaySec=900 adds up to 15 minutes of random delay, which prevents thundering herd problems when multiple servers share a backup destination. Persistent=true ensures the backup runs on the next boot if the server was powered off at the scheduled time.

Reload systemd and enable the timer:

sudo systemctl daemon-reload
sudo systemctl enable --now system-backup.timer

Confirm the timer is scheduled:

systemctl list-timers system-backup.timer

The output shows the next scheduled run:

NEXT                        LEFT     LAST PASSED UNIT                 ACTIVATES
Tue 2026-03-31 02:00:00 UTC 2h left  n/a  n/a    system-backup.timer  system-backup.service

To test the systemd integration immediately, trigger the service manually and watch the journal output:

sudo systemctl start system-backup.service
journalctl -u system-backup.service --no-pager

The journal captures the full backup output:

Mar 30 23:55:10 web01 systemd[1]: Starting system-backup.service - System directory backup...
Mar 30 23:55:10 web01 system-backup.sh[1767]: [2026-03-30 23:55:10] [INFO] Loading configuration from /etc/backup.conf
Mar 30 23:55:10 web01 system-backup.sh[1767]: [2026-03-30 23:55:10] [INFO] ========== BACKUP STARTED ==========
Mar 30 23:55:10 web01 system-backup.sh[1767]: [2026-03-30 23:55:10] [INFO] Disk space available: 16.7GB (need 1GB)
Mar 30 23:55:10 web01 system-backup.sh[1767]: [2026-03-30 23:55:10] [INFO] Backing up: /etc /opt/myapp
Mar 30 23:55:10 web01 system-backup.sh[1767]: [2026-03-30 23:55:10] [INFO] Destination: /backup/backup_web01_2026-03-30_23-55-10.tar.xz
Mar 30 23:55:12 web01 system-backup.sh[1767]: [2026-03-30 23:55:12] [INFO] Backup complete: /backup/backup_web01_2026-03-30_23-55-10.tar.xz (2.5M)
Mar 30 23:55:12 web01 system-backup.sh[1767]: [2026-03-30 23:55:12] [INFO] No backups older than 7 days to remove
Mar 30 23:55:12 web01 system-backup.sh[1767]: [2026-03-30 23:55:12] [INFO] Total runtime: 2s
Mar 30 23:55:12 web01 system-backup.sh[1767]: [2026-03-30 23:55:12] [INFO] ========== BACKUP FINISHED ==========
Mar 30 23:55:12 web01 systemd[1]: Finished system-backup.service - System directory backup.

Compression Comparison

The script supports three compression algorithms. Here is how they performed backing up /etc, /home, /root, and /opt on an Ubuntu 24.04 test server:

CompressionArchive SizeSpeedCPU UsageBest For
gzip2.6 MB<1sLowDaily automated backups
bzip2~2.5 MB~3sMediumBalance of size and speed
xz2.5 MB2sHighLong-term archival, smallest size

gzip is the default because it is the fastest and produces reasonable archive sizes. The difference between gzip and xz is negligible on small datasets like configuration files, but on servers with gigabytes of application data, xz archives can be 20-30% smaller. For automated daily backups where speed matters more than size, stick with gzip. Switch to xz for weekly or monthly archival backups where you want the smallest possible files.

The full script is available on GitHub. For backups that need encryption or remote storage to S3-compatible endpoints, the Restic and BorgBackup guides are worth reading. For syncing directories to a remote server over SSH, the rsync with systemd timer approach works well alongside this script.

Related Articles

CentOS Manage SELinux Status, Context, Ports and Booleans Using Ansible Security Install and Configure Firewalld on Ubuntu 24.04 / 22.04 Storage How To add FTP Site on Windows Server 2019 Rocky Linux Restic Backup to S3-Compatible Storage on Linux

Leave a Comment

Press ESC to close