How to Implement Server Backups with AWS S3

Open Source, Security, Tips & Tricks, and Tutorials

How to Implement Server Backups with AWS S3

Losing server data to hardware failure, accidental deletion, or a ransomware attack can shut down a business overnight. A reliable backup strategy is the safety net every production system needs — and Amazon S3 is one of the best places to store those backups.

This guide walks through a complete S3 backup strategy for your servers: automated scripts for files and databases, scheduling, verification, lifecycle policies for cost control, and how to deploy and manage your backup scripts using DeployHQ.


Why Amazon S3 for Server Backups?

Amazon S3 (Simple Storage Service) is purpose-built for durable, scalable storage:

  • 99.999999999% durability (11 nines) — your data is replicated across multiple facilities
  • Cost-effective — pay only for what you store, with tiered pricing for infrequent access and archival
  • Versioning — recover previous versions of any object
  • Encryption — server-side encryption (SSE-S3, SSE-KMS) protects data at rest
  • Lifecycle policies — automatically transition or delete objects based on age
  • Cross-region replication — replicate backups to a second AWS region for disaster recovery

For teams that already deploy web applications with tools like DeployHQ, S3 integrates naturally into your existing workflow — you can manage backup scripts alongside your application code and deploy them automatically.


S3 Backup Strategy Overview

A solid backup strategy answers four questions:

  1. What gets backed up? (files, databases, configuration)
  2. How often? (frequency based on data change rate)
  3. How long are backups retained? (retention policy)
  4. How do you verify backups work? (automated testing)

Here's a typical strategy for a web application server:

Component Frequency Retention Storage Class
Application files Daily 30 days S3 Standard → IA after 30d
Database Every 6 hours 30 days S3 Standard → IA after 30d
Configuration files Daily 90 days S3 Standard
Full system snapshot Weekly 90 days S3 Standard → Glacier after 30d

Prerequisites

Install AWS CLI v2

The modern way to install the AWS CLI on Ubuntu/Debian:

curl "https://awscli.amazonaws.com/awscli-exe-linux-x86_64.zip" -o "awscliv2.zip"
unzip awscliv2.zip
sudo ./aws/install

Create a Dedicated IAM User

Create an IAM user with only the permissions needed for backups — never use root credentials:

{
  "Version": "2012-10-17",
  "Statement": [
    {
      "Effect": "Allow",
      "Action": [
        "s3:PutObject",
        "s3:GetObject",
        "s3:ListBucket",
        "s3:DeleteObject"
      ],
      "Resource": [
        "arn:aws:s3:::your-backup-bucket",
        "arn:aws:s3:::your-backup-bucket/*"
      ]
    }
  ]
}

Configure the CLI with the IAM user credentials:

aws configure
# Enter your Access Key ID, Secret Access Key, region, and output format

Create an Encrypted S3 Bucket

# Create the bucket
aws s3 mb s3://your-backup-bucket --region eu-west-1

# Enable default encryption
aws s3api put-bucket-encryption \
    --bucket your-backup-bucket \
    --server-side-encryption-configuration '{
        "Rules": [{"ApplyServerSideEncryptionByDefault": {"SSEAlgorithm": "AES256"}}]
    }'

# Enable versioning
aws s3api put-bucket-versioning \
    --bucket your-backup-bucket \
    --versioning-configuration Status=Enabled

# Block public access
aws s3api put-public-access-block \
    --bucket your-backup-bucket \
    --public-access-block-configuration \
        BlockPublicAcls=true,IgnorePublicAcls=true,BlockPublicPolicy=true,RestrictPublicBuckets=true

Backup Scripts

File Backup Script

Create backup-files.sh:

#!/bin/bash
set -euo pipefail

# Configuration
TIMESTAMP=$(date +"%Y%m%d_%H%M%S")
BACKUP_DIR="/var/www"
BACKUP_NAME="files_${TIMESTAMP}.tar.gz"
S3_BUCKET="your-backup-bucket"
S3_PREFIX="backups/files"
TMP_DIR="/tmp/backups"

# Ensure temp directory exists
mkdir -p "$TMP_DIR"

echo "[$(date)] Starting file backup..."

# Create compressed archive
tar -czf "${TMP_DIR}/${BACKUP_NAME}" "$BACKUP_DIR" 2>/dev/null

# Upload to S3 with server-side encryption
aws s3 cp "${TMP_DIR}/${BACKUP_NAME}" "s3://${S3_BUCKET}/${S3_PREFIX}/${BACKUP_NAME}" \
    --sse AES256 \
    --storage-class STANDARD

# Clean up local file
rm -f "${TMP_DIR}/${BACKUP_NAME}"

echo "[$(date)] File backup uploaded: s3://${S3_BUCKET}/${S3_PREFIX}/${BACKUP_NAME}"

Database Backup Script

Create backup-db.sh for MySQL/MariaDB:

#!/bin/bash
set -euo pipefail

# Configuration
TIMESTAMP=$(date +"%Y%m%d_%H%M%S")
DB_NAME="your_database"
BACKUP_NAME="db_${DB_NAME}_${TIMESTAMP}.sql.gz"
S3_BUCKET="your-backup-bucket"
S3_PREFIX="backups/database"
TMP_DIR="/tmp/backups"

mkdir -p "$TMP_DIR"

echo "[$(date)] Starting database backup for ${DB_NAME}..."

# Dump and compress in one step
# Uses ~/.my.cnf for credentials (never hardcode passwords in scripts)
mysqldump --defaults-file=~/.my.cnf \
    --single-transaction \
    --routines \
    --triggers \
    "$DB_NAME" | gzip > "${TMP_DIR}/${BACKUP_NAME}"

# Upload to S3
aws s3 cp "${TMP_DIR}/${BACKUP_NAME}" "s3://${S3_BUCKET}/${S3_PREFIX}/${BACKUP_NAME}" \
    --sse AES256

# Clean up
rm -f "${TMP_DIR}/${BACKUP_NAME}"

echo "[$(date)] Database backup uploaded: s3://${S3_BUCKET}/${S3_PREFIX}/${BACKUP_NAME}"

Important: Store database credentials in ~/.my.cnf with restricted permissions, not in the script:

# ~/.my.cnf
[mysqldump]
user=backup_user
password=your_secure_password
chmod 600 ~/.my.cnf

PostgreSQL Backup Script

For PostgreSQL databases, create backup-pg.sh:

#!/bin/bash
set -euo pipefail

TIMESTAMP=$(date +"%Y%m%d_%H%M%S")
DB_NAME="your_database"
BACKUP_NAME="pg_${DB_NAME}_${TIMESTAMP}.sql.gz"
S3_BUCKET="your-backup-bucket"
S3_PREFIX="backups/database"
TMP_DIR="/tmp/backups"

mkdir -p "$TMP_DIR"

echo "[$(date)] Starting PostgreSQL backup for ${DB_NAME}..."

# Uses ~/.pgpass for credentials
pg_dump --format=custom "$DB_NAME" | gzip > "${TMP_DIR}/${BACKUP_NAME}"

aws s3 cp "${TMP_DIR}/${BACKUP_NAME}" "s3://${S3_BUCKET}/${S3_PREFIX}/${BACKUP_NAME}" \
    --sse AES256

rm -f "${TMP_DIR}/${BACKUP_NAME}"

echo "[$(date)] PostgreSQL backup uploaded: s3://${S3_BUCKET}/${S3_PREFIX}/${BACKUP_NAME}"

Scheduling Backups with Cron

Make the scripts executable and schedule them:

chmod +x backup-files.sh backup-db.sh

Edit your crontab (crontab -e):

# File backup — daily at 2:00 AM
0 2 * * * /opt/backups/backup-files.sh >> /var/log/backup-files.log 2>&1

# Database backup — every 6 hours
0 */6 * * * /opt/backups/backup-db.sh >> /var/log/backup-db.log 2>&1

# Backup verification — daily at 4:00 AM
0 4 * * * /opt/backups/verify-backup.sh >> /var/log/backup-verify.log 2>&1

Backup Verification

Backups you never test are backups you can't trust. Create verify-backup.sh:

#!/bin/bash
set -euo pipefail

S3_BUCKET="your-backup-bucket"
ALERT_EMAIL="admin@example.com"
MAX_AGE_HOURS=24

echo "[$(date)] Starting backup verification..."

# Check file backups
LATEST_FILE=$(aws s3 ls "s3://${S3_BUCKET}/backups/files/" --recursive | sort | tail -n 1)
if [[ -z "$LATEST_FILE" ]]; then
    echo "ALERT: No file backups found!" | mail -s "Backup Alert" "$ALERT_EMAIL"
    exit 1
fi

# Check the timestamp of the latest backup
BACKUP_DATE=$(echo "$LATEST_FILE" | awk '{print $1" "$2}')
BACKUP_EPOCH=$(date -d "$BACKUP_DATE" +%s)
CURRENT_EPOCH=$(date +%s)
AGE_HOURS=$(( (CURRENT_EPOCH - BACKUP_EPOCH) / 3600 ))

if [[ $AGE_HOURS -gt $MAX_AGE_HOURS ]]; then
    echo "ALERT: Latest file backup is ${AGE_HOURS}h old (max: ${MAX_AGE_HOURS}h)" | \
        mail -s "Backup Alert: Stale Backup" "$ALERT_EMAIL"
    exit 1
fi

# Check database backups
LATEST_DB=$(aws s3 ls "s3://${S3_BUCKET}/backups/database/" --recursive | sort | tail -n 1)
if [[ -z "$LATEST_DB" ]]; then
    echo "ALERT: No database backups found!" | mail -s "Backup Alert" "$ALERT_EMAIL"
    exit 1
fi

# Verify file integrity by downloading and testing
LATEST_KEY=$(echo "$LATEST_FILE" | awk '{print $4}')
aws s3 cp "s3://${S3_BUCKET}/${LATEST_KEY}" /tmp/verify_backup.tar.gz --quiet
tar -tzf /tmp/verify_backup.tar.gz > /dev/null 2>&1
RESULT=$?
rm -f /tmp/verify_backup.tar.gz

if [[ $RESULT -ne 0 ]]; then
    echo "ALERT: Latest backup is corrupted!" | mail -s "Backup Alert: Corrupt" "$ALERT_EMAIL"
    exit 1
fi

echo "[$(date)] All backup checks passed"

S3 Lifecycle Policy for Cost Control

Move older backups to cheaper storage classes automatically. Create lifecycle.json:

{
  "Rules": [
    {
      "ID": "BackupLifecycle",
      "Status": "Enabled",
      "Filter": {
        "Prefix": "backups/"
      },
      "Transitions": [
        {
          "Days": 30,
          "StorageClass": "STANDARD_IA"
        },
        {
          "Days": 90,
          "StorageClass": "GLACIER"
        }
      ],
      "Expiration": {
        "Days": 365
      },
      "NoncurrentVersionExpiration": {
        "NoncurrentDays": 30
      }
    }
  ]
}

Apply it:

aws s3api put-bucket-lifecycle-configuration \
    --bucket your-backup-bucket \
    --lifecycle-configuration file://lifecycle.json

This gives you:

  • Days 1-30: S3 Standard (~$0.023/GB/month)
  • Days 31-90: S3 Infrequent Access (~$0.0125/GB/month)
  • Days 91-365: Glacier (~$0.004/GB/month)
  • After 365 days: Automatically deleted

For a typical web server with 50GB of daily backups, this lifecycle policy reduces annual storage costs by roughly 60% compared to keeping everything in S3 Standard.


Deploying Backup Scripts with DeployHQ

If you already use DeployHQ to deploy your web application, you can manage your backup scripts as part of your codebase — version-controlled, tested, and deployed automatically alongside your application code.

Why Deploy Backup Scripts Through DeployHQ?

  • Version control — backup script changes go through code review like any other code
  • Automatic deployment — push to Git and DeployHQ deploys updated scripts to your server
  • Consistency — every server gets the same backup configuration
  • Zero-downtimeDeployHQ's zero-downtime deployments ensure your backups aren't interrupted during a deploy

Project Structure

Add your backup scripts to your repository:

project/
├── scripts/
│   └── backups/
│       ├── backup-files.sh
│       ├── backup-db.sh
│       ├── verify-backup.sh
│       └── lifecycle.json
├── src/
│   └── ...
└── ...

Configure DeployHQ SSH Commands

In your DeployHQ project, go to SSH Commands and add a post-deploy command to install or update the backup cron jobs:

# Copy backup scripts to the right location
cp /var/www/myapp/current/scripts/backups/*.sh /opt/backups/
chmod +x /opt/backups/*.sh

# Install cron jobs (idempotent — safe to run on every deploy)
crontab -l 2>/dev/null | grep -v '/opt/backups/' > /tmp/crontab.tmp || true
echo "0 2 * * * /opt/backups/backup-files.sh >> /var/log/backup-files.log 2>&1" >> /tmp/crontab.tmp
echo "0 */6 * * * /opt/backups/backup-db.sh >> /var/log/backup-db.log 2>&1" >> /tmp/crontab.tmp
echo "0 4 * * * /opt/backups/verify-backup.sh >> /var/log/backup-verify.log 2>&1" >> /tmp/crontab.tmp
crontab /tmp/crontab.tmp
rm /tmp/crontab.tmp

This way, whenever you update a backup script and push to Git, DeployHQ automatically deploys the updated script and refreshes the cron schedule on your server.

Using Config Files for Per-Environment Settings

DeployHQ's config files feature lets you inject different S3 bucket names or AWS credentials per environment. Create a config file in DeployHQ that maps to scripts/backups/.env:

S3_BUCKET=production-backup-bucket
AWS_DEFAULT_REGION=eu-west-1
DB_NAME=production_db
ALERT_EMAIL=ops@example.com

Then source this file in your backup scripts:

#!/bin/bash
set -euo pipefail
source "$(dirname "$0")/.env"
# ... rest of script uses $S3_BUCKET, $DB_NAME, etc.

For staging, DeployHQ injects a different .env with the staging bucket name — no code changes needed.


Restoring from Backups

A backup strategy is only as good as your restore process. Here's how to restore from S3:

Restore Files

# List available backups
aws s3 ls s3://your-backup-bucket/backups/files/ | tail -10

# Download a specific backup
aws s3 cp s3://your-backup-bucket/backups/files/files_20260215_020000.tar.gz /tmp/

# Restore to the original location
cd /
tar -xzf /tmp/files_20260215_020000.tar.gz

Restore a MySQL Database

# Download the backup
aws s3 cp s3://your-backup-bucket/backups/database/db_myapp_20260215_060000.sql.gz /tmp/

# Decompress and restore
gunzip /tmp/db_myapp_20260215_060000.sql.gz
mysql --defaults-file=~/.my.cnf your_database < /tmp/db_myapp_20260215_060000.sql

Restore a PostgreSQL Database

aws s3 cp s3://your-backup-bucket/backups/database/pg_myapp_20260215_060000.sql.gz /tmp/
gunzip /tmp/pg_myapp_20260215_060000.sql.gz
pg_restore --clean --dbname=your_database /tmp/pg_myapp_20260215_060000.sql

Best Practices

  1. Test restores regularly — schedule a monthly restore drill to a test server. A backup you've never restored is a backup you can't trust.
  2. Encrypt everything — enable S3 default encryption and use IAM policies to restrict access. Never store AWS credentials in your backup scripts.
  3. Monitor and alert — set up CloudWatch alarms for failed S3 uploads, or use the verification script above with email alerts.
  4. Use cross-region replication — for critical data, replicate your backup bucket to a second AWS region.
  5. Separate backup IAM credentials — the IAM user that writes backups should have minimal permissions (PutObject, GetObject, ListBucket) and no ability to delete the bucket itself.
  6. Log everything — redirect script output to log files so you can diagnose failures quickly.
  7. Version your backup scripts — keep them in Git alongside your application code and deploy them with DeployHQ so every server stays in sync.

Common Questions

How much does S3 backup storage cost?

For a typical web server generating ~2GB of compressed daily backups with a 30-day retention policy, expect roughly $1.50-3.00/month in S3 Standard storage. With lifecycle policies moving older backups to Glacier, annual costs drop significantly.

Can I back up to S3 from any server?

Yes. S3 is cloud-agnostic — you can back up from any Linux server with internet access, whether it's on AWS, DigitalOcean, Hetzner, or a bare-metal server in your office. All you need is the AWS CLI and valid IAM credentials.

What about S3-compatible alternatives?

Services like Cloudflare R2, Backblaze B2, and MinIO offer S3-compatible APIs. The scripts in this guide work with any S3-compatible storage — just change the endpoint URL in your AWS CLI configuration.

How do I handle large databases?

For databases larger than 10GB, consider incremental backups (using tools like xtrabackup for MySQL or pg_basebackup for PostgreSQL) instead of full dumps. You can also pipe the dump directly to S3 using aws s3 cp - s3://bucket/key to avoid filling up local disk.


The Bottom Line

Automated backups to S3 are straightforward to set up and inexpensive to run. The combination of bash scripts, cron scheduling, lifecycle policies, and verification checks gives you a reliable safety net for your production data.

If you're already deploying your web application with DeployHQ, managing your backup scripts through the same pipeline keeps everything version-controlled and automatically deployed. Start your free trial to see how DeployHQ simplifies deployment for your entire infrastructure — application code and operational scripts alike.

Written by

Facundo F

Facundo | CTO | DeployHQ | Continuous Delivery & Software Engineering Leadership - As CTO at DeployHQ, Facundo leads the software engineering team, driving innovation in continuous delivery. Outside of work, he enjoys cycling and nature, accompanied by Bono 🐶.