Shore Up
A row of dated wooden crates being sealed and loaded onto a delivery truck bound for a distant warehouse, while a worker removes the oldest crates from a shelf and discards them.
Linux

Scheduled Database and File Backups to S3-Compatible Storage with rclone

Ketan Aagja8 min read
No ratings yet

Before you run this

This sets up an unattended nightly job that dumps your MySQL/MariaDB databases and tars up a couple of directories, uploads both to an S3-compatible bucket with rclone, and then deletes backups in that bucket older than a retention window. Its purpose is a hands-off off-site copy that prunes itself so the bucket doesn't grow forever.

  • Privileges: the backup script reads /etc and web roots and writes to /var/backups, so it runs as root via a systemd service. rclone config and the timer commands also need root (or sudo).
  • This deletes data, and that part is irreversible. The retention step runs rclone delete --min-age, which permanently removes objects from the bucket. Object versioning on the S3 side is your only safety net; without it, a deleted backup is gone. Read the whole script, and run the delete command with --dry-run first so you can see exactly what it would remove before it removes anything.
  • Test first. Point the remote at a throwaway bucket, run the script by hand once, and confirm the objects land and restore cleanly before you enable the timer or trust it on a production box.
  • Credentials matter. The MySQL password lives in a file and the S3 keys live in rclone.conf. Lock both down to chmod 600 and root ownership. A world-readable backup config is a breach waiting to happen.

I'm assuming Debian 12 / Ubuntu 22.04, systemd, MariaDB or MySQL on the same host, and rclone v1.60 or newer already installed (rclone version to check). If yours is older, install a current build from the downloads page at rclone.org rather than an ancient distro package. On RHEL/Alma the paths and package names are the same idea; the service manager is identical.

1. Configure the rclone remote

Run this as root so the config lands in root's config directory (/root/.config/rclone/rclone.conf) — that's where the systemd service will look for it.

sudo rclone config

Choose n for a new remote, name it mybackup, pick storage type s3, and select your provider from the list (AWS, Wasabi, Backblaze B2's S3 endpoint, MinIO, or Other for a generic S3-compatible service). You'll be prompted for the access key, secret key, region, and endpoint. Enter them for your provider — the exact endpoint URL comes from your storage vendor's docs, not from me.

When you're done, the config it writes looks roughly like this:

[mybackup]
type = s3
provider = Other
access_key_id = YOUR_ACCESS_KEY
secret_access_key = YOUR_SECRET_KEY
endpoint = s3.example-provider.com
region = us-east-1

Verify it can see the bucket (replace my-bucket with your real bucket):

sudo rclone lsd mybackup:
sudo rclone ls mybackup:my-bucket

2. Store the database credentials

Don't put the DB password on the mysqldump command line. Put it in a defaults file that mysqldump reads with --defaults-extra-file:

sudo mkdir -p /etc/backup
sudo tee /etc/backup/mysql.cnf >/dev/null <<'EOF'
[client]
user=backup
password=REPLACE_WITH_DB_PASSWORD
EOF
sudo chmod 600 /etc/backup/mysql.cnf

Use a dedicated MySQL user with read rights for backups, not root.

3. The backup script

Save this as /usr/local/bin/backup-to-s3.sh. Replace the obvious placeholders: my-bucket, the paths in FILES_SRC, and the retention window if 30 days isn't right.

#!/usr/bin/env bash
set -euo pipefail

# --- settings ---
STAMP="$(date +%Y-%m-%d_%H%M%S)"
WORKDIR="/var/backups/rclone-stage"
REMOTE="mybackup:my-bucket/backups"      # bucket + prefix
RETENTION="30d"                          # delete objects older than this
DB_CNF="/etc/backup/mysql.cnf"
FILES_SRC=(/var/www /etc)                # directories to archive
RCLONE_CONF="/root/.config/rclone/rclone.conf"

mkdir -p "$WORKDIR"
DB_DUMP="$WORKDIR/db_${STAMP}.sql.gz"
FILES_TAR="$WORKDIR/files_${STAMP}.tar.gz"

# --- database dump (consistent snapshot for InnoDB) ---
mysqldump --defaults-extra-file="$DB_CNF" \
  --single-transaction --routines --triggers \
  --all-databases | gzip > "$DB_DUMP"

# --- files archive ---
tar czf "$FILES_TAR" "${FILES_SRC[@]}"

# --- upload today's two files ---
rclone --config "$RCLONE_CONF" copy "$WORKDIR" "$REMOTE" \
  --include "*_${STAMP}.*"

# --- clean local staging so the disk doesn't fill ---
rm -f "$DB_DUMP" "$FILES_TAR"

# --- prune old objects in the bucket (irreversible) ---
rclone --config "$RCLONE_CONF" delete "$REMOTE" --min-age "$RETENTION"

Make it executable:

sudo chmod 700 /usr/local/bin/backup-to-s3.sh

A note on --single-transaction: it gives a consistent snapshot for InnoDB tables. If you still run MyISAM tables, that flag doesn't protect them — check the MySQL/MariaDB docs for your engine before trusting the dump.

Before you schedule anything, run it once by hand and watch it:

sudo /usr/local/bin/backup-to-s3.sh

Test the prune separately first. Comment out the last line, run the script for real for a day or two, then run the delete on its own with --dry-run so you can read the list of what it would remove:

sudo rclone --config /root/.config/rclone/rclone.conf \
  delete mybackup:my-bucket/backups --min-age 30d --dry-run

Only once that list looks correct should you re-enable the delete line in the script.

4. Schedule it with a systemd timer

Two unit files. First the service, /etc/systemd/system/backup-to-s3.service:

[Unit]
Description=Database and files backup to S3-compatible storage

[Service]
Type=oneshot
ExecStart=/usr/local/bin/backup-to-s3.sh

Then the timer, /etc/systemd/system/backup-to-s3.timer:

[Unit]
Description=Run S3 backup nightly

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

[Install]
WantedBy=timers.target

Persistent=true means if the box was off at 02:30, the job runs at next boot. Enable and start the timer:

sudo systemctl daemon-reload
sudo systemctl enable --now backup-to-s3.timer

I'm using a systemd timer here rather than cron because the logging and status are cleaner; a /etc/cron.d entry calling the same script works fine if you prefer cron.

5. Verify it worked

Check the timer is armed and see when it fires next:

systemctl list-timers backup-to-s3.timer

Trigger the service on demand and read its output:

sudo systemctl start backup-to-s3.service
journalctl -u backup-to-s3.service --no-pager -n 50

Confirm the objects arrived in the bucket, newest first:

sudo rclone --config /root/.config/rclone/rclone.conf \
  lsl mybackup:my-bucket/backups

Prove the backup restores — an untested backup is a guess. Pull the latest DB dump to a temp dir and inspect it:

sudo rclone --config /root/.config/rclone/rclone.conf \
  copy mybackup:my-bucket/backups /tmp/restore-test \
  --include "db_*.sql.gz"
zcat /tmp/restore-test/db_*.sql.gz | head -n 20

Restore into a scratch database or a test host, never straight over production, with gunzip < file.sql.gz | mysql testdb.

Undo and rollback

  • Stop the schedule: sudo systemctl disable --now backup-to-s3.timer.
  • Remove it entirely: delete the two unit files in /etc/systemd/system/, run sudo systemctl daemon-reload, then remove /usr/local/bin/backup-to-s3.sh and /etc/backup/mysql.cnf.
  • Remove the remote: sudo rclone config → delete the mybackup remote.
  • The bucket contents are the one thing the OS can't undo for you. Deleting objects there is done through rclone delete/rclone purge or your provider's console, and it's permanent unless the bucket has versioning enabled. Turn on versioning on the S3 side if you want a recovery path.

For the exact meaning of any flag, the rclone docs at rclone.org cover the s3 backend, the --min-age filter, and the copy/delete commands; consult the MySQL or MariaDB manual for mysqldump options specific to your engine.

Written by
Ketan Aagja

Runs enterprise networks and security for a living, and writes Shore Up to turn two decades of hands-on Linux, Windows and mail-server work into guides you can actually use.

More about the author →

Was this article helpful?

Tap a star — no sign-in needed.

Be the first to rate this article.