The Backup Rule Nobody Follows

The 3-2-1 backup rule says: keep 3 copies of your data, on 2 different storage types, with 1 copy offsite. Most developers know this rule. Almost nobody follows it for their CMS installations. They rely on the hosting provider's daily backup and hope for the best.

We followed that approach until a hosting migration went wrong and we lost 72 hours of content across two sites. The hosting provider's "daily backup" was actually 3 days old. That incident cost us a weekend of manual content recovery and taught us to own our backup strategy.

Database Backups with mysqldump

The foundation is automated MySQL dumps. JekCMS includes a Backup class that wraps mysqldump with proper options:

class Backup
{
    public function databaseBackup(): string
    {
        $filename = 'db_' . DB_NAME . '_' . date('Y-m-d_His') . '.sql.gz';
        $filepath = ROOT_PATH . '/backups/' . $filename;

        $cmd = sprintf(
            'mysqldump --host=%s --user=%s --password=%s ' .
            '--single-transaction --routines --triggers ' .
            '--default-character-set=utf8mb4 %s | gzip > %s',
            escapeshellarg(DB_HOST),
            escapeshellarg(DB_USER),
            escapeshellarg(DB_PASS),
            escapeshellarg(DB_NAME),
            escapeshellarg($filepath)
        );

        exec($cmd, $output, $returnCode);

        if ($returnCode !== 0) {
            throw new Exception('Database backup failed with code: ' . $returnCode);
        }

        return $filepath;
    }
}

Key flags: --single-transaction ensures a consistent snapshot without locking tables (critical for InnoDB). --routines --triggers includes stored procedures. The output is gzipped, typically reducing a 50MB dump to 5-8MB.

File Backups

Database alone is not enough. Uploaded images, theme customizations, and configuration files need backing up too:

public function fileBackup(): string
{
    $filename = 'files_' . date('Y-m-d_His') . '.tar.gz';
    $filepath = ROOT_PATH . '/backups/' . $filename;

    $includes = ['uploads/', 'config/', 'themes/'];
    $excludes = ['cache/*', 'temp/*', 'logs/*', '*.log'];

    $excludeFlags = implode(' ', array_map(
        fn($e) => '--exclude=' . escapeshellarg($e),
        $excludes
    ));

    $cmd = sprintf(
        'tar -czf %s %s -C %s %s',
        escapeshellarg($filepath),
        $excludeFlags,
        escapeshellarg(ROOT_PATH),
        implode(' ', $includes)
    );

    exec($cmd, $output, $returnCode);
    return $filepath;
}

Cron Schedule

# Daily database backup at 3 AM
0 3 * * * php /home/user/public_html/includes/backup-runner.php db

# Weekly file backup on Sundays at 4 AM
0 4 * * 0 php /home/user/public_html/includes/backup-runner.php files

# Monthly full backup on 1st at 5 AM
0 5 1 * * php /home/user/public_html/includes/backup-runner.php full

Rotation Policy

Without rotation, backups consume unlimited storage. Our policy: keep daily backups for 7 days, weekly backups for 4 weeks, monthly backups for 12 months. The cleanup runs after each backup:

public function rotate(): void
{
    $backupDir = ROOT_PATH . '/backups';
    $files = glob($backupDir . '/*.{sql.gz,tar.gz}', GLOB_BRACE);

    foreach ($files as $file) {
        $age = time() - filemtime($file);
        $days = $age / 86400;

        // Daily: keep 7 days
        if (strpos($file, 'db_') !== false && $days > 7) {
            // Keep if it is a Sunday backup (weekly) and less than 30 days
            $dayOfWeek = date('N', filemtime($file));
            if ($dayOfWeek != 7 || $days > 30) {
                // Keep if 1st of month and less than 365 days
                $dayOfMonth = date('j', filemtime($file));
                if ($dayOfMonth != 1 || $days > 365) {
                    unlink($file);
                }
            }
        }
    }
}

Testing Recovery

A backup you have never tested is not a backup — it is a hope. We test recovery quarterly by restoring to a staging server. The process takes about 15 minutes: import the SQL dump, extract the file archive, update config for the staging domain, verify.

Our last recovery test (February 2026) completed in 11 minutes with zero data loss. That confidence is worth every minute spent on the backup system.