← Back to Blog
☁️ Automatización AWS
December 5, 2025

Scripts de Backup: S3 + Lifecycle Policies

📋 Contexto

Los backups son como los seguros: nadie quiere pagarlos hasta que los necesita. En este post, cómo implementé un sistema de backups automatizado para mi home lab usando S3, con lifecycle policies para optimizar costos y scripts bash para automatización completa.

🎯 Estrategia de Backup (3-2-1 Rule)

  • 3 copias: Original + 2 backups
  • 2 medios diferentes: Disco local + cloud (S3)
  • 1 copia offsite: S3 en región diferente

💰 Optimización de Costos con S3 Storage Classes

Storage Class Costo (GB/mes) Uso
S3 Standard $0.023 Últimos 7 días
S3 Standard-IA $0.0125 8-30 días
S3 Glacier Flexible $0.0036 31-90 días
S3 Glacier Deep Archive $0.00099 >90 días
💡 Ahorro: Mover backups de 30 días de Standard a Glacier = ~84% menos costo

🛠️ Script de Backup Completo

#!/bin/bash
# backup-to-s3.sh - Automated backup script with lifecycle management

set -euo pipefail
IFS=$'\n\t'

##############################################################################
# Configuration
##############################################################################

readonly SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
readonly BACKUP_NAME="homelab-backup"
readonly S3_BUCKET="my-backups-$(aws sts get-caller-identity --query Account --output text)"
readonly S3_PREFIX="homelab/"
readonly BACKUP_DIRS=(
    "/home/mytechzone/n8n-lab"
    "/home/mytechzone/scripts"
    "/etc/nginx"
    "/etc/systemd/system"
)
readonly EXCLUDE_PATTERNS=(
    "*.log"
    "*.tmp"
    "node_modules/"
    ".git/"
    "__pycache__/"
)
readonly TEMP_DIR="/tmp/backups"
readonly LOG_FILE="/var/log/backup-s3.log"
readonly RETENTION_DAYS=90

##############################################################################
# Logging
##############################################################################

log() {
    echo "[$(date +'%Y-%m-%d %H:%M:%S')] [INFO] $*" | tee -a "$LOG_FILE"
}

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

##############################################################################
# Validation
##############################################################################

check_prerequisites() {
    log "Checking prerequisites..."
    
    # Check AWS CLI
    if ! command -v aws &> /dev/null; then
        error "AWS CLI not installed"
        exit 1
    fi
    
    # Check authentication
    if ! aws sts get-caller-identity &> /dev/null; then
        error "Not authenticated with AWS"
        exit 1
    fi
    
    # Check disk space (need at least 5GB free)
    local available=$(df /tmp | tail -1 | awk '{print $4}')
    if [ "$available" -lt 5242880 ]; then
        error "Not enough disk space in /tmp"
        exit 1
    fi
    
    log "✓ Prerequisites OK"
}

##############################################################################
# S3 Bucket Setup
##############################################################################

setup_s3_bucket() {
    log "Setting up S3 bucket: $S3_BUCKET"
    
    # Create bucket if doesn't exist
    if ! aws s3 ls "s3://$S3_BUCKET" &> /dev/null; then
        log "Creating bucket..."
        aws s3 mb "s3://$S3_BUCKET" --region us-east-1
        
        # Enable versioning
        aws s3api put-bucket-versioning \
            --bucket "$S3_BUCKET" \
            --versioning-configuration Status=Enabled
        
        # Enable encryption
        aws s3api put-bucket-encryption \
            --bucket "$S3_BUCKET" \
            --server-side-encryption-configuration '{
                "Rules": [{
                    "ApplyServerSideEncryptionByDefault": {
                        "SSEAlgorithm": "AES256"
                    }
                }]
            }'
    fi
    
    log "✓ Bucket ready"
}

##############################################################################
# Lifecycle Policy
##############################################################################

configure_lifecycle() {
    log "Configuring lifecycle policy..."
    
    cat > /tmp/lifecycle-policy.json << EOF
{
    "Rules": [
        {
            "Id": "BackupRetention",
            "Status": "Enabled",
            "Prefix": "$S3_PREFIX",
            "Transitions": [
                {
                    "Days": 7,
                    "StorageClass": "STANDARD_IA"
                },
                {
                    "Days": 30,
                    "StorageClass": "GLACIER"
                },
                {
                    "Days": 90,
                    "StorageClass": "DEEP_ARCHIVE"
                }
            ],
            "Expiration": {
                "Days": $RETENTION_DAYS
            }
        }
    ]
}
EOF
    
    aws s3api put-bucket-lifecycle-configuration \
        --bucket "$S3_BUCKET" \
        --lifecycle-configuration file:///tmp/lifecycle-policy.json
    
    rm /tmp/lifecycle-policy.json
    
    log "✓ Lifecycle configured"
}

##############################################################################
# Create Backup
##############################################################################

create_backup() {
    log "Creating backup archive..."
    
    mkdir -p "$TEMP_DIR"
    
    local timestamp=$(date +%Y%m%d_%H%M%S)
    local backup_file="$TEMP_DIR/${BACKUP_NAME}_${timestamp}.tar.gz"
    
    # Build exclude options
    local exclude_opts=""
    for pattern in "${EXCLUDE_PATTERNS[@]}"; do
        exclude_opts+=" --exclude='$pattern'"
    done
    
    # Create tar archive
    log "Archiving directories..."
    eval tar czf "$backup_file" \
        $exclude_opts \
        "${BACKUP_DIRS[@]}" \
        2>> "$LOG_FILE" || {
            error "Tar failed"
            return 1
        }
    
    # Get size
    local size_mb=$(du -m "$backup_file" | cut -f1)
    log "✓ Backup created: $backup_file ($size_mb MB)"
    
    echo "$backup_file"
}

##############################################################################
# Upload to S3
##############################################################################

upload_to_s3() {
    local backup_file=$1
    local filename=$(basename "$backup_file")
    local s3_path="s3://$S3_BUCKET/$S3_PREFIX$filename"
    
    log "Uploading to S3: $s3_path"
    
    # Upload with progress
    aws s3 cp "$backup_file" "$s3_path" \
        --storage-class STANDARD \
        --metadata "backup-date=$(date -Iseconds),hostname=$(hostname)" \
        2>&1 | tee -a "$LOG_FILE"
    
    if [ ${PIPESTATUS[0]} -eq 0 ]; then
        log "✓ Upload successful"
        return 0
    else
        error "Upload failed"
        return 1
    fi
}

##############################################################################
# Cleanup Old Backups (Local)
##############################################################################

cleanup_local() {
    log "Cleaning up local temp files..."
    
    find "$TEMP_DIR" -name "${BACKUP_NAME}_*.tar.gz" -type f -mtime +7 -delete
    
    log "✓ Local cleanup done"
}

##############################################################################
# Verify Backup
##############################################################################

verify_backup() {
    local s3_path=$1
    
    log "Verifying backup in S3..."
    
    # Check if file exists
    if aws s3 ls "$s3_path" &> /dev/null; then
        log "✓ Backup verified in S3"
        return 0
    else
        error "Backup not found in S3"
        return 1
    fi
}

##############################################################################
# Notification (Optional)
##############################################################################

send_notification() {
    local status=$1
    local message=$2
    
    # Option 1: Email via SES
    # aws ses send-email ...
    
    # Option 2: SNS
    # aws sns publish --topic-arn "..." --message "$message"
    
    # Option 3: Webhook (Discord, Slack)
    # curl -X POST -H 'Content-type: application/json' \
    #      --data "{\"text\":\"$message\"}" \
    #      "$WEBHOOK_URL"
    
    log "Notification sent: $status"
}

##############################################################################
# Main
##############################################################################

main() {
    log "=========================================="
    log "Starting backup process"
    log "=========================================="
    
    # Validate
    check_prerequisites
    
    # Setup infrastructure
    setup_s3_bucket
    configure_lifecycle
    
    # Create and upload backup
    local backup_file
    if backup_file=$(create_backup); then
        if upload_to_s3 "$backup_file"; then
            local s3_path="s3://$S3_BUCKET/$S3_PREFIX$(basename "$backup_file")"
            
            if verify_backup "$s3_path"; then
                log "✅ Backup completed successfully"
                send_notification "SUCCESS" "Backup completed: $(basename "$backup_file")"
                
                # Cleanup
                cleanup_local
                rm -f "$backup_file"
                
                exit 0
            fi
        fi
    fi
    
    error "❌ Backup failed"
    send_notification "FAILURE" "Backup failed - check logs"
    exit 1
}

# Run
main "$@"

⏰ Automatización con Cron

# /etc/cron.d/backup-s3
# Run backup daily at 2 AM
0 2 * * * mytechzone /home/mytechzone/scripts/backup-to-s3.sh

# Weekly full backup on Sunday
0 3 * * 0 mytechzone /home/mytechzone/scripts/backup-to-s3.sh --full

# Monthly deep backup (1st day of month)
0 4 1 * * mytechzone /home/mytechzone/scripts/backup-to-s3.sh --monthly

📊 Script de Restore

#!/bin/bash
# restore-from-s3.sh - Restore backup from S3

set -euo pipefail

readonly S3_BUCKET="my-backups-123456789012"
readonly S3_PREFIX="homelab/"
readonly RESTORE_DIR="/tmp/restore"

list_backups() {
    echo "Available backups:"
    echo ""
    
    aws s3 ls "s3://$S3_BUCKET/$S3_PREFIX" \
        --recursive \
        --human-readable \
        --summarize | \
    grep -E '\.tar\.gz$' | \
    nl -w2 -s'. '
}

download_backup() {
    local backup_file=$1
    local s3_path="s3://$S3_BUCKET/$S3_PREFIX$backup_file"
    local local_path="$RESTORE_DIR/$backup_file"
    
    echo "Downloading $backup_file..."
    
    mkdir -p "$RESTORE_DIR"
    
    aws s3 cp "$s3_path" "$local_path" \
        --storage-class STANDARD  # Restore from Glacier if needed
    
    echo "✓ Downloaded to: $local_path"
}

restore_backup() {
    local backup_file=$1
    local target_dir=${2:-/}
    
    echo "Restoring to: $target_dir"
    
    tar xzf "$RESTORE_DIR/$backup_file" -C "$target_dir" --verbose
    
    echo "✓ Restore complete"
}

main() {
    if [ $# -eq 0 ]; then
        list_backups
        echo ""
        read -p "Enter backup filename to restore: " backup_file
    else
        backup_file=$1
    fi
    
    download_backup "$backup_file"
    
    read -p "Restore to / ? (yes/no): " confirm
    if [ "$confirm" = "yes" ]; then
        restore_backup "$backup_file"
    else
        echo "Backup downloaded to $RESTORE_DIR/$backup_file"
        echo "Extract manually with: tar xzf $RESTORE_DIR/$backup_file -C /target/path"
    fi
}

main "$@"

💡 Monitoring de Backups

#!/bin/bash
# check-backup-status.sh - Monitor backup health

set -euo pipefail

readonly S3_BUCKET="my-backups-123456789012"
readonly S3_PREFIX="homelab/"
readonly MAX_AGE_HOURS=28  # Alert if no backup in 28 hours

get_latest_backup() {
    aws s3 ls "s3://$S3_BUCKET/$S3_PREFIX" \
        --recursive | \
    grep -E '\.tar\.gz$' | \
    sort -k1,2 | \
    tail -1 | \
    awk '{print $1, $2, $4}'
}

check_backup_age() {
    local latest=$(get_latest_backup)
    
    if [ -z "$latest" ]; then
        echo "❌ No backups found!"
        return 1
    fi
    
    local backup_date=$(echo "$latest" | awk '{print $1, $2}')
    local backup_file=$(echo "$latest" | awk '{print $3}')
    local backup_timestamp=$(date -d "$backup_date" +%s)
    local current_timestamp=$(date +%s)
    local age_hours=$(( (current_timestamp - backup_timestamp) / 3600 ))
    
    echo "Latest backup: $backup_file"
    echo "Age: $age_hours hours"
    
    if [ $age_hours -gt $MAX_AGE_HOURS ]; then
        echo "⚠️  Backup is too old!"
        return 1
    fi
    
    echo "✅ Backup is current"
    return 0
}

main() {
    echo "=== Backup Status Check ==="
    echo ""
    check_backup_age
}

main "$@"

📈 Estimación de Costos

# Escenario: 10 GB backup diario, retention 90 días

# Month 1:
Days 1-7:   70 GB × $0.023  = $1.61
Days 8-30:  230 GB × $0.0125 = $2.88
Days 31+:   0 GB (no data yet)
Total: $4.49

# Month 2:
Days 1-7:   70 GB × $0.023  = $1.61
Days 8-30:  230 GB × $0.0125 = $2.88
Days 31-60: 310 GB × $0.0036 = $1.12
Total: $5.61

# Month 3 (steady state):
Days 1-7:   70 GB × $0.023  = $1.61
Days 8-30:  230 GB × $0.0125 = $2.88
Days 31-90: 610 GB × $0.0036 = $2.20
Total: $6.69/month

# vs keeping everything in S3 Standard:
900 GB × $0.023 = $20.70/month
Savings: ~68%
✅ Ahorro Real: De $20/mes a $7/mes = $156/año ahorrados con lifecycle policies

🚨 Testing del Restore

#!/bin/bash
# test-restore.sh - Automated restore testing

set -euo pipefail

readonly TEST_DIR="/tmp/restore-test"
readonly S3_BUCKET="my-backups-123456789012"
readonly S3_PREFIX="homelab/"

test_restore() {
    echo "=== Backup Restore Test ==="
    echo ""
    
    # Get latest backup
    local latest_backup=$(aws s3 ls "s3://$S3_BUCKET/$S3_PREFIX" \
        --recursive | \
        grep -E '\.tar\.gz$' | \
        sort | \
        tail -1 | \
        awk '{print $4}')
    
    echo "Testing: $latest_backup"
    
    # Download
    mkdir -p "$TEST_DIR"
    aws s3 cp "s3://$S3_BUCKET/$latest_backup" "$TEST_DIR/"
    
    # Extract
    tar tzf "$TEST_DIR/$(basename "$latest_backup")" &> /dev/null
    
    if [ $? -eq 0 ]; then
        echo "✅ Backup is valid and can be restored"
        rm -rf "$TEST_DIR"
        return 0
    else
        echo "❌ Backup is corrupted!"
        return 1
    fi
}

# Run monthly via cron
test_restore

💡 Best Practices

  • Test restores regularmente: Un backup sin test es un backup sin valor
  • Encripción: Usa S3 SSE o KMS
  • Versioning: Enable S3 versioning para protección contra deletes accidentales
  • Multi-region: Replica backups críticos a otra región
  • Alertas: Notificaciones si backup falla
  • Lifecycle policies: Optimiza costos automáticamente
  • Exclude unnecessary files: logs, tmp, node_modules
  • No hardcodear credenciales: Usa IAM roles o SSO

🔐 IAM Policy Necesaria

{
    "Version": "2012-10-17",
    "Statement": [
        {
            "Effect": "Allow",
            "Action": [
                "s3:PutObject",
                "s3:GetObject",
                "s3:ListBucket",
                "s3:DeleteObject"
            ],
            "Resource": [
                "arn:aws:s3:::my-backups-*",
                "arn:aws:s3:::my-backups-*/*"
            ]
        },
        {
            "Effect": "Allow",
            "Action": [
                "s3:CreateBucket",
                "s3:PutBucketVersioning",
                "s3:PutLifecycleConfiguration"
            ],
            "Resource": "arn:aws:s3:::my-backups-*"
        }
    ]
}

📊 Mi Resultado Final

Infrastructure:
• Backup diario automático a las 2 AM
• Retention: 90 días
• Tamaño promedio: 8 GB/backup
• Storage classes: Standard → IA → Glacier

Costs:
• $6.50/mes (~720 GB total)
• vs $18/mes sin lifecycle
• Ahorro: 64%

Reliability:
• 180+ backups exitosos consecutivos
• 0 fallos en 6 meses
• Restore testing: mensual, 100% éxito
• Recovery time: ~10 minutos

📚 Recursos

💭 Conclusión

Los backups no tienen que ser caros ni complicados. Con S3 + lifecycle policies + un buen script bash, tienes un sistema enterprise-grade por menos de $10/mes. La clave es automatización + testing regular.

Recuerda: el mejor backup es el que funciona cuando lo necesitas. Testea tus restores. Siempre.