Skip to content

Mini Project: Automated Backup System with Cron

DodaTech Updated 2026-06-28 8 min read

In this tutorial, you will learn about Mini Project: Automated Backup System with Cron. We cover key concepts, practical examples, and best practices to help you master this topic.

Build a complete automated backup system using cron: database backups, file archiving, log rotation, health checks, monitoring, and alerting for production workloads.

What You Learn

You will apply all cron concepts in a single project: crontab configuration, locking to prevent overlaps, logging and monitoring, health checks with dead man switch, error handling with retries, backup rotation, and verification.

Why It Matters

A backup system is the most common cron use case. It touches every cron concept: scheduling, locking, logging, retries, health checks, and monitoring. Building one end-to-end teaches you how to combine all cron patterns for a production-ready solution.

Real-World Use

DodaTech's backup system runs on 50 servers: database backups every 6 hours, file backups daily, log rotation weekly, with health checks pinging every run. The system has 99.99% reliability and sends a weekly summary of all backup statuses.

Project Structure

# /opt/backup-system/
# ├── backup.sh            # Main backup script
# ├── backup.conf          # Configuration
# ├── database.sh          # Database backup functions
# ├── files.sh             # File backup functions
# ├── rotate.sh            # Backup rotation
# ├── verify.sh            # Backup verification
# ├── healthcheck.sh       # Health check pings
# ├── lock.sh              # Lock management
# └── logger.sh            # Logging functions

Configuration

#!/bin/bash
# /opt/backup-system/backup.conf

BACKUP_ROOT="/backups"
LOG_DIR="/var/log/backup-system"
LOCK_DIR="/var/lock/backup-system"
HEALTHCHECK_KEY="YOUR-HEALTHCHECKS-KEY"

DB_HOST="localhost"
DB_NAME="production"
DB_USER="backup"

BACKUP_RETENTION_DAYS=30
LOG_RETENTION_DAYS=90

OBJECT_STORAGE="s3://dodatech-backups"
ENCRYPTION_KEY="/etc/backup/gpg-key"

Database Backup Script

#!/bin/bash
# /opt/backup-system/database.sh
source "$(dirname $0)/backup.conf"
source "$(dirname $0)/logger.sh"

SOURCE_TYPE="database"

backup_database() {
    local BACKUP_FILE="${BACKUP_ROOT}/database/${DB_NAME}-$(date +%Y%m%d-%H%M%S).sql.gz"
    local ENCRYPTED_FILE="${BACKUP_FILE}.gpg"

    mkdir -p "$(dirname $BACKUP_FILE)"

    log_info "Starting database backup: ${DB_NAME}"

    pg_dump -h "$DB_HOST" -U "$DB_USER" "$DB_NAME" | gzip > "$BACKUP_FILE"
    local EXIT_CODE=$?

    if [ $EXIT_CODE -ne 0 ]; then
        log_error "Database dump failed (exit: ${EXIT_CODE})"
        rm -f "$BACKUP_FILE"
        return 1
    fi

    local SIZE=$(du -h "$BACKUP_FILE" | cut -f1)
    log_info "Database dump completed: ${SIZE}"

    gpg --encrypt --recipient backup-team \
        --output "$ENCRYPTED_FILE" \
        "$BACKUP_FILE" && rm "$BACKUP_FILE"

    aws s3 cp "$ENCRYPTED_FILE" "${OBJECT_STORAGE}/database/"
    log_info "Uploaded to S3: ${OBJECT_STORAGE}/database/"

    echo "$ENCRYPTED_FILE"
}

File Backup Script

#!/bin/bash
# /opt/backup-system/files.sh
source "$(dirname $0)/backup.conf"
source "$(dirname $0)/logger.sh"

SOURCE_TYPE="files"

backup_files() {
    local BACKUP_FILE="${BACKUP_ROOT}/files/server-$(date +%Y%m%d-%H%M%S).tar.gz"
    local DIRS_TO_BACKUP=(
        "/etc"
        "/var/www"
        "/opt/app"
    )

    mkdir -p "$(dirname $BACKUP_FILE)"

    log_info "Starting file backup: ${DIRS_TO_BACKUP[*]}"

    tar -czf "$BACKUP_FILE" "${DIRS_TO_BACKUP[@]}"
    local EXIT_CODE=$?

    if [ $EXIT_CODE -ne 0 ]; then
        log_error "File backup failed (exit: ${EXIT_CODE})"
        return 1
    fi

    local SIZE=$(du -h "$BACKUP_FILE" | cut -f1)
    log_info "File backup completed: ${SIZE}"

    aws s3 cp "$BACKUP_FILE" "${OBJECT_STORAGE}/files/"
    log_info "Uploaded to S3: ${OBJECT_STORAGE}/files/"

    echo "$BACKUP_FILE"
}

Logging Module

#!/bin/bash
# /opt/backup-system/logger.sh

LOG_FILE="${LOG_DIR}/backup.log"

log_info() {
    local TIMESTAMP=$(date '+%Y-%m-%d %H:%M:%S')
    echo "[${TIMESTAMP}] [INFO] $1" | tee -a "$LOG_FILE"
}

log_error() {
    local TIMESTAMP=$(date '+%Y-%m-%d %H:%M:%S')
    echo "[${TIMESTAMP}] [ERROR] $1" | tee -a "$LOG_FILE" >&2
}

log_metrics() {
    local JOB_NAME="$1"
    local STATUS="$2"
    local DURATION="$3"
    local SIZE="$4"
    echo "${JOB_NAME},${STATUS},${DURATION},${SIZE},$(date +%s)" >> "${LOG_DIR}/metrics.csv"
}

Lock Module

#!/bin/bash
# /opt/backup-system/lock.sh

acquire_lock() {
    local LOCK_NAME="$1"
    local LOCK_FILE="${LOCK_DIR}/${LOCK_NAME}.lock"

    mkdir -p "$LOCK_DIR"

    exec 200>"$LOCK_FILE"
    if ! flock -n 200; then
        echo "Another ${LOCK_NAME} backup is running"
        return 1
    fi

    echo $$ > "$LOCK_FILE"
    return 0
}

release_lock() {
    local LOCK_NAME="$1"
    local LOCK_FILE="${LOCK_DIR}/${LOCK_NAME}.lock"

    if [ -f "$LOCK_FILE" ]; then
        rm -f "$LOCK_FILE"
    fi
}

Health Check Module

#!/bin/bash
# /opt/backup-system/healthcheck.sh

PING_URL="https://hc-ping.com/${HEALTHCHECK_KEY}"

send_start_ping() {
    curl -fsS -m 10 "${PING_URL}/start" > /dev/null 2>&1
}

send_success_ping() {
    local DURATION="$1"
    curl -fsS -m 10 "${PING_URL}/${DURATION}" > /dev/null 2>&1
}

send_failure_ping() {
    curl -fsS -m 10 "${PING_URL}/fail" > /dev/null 2>&1
}

Main Backup Script

#!/bin/bash
# /opt/backup-system/backup.sh
# Main orchestration script

SCRIPT_DIR="$(cd "$(dirname "$0")" && pwd)"
source "${SCRIPT_DIR}/backup.conf"
source "${SCRIPT_DIR}/lock.sh"
source "${SCRIPT_DIR}/logger.sh"
source "${SCRIPT_DIR}/healthcheck.sh"

OVERALL_STATUS=0
START_TIME=$(date +%s)

# Acquire global backup lock
if ! acquire_lock "full-backup"; then
    log_error "Could not acquire backup lock"
    exit 1
fi

send_start_ping

log_info "=== Full Backup Started ==="

# Database backup
log_info "Phase 1: Database backup"
if "${SCRIPT_DIR}/database.sh"; then
    log_info "Database backup: OK"
else
    log_error "Database backup: FAILED"
    OVERALL_STATUS=1
fi

# File backup
log_info "Phase 2: File backup"
if "${SCRIPT_DIR}/files.sh"; then
    log_info "File backup: OK"
else
    log_error "File backup: FAILED"
    OVERALL_STATUS=1
fi

# Log rotation
log_info "Phase 3: Log rotation"
if "${SCRIPT_DIR}/rotate.sh"; then
    log_info "Log rotation: OK"
else
    log_error "Log rotation: FAILED"
fi

# Backup verification
log_info "Phase 4: Verification"
if "${SCRIPT_DIR}/verify.sh"; then
    log_info "Verification: OK"
else
    log_error "Verification: FAILED"
    OVERALL_STATUS=1
fi

END_TIME=$(date +%s)
DURATION=$((END_TIME - START_TIME))

if [ $OVERALL_STATUS -eq 0 ]; then
    log_info "=== Full Backup Completed in ${DURATION}s ==="
    send_success_ping "$DURATION"
else
    log_error "=== Full Backup FAILED after ${DURATION}s ==="
    send_failure_ping
fi

release_lock "full-backup"
exit $OVERALL_STATUS

Rotation Script

#!/bin/bash
# /opt/backup-system/rotate.sh
source "$(dirname $0)/backup.conf"
source "$(dirname $0)/logger.sh"

log_info "Starting backup rotation"

# Remove local backups older than retention period
find "${BACKUP_ROOT}/database" -type f -mtime +${BACKUP_RETENTION_DAYS} -delete
find "${BACKUP_ROOT}/files" -type f -mtime +${BACKUP_RETENTION_DAYS} -delete

# Remove old logs
find "$LOG_DIR" -type f -mtime +${LOG_RETENTION_DAYS} -delete

# List S3 backups older than 90 days for manual review
aws s3 ls "${OBJECT_STORAGE}/database/" --recursive | \
    awk -v cutoff="$(date -d '-90 days' +%Y-%m-%d)" '$1 < cutoff {print $4}' \
    > /tmp/old-s3-backups.txt

log_info "Rotation completed"

Verification Script

#!/bin/bash
# /opt/backup-system/verify.sh
source "$(dirname $0)/backup.conf"
source "$(dirname $0)/logger.sh"

log_info "Starting backup verification"

DATABASE_BACKUPS=$(ls -t "${BACKUP_ROOT}/database/"*.gpg 2>/dev/null | head -1)
FILE_BACKUPS=$(ls -t "${BACKUP_ROOT}/files/"*.tar.gz 2>/dev/null | head -1)
VERIFIED=true

if [ -z "$DATABASE_BACKUPS" ]; then
    log_error "No database backups found"
    VERIFIED=false
else
    log_info "Latest database backup: $(basename $DATABASE_BACKUPS)"

    # Verify GPG encryption
    if gpg --verify "$DATABASE_BACKUPS" 2>/dev/null; then
        log_info "Database backup signature: valid"
    else
        log_info "Database backup exists (verification skipped)"
    fi
fi

if [ -z "$FILE_BACKUPS" ]; then
    log_error "No file backups found"
    VERIFIED=false
else
    log_info "Latest file backup: $(basename $FILE_BACKUPS)"

    # Test archive integrity
    if gzip -t "$FILE_BACKUPS" 2>/dev/null; then
        log_info "File backup integrity: OK"
    else
        log_error "File backup integrity: FAILED"
        VERIFIED=false
    fi
fi

$VERIFIED && return 0 || return 1

Crontab Configuration

# /etc/cron.d/backup-system

PATH=/usr/local/sbin:/usr/local/bin:/usr/sbin:/usr/bin:/sbin:/bin
SHELL=/bin/bash
MAILTO=backup-team@dodatech.com

# Database backup every 6 hours
0 */6 * * * root /opt/backup-system/backup.sh >> /var/log/backup-system/cron.log 2>&1

# File backup daily at 2 AM
0 2 * * * root /opt/backup-system/files.sh >> /var/log/backup-system/cron.log 2>&1

# Backup rotation daily at 4 AM
0 4 * * * root /opt/backup-system/rotate.sh >> /var/log/backup-system/cron.log 2>&1

# Verification after each backup (runs via backup.sh, separate check here)
30 */6 * * * root /opt/backup-system/verify.sh >> /var/log/backup-system/cron.log 2>&1

# Weekly summary report
0 9 * * 1 root /opt/backup-system/summary.py >> /var/log/backup-system/cron.log 2>&1

Monitoring and Dashboard

#!/usr/bin/env python3
"""Backup system monitoring dashboard."""
import os
import json
from datetime import datetime

class BackupDashboard:
    def __init__(self, log_dir='/var/log/backup-system'):
        self.log_dir = log_dir

    def get_latest_status(self):
        metrics_file = os.path.join(self.log_dir, 'metrics.csv')
        if not os.path.exists(metrics_file):
            return {'status': 'unknown'}

        with open(metrics_file) as f:
            lines = f.readlines()

        if not lines:
            return {'status': 'unknown'}

        last_line = lines[-1].strip()
        parts = last_line.split(',')
        if len(parts) >= 4:
            return {
                'job': parts[0],
                'status': parts[1],
                'duration': parts[2],
                'size': parts[3],
                'timestamp': datetime.fromtimestamp(int(parts[4])).isoformat(),
            }
        return {'status': 'unknown'}

    def get_24h_summary(self):
        metrics_file = os.path.join(self.log_dir, 'metrics.csv')
        if not os.path.exists(metrics_file):
            return {'total': 0, 'success': 0, 'failed': 0}

        cutoff = datetime.now().timestamp() - 86400
        total = 0
        success = 0
        failed = 0

        with open(metrics_file) as f:
            for line in f:
                parts = line.strip().split(',')
                if len(parts) >= 5 and int(parts[4]) > cutoff:
                    total += 1
                    if parts[1] == 'success':
                        success += 1
                    else:
                        failed += 1

        return {'total': total, 'success': success, 'failed': failed}

    def print_report(self):
        status = self.get_latest_status()
        summary = self.get_24h_summary()

        print("=" * 50)
        print("BACKUP SYSTEM STATUS REPORT")
        print("=" * 50)
        print(f"Time: {datetime.now().isoformat()}")
        print(f"Host: {os.uname().nodename}")
        print()

        print("Last backup:")
        if status['status'] != 'unknown':
            print(f"  Job:      {status['job']}")
            print(f"  Status:   {status['status'].upper()}")
            print(f"  Duration: {status['duration']}s")
            print(f"  Size:     {status['size']}")
            print(f"  Time:     {status['timestamp']}")
        else:
            print("  No backup data found")

        print()
        print("Last 24 hours:")
        print(f"  Total:  {summary['total']}")
        print(f"  OK:     {summary['success']}")
        print(f"  Failed: {summary['failed']}")
        print(f"  Rate:   {summary['success']/max(summary['total'],1)*100:.0f}%")
        print("=" * 50)

dashboard = BackupDashboard()
dashboard.print_report()

Expected output:

==================================================
BACKUP SYSTEM STATUS REPORT
==================================================
Time: 2026-06-28T10:00:00
Host: server-01

Last backup:
  Job:      full-backup
  Status:   SUCCESS
  Duration: 145s
  Size:     2.3G
  Time:     2026-06-28T06:00:00

Last 24 hours:
  Total:  4
  OK:     4
  Failed: 0
  Rate:   100%
==================================================

Extension Challenges

After the basic backup system works, extend it with:

  1. Distributed backup: Use Redis leader election so only one server runs the backup when multiple servers have the same crontab.

  2. Incremental backups: After the first full backup, only back up changed files using rsync or a change journal.

  3. Encryption at rest: Encrypt all backups with GPG before uploading to S3, with key rotation every 90 days.

  4. Restore testing: Automatically restore the latest backup to a staging environment every week and verify data integrity.

  5. Cost optimization: Move backups older than 30 days to Glacier (cold storage), delete backups older than 1 year.

Common Mistakes

1. No Locking

Backup takes longer than the schedule interval, a second instance starts and writes to the same file, corrupting both backups.

2. No Verification

The backup script exits 0 but the backup file is empty or corrupted. Always verify backup integrity after creation.

3. Only One Copy

Local backup + same-machine backup = no backup. Always store backups off-server (S3, separate storage server).

4. No Retention Policy

Backups accumulate and fill the disk. Implement rotation: keep daily for 7 days, weekly for 4 weeks, monthly for 12 months.

5. Not Testing Restores

Untested backups are not backups. Regularly verify that backups can be restored. Schedule automated restore tests.

FAQ

How often should I run database backups?

Every 6 hours for production databases. Critical databases may need hourly backups. Use WAL archiving for point-in-time recovery between full backups.

How long should I keep backups?

Daily: 7-30 days. Weekly: 3 months. Monthly: 1 year. Yearly: indefinite for compliance. Adjust based on data criticality and storage costs.

What is the 3-2-1 backup rule?

3 copies of data, on 2 different media types, with 1 copy off-site. Follow this rule for production data.

Should backups be compressed?

Yes. Compression reduces storage and transfer costs. Use gzip for text data (logs, SQL dumps). Consider deduplication for file backups.

How do I verify backup integrity?

Check checksums, test-compress archives, GPG-verify encrypted files, and periodically restore to a test environment. Automated verification prevents surprises.

What's Next

Now that you have built a complete backup system, explore server-sent events for real-time updates, then learn about webhooks for event-driven backend communication.

Built by the developers of DodaTech

Doda Browser, DodaZIP & Durga Antivirus Pro