Amazon S3 is a durable, inexpensive place to keep off-site copies of a server's data, and the aws s3 sync command only uploads files that are new or have changed, so nightly runs stay fast. In this tutorial you will create a versioned S3 bucket, an IAM user that can write backups but cannot delete them, a backup script for configuration files, web content and MySQL databases, and a systemd timer that runs it every night on Ubuntu 24.04. You will finish by restoring files, including an older version of a database dump.

Prerequisites

To follow this guide you need:

  • A server running Ubuntu 24.04 LTS, for example a CubePath VPS, with a non-root user with sudo privileges.
  • AWS CLI v2 installed on the server. See How to Install and Configure AWS CLI v2 on Ubuntu 24.04.
  • Administrator credentials for your AWS account configured as a profile on your workstation or on the server (this guide calls it admin). They are only used during setup.
  • Optional: MySQL or MariaDB running on the server, if you want to back up databases.

How the backup works

The design choices below protect you against the most common failure, which is a compromised or broken server destroying its own backups:

  • Versioning is enabled on the bucket. When sync uploads a changed file, S3 keeps the previous version instead of overwriting it.
  • The server's IAM user cannot delete objects. Even if an attacker gets root on the server and steals the key, they cannot wipe the backups.
  • A lifecycle rule expires old versions after 90 days, so storage costs do not grow forever.
  • Files whose ownership and permissions matter (/etc) are archived with tar first, because S3 objects do not store Unix owners or modes. Web content is synced file by file.

Step 1 - Creating a versioned S3 bucket

Bucket names are global across all AWS accounts, so choose a unique name such as yourcompany-server-backups. Create the bucket in the region closest to your server, using your admin profile:

aws s3 mb s3://your_bucket_name --region eu-west-1 --profile admin
make_bucket: your_bucket_name

Turn on versioning:

aws s3api put-bucket-versioning \
  --bucket your_bucket_name \
  --versioning-configuration Status=Enabled \
  --profile admin

New buckets already block all public access and encrypt every object at rest with SSE-S3 by default. Confirm both settings rather than assuming them:

aws s3api get-public-access-block --bucket your_bucket_name --profile admin
aws s3api get-bucket-encryption --bucket your_bucket_name --profile admin
{
    "PublicAccessBlockConfiguration": {
        "BlockPublicAcls": true,
        "IgnorePublicAcls": true,
        "BlockPublicPolicy": true,
        "RestrictPublicBuckets": true
    }
}
{
    "ServerSideEncryptionConfiguration": {
        "Rules": [
            {
                "ApplyServerSideEncryptionByDefault": {
                    "SSEAlgorithm": "AES256"
                },
                "BucketKeyEnabled": false
            }
        ]
    }
}

Step 2 - Adding a lifecycle rule

With versioning on, every nightly change leaves a noncurrent version behind. This rule deletes noncurrent versions 90 days after they were replaced and cleans up multipart uploads that were interrupted, which otherwise keep costing money without being visible in aws s3 ls.

Create the rule file:

nano lifecycle.json
{
  "Rules": [
    {
      "ID": "expire-old-versions",
      "Status": "Enabled",
      "Filter": {},
      "NoncurrentVersionExpiration": {
        "NoncurrentDays": 90
      },
      "AbortIncompleteMultipartUpload": {
        "DaysAfterInitiation": 7
      }
    }
  ]
}

Apply it to the bucket and read it back:

aws s3api put-bucket-lifecycle-configuration \
  --bucket your_bucket_name \
  --lifecycle-configuration file://lifecycle.json \
  --profile admin
aws s3api get-bucket-lifecycle-configuration --bucket your_bucket_name --profile admin

Adjust NoncurrentDays to your retention policy. The current copy of each file is never expired by this rule.

Step 3 - Creating a least-privilege IAM user

The server needs to list the bucket, upload objects and read them back for restores. It does not need s3:DeleteObject, so it does not get it. Create the policy file:

nano backup-policy.json
{
  "Version": "2012-10-17",
  "Statement": [
    {
      "Sid": "ListBucket",
      "Effect": "Allow",
      "Action": ["s3:ListBucket", "s3:ListBucketVersions", "s3:GetBucketLocation"],
      "Resource": "arn:aws:s3:::your_bucket_name"
    },
    {
      "Sid": "ReadWriteObjects",
      "Effect": "Allow",
      "Action": ["s3:PutObject", "s3:GetObject", "s3:GetObjectVersion"],
      "Resource": "arn:aws:s3:::your_bucket_name/*"
    }
  ]
}

Create the user, attach the policy inline and generate an access key:

aws iam create-user --user-name s3-backup --profile admin
aws iam put-user-policy \
  --user-name s3-backup \
  --policy-name s3-backup-your_bucket_name \
  --policy-document file://backup-policy.json \
  --profile admin
aws iam create-access-key --user-name s3-backup --profile admin
{
    "AccessKey": {
        "UserName": "s3-backup",
        "AccessKeyId": "AKIAXXXXXXXXXXXXXXXX",
        "Status": "Active",
        "SecretAccessKey": "your_secret_key",
        "CreateDate": "2026-09-25T09:00:00+00:00"
    }
}

Copy AccessKeyId and SecretAccessKey now; the secret cannot be retrieved later.

Step 4 - Configuring the backup profile for root

The backup runs as root, because it needs to read every file in /etc and dump all databases. Store the new key in a backup profile in root's AWS configuration:

sudo aws configure --profile backup
AWS Access Key ID [None]: AKIAXXXXXXXXXXXXXXXX
AWS Secret Access Key [None]: your_secret_key
Default region name [None]: eu-west-1
Default output format [None]: json

Check that root can use the key and that the permissions work as intended. The first command should succeed and the second should be denied:

sudo aws sts get-caller-identity --profile backup
echo test | sudo aws s3 cp - s3://your_bucket_name/permission-test.txt --profile backup
sudo aws s3 rm s3://your_bucket_name/permission-test.txt --profile backup
upload: - to s3://your_bucket_name/permission-test.txt
delete failed: s3://your_bucket_name/permission-test.txt An error occurred (AccessDenied) when calling the DeleteObject operation: ...

That AccessDenied is exactly what you want: the server can add backups but cannot remove them. Delete the test object with your admin profile:

aws s3 rm s3://your_bucket_name/permission-test.txt --profile admin

Step 5 - Writing the backup script

The script stages the files that need an archive (the /etc tarball and the database dump) in /var/backups/s3-staging, then syncs that directory and the web root to a per-host prefix in the bucket. The staged files keep the same names every night; S3 versioning keeps the older copies.

Create the script:

sudo nano /usr/local/sbin/s3-backup
#!/usr/bin/env bash
set -euo pipefail

BUCKET="s3://your_bucket_name"
PREFIX="$BUCKET/$(hostname -s)"
STAGING="/var/backups/s3-staging"
WEB_ROOT="/var/www"

export AWS_PROFILE="backup"
umask 077
mkdir -p "$STAGING"

echo "Archiving /etc"
tar -czf "$STAGING/etc.tar.gz" -C / etc

if command -v mysqldump >/dev/null 2>&1; then
    echo "Dumping MySQL databases"
    mysqldump --all-databases --single-transaction --routines --events \
        | gzip > "$STAGING/all-databases.sql.gz"
fi

echo "Uploading staged archives"
aws s3 sync "$STAGING" "$PREFIX/staging/" --only-show-errors

if [ -d "$WEB_ROOT" ]; then
    echo "Syncing $WEB_ROOT"
    aws s3 sync "$WEB_ROOT" "$PREFIX/www/" --only-show-errors \
        --exclude "*/.git/*" \
        --exclude "*/node_modules/*" \
        --exclude "*.tmp"
fi

echo "Backup finished: $PREFIX"

A few details worth knowing:

  • set -euo pipefail makes the script stop on the first error, including a failed mysqldump inside the pipe, so a broken dump never silently replaces a good one.
  • On Ubuntu, root connects to a local MySQL or MariaDB server through the Unix socket without a password, so mysqldump needs no credentials file. --single-transaction produces a consistent dump of InnoDB tables without locking them.
  • aws s3 sync is run without --delete: files removed from the server stay in the bucket, and the IAM user could not delete them anyway.

Make the script executable and readable only by root:

sudo chmod 700 /usr/local/sbin/s3-backup

Preview what the web sync would upload with --dryrun before running it for real:

sudo aws s3 sync /var/www s3://your_bucket_name/$(hostname -s)/www/ --dryrun --profile backup | head

Now run the full backup once by hand:

sudo /usr/local/sbin/s3-backup
Archiving /etc
Dumping MySQL databases
Uploading staged archives
Syncing /var/www
Backup finished: s3://your_bucket_name/web01

List what arrived in the bucket:

sudo aws s3 ls s3://your_bucket_name/$(hostname -s)/ --recursive --human-readable --profile backup | head
2026-09-25 09:14:02    1.1 MiB web01/staging/all-databases.sql.gz
2026-09-25 09:14:01  612.4 KiB web01/staging/etc.tar.gz
2026-09-25 09:14:03   14.2 KiB web01/www/html/index.html

Step 6 - Scheduling the backup with a systemd timer

A systemd timer is easier to monitor than a cron entry: runs are logged in the journal, failures show up in systemctl, and Persistent=true catches up on a run missed while the server was off.

Create the service unit:

sudo nano /etc/systemd/system/s3-backup.service
[Unit]
Description=Back up server data to Amazon S3
Wants=network-online.target
After=network-online.target

[Service]
Type=oneshot
User=root
ExecStart=/usr/local/sbin/s3-backup
Nice=10
IOSchedulingClass=idle

User=root makes systemd set HOME=/root, which is where the AWS CLI looks for the backup profile.

Create the timer, which runs the service every night at 02:30 with a random delay of up to 15 minutes:

sudo nano /etc/systemd/system/s3-backup.timer
[Unit]
Description=Nightly Amazon S3 backup

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

[Install]
WantedBy=timers.target

Reload systemd and enable the timer:

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

Check when it will run next:

systemctl list-timers s3-backup.timer
NEXT                        LEFT     LAST PASSED UNIT            ACTIVATES
Fri 2026-09-26 02:38:51 UTC 17h left -    -      s3-backup.timer s3-backup.service

Trigger a run through systemd to confirm the unit works, then read its log:

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

Step 7 - Restoring from the backup

Restore into a temporary directory first, inspect the files, and only then copy them into place.

To restore the current web content:

sudo aws s3 sync s3://your_bucket_name/$(hostname -s)/www/ /root/restore/www/ --profile backup

To restore the /etc archive and extract a single file from it:

sudo aws s3 cp s3://your_bucket_name/$(hostname -s)/staging/etc.tar.gz /root/restore/ --profile backup
sudo tar -xzf /root/restore/etc.tar.gz -C /root/restore etc/nginx/nginx.conf

To get an older copy of a file, list its versions. Each entry has a VersionId and a LastModified date:

sudo aws s3api list-object-versions \
  --bucket your_bucket_name \
  --prefix "$(hostname -s)/staging/all-databases.sql.gz" \
  --query 'Versions[].{Id:VersionId,Date:LastModified,Latest:IsLatest}' \
  --output table --profile backup
------------------------------------------------------------------------
|                          ListObjectVersions                          |
+----------------------------+-------------------------------+---------+
|            Date            |              Id               | Latest  |
+----------------------------+-------------------------------+---------+
|  2026-09-25T02:41:10+00:00 |  3HL4kqtJlcpXroDTDmJ.rmSpXd3d |  True   |
|  2026-09-24T02:35:52+00:00 |  kGTmv4X2UqG9Y.Pz0lX7vI1PX8vT |  False  |
+----------------------------+-------------------------------+---------+

Download the version you need by its ID:

sudo aws s3api get-object \
  --bucket your_bucket_name \
  --key "$(hostname -s)/staging/all-databases.sql.gz" \
  --version-id kGTmv4X2UqG9Y.Pz0lX7vI1PX8vT \
  /root/restore/all-databases-2026-09-24.sql.gz \
  --profile backup

Import it with gunzip -c /root/restore/all-databases-2026-09-24.sql.gz | sudo mysql only after checking that it is the dump you want, because it overwrites the existing databases.

Troubleshooting

Unable to locate credentials in the journal but the script works from your shell: the unit is not running with root's home directory. Make sure User=root is in the [Service] section, or that you ran aws configure with sudo.

AccessDenied on PutObject: the bucket name in backup-policy.json does not match the one in the script, or the policy was attached to a different user. Check with aws iam get-user-policy --user-name s3-backup --policy-name s3-backup-your_bucket_name --profile admin.

mysqldump: Got error: 1045: Access denied for user 'root'@'localhost': your MySQL root account uses password authentication. Create /root/.my.cnf with a [mysqldump] section containing user and password, and restrict it with chmod 600.

Slow uploads of large files: raise the parallelism for the backup profile, for example sudo aws configure set s3.max_concurrent_requests 20 --profile backup.

Conclusion

Your server now sends a nightly backup to a versioned, private S3 bucket using credentials that cannot delete anything, with old versions pruned automatically and a tested restore procedure. As next steps, consider enabling S3 Object Lock on a new bucket for true immutability, encrypting the database dump with gpg before upload if it contains sensitive data, and scheduling a periodic restore test so you know the backups are usable.