backup-strategy — independently scanned and version-tracked by SaferSkills.
SaferSkills independently audited backup-strategy (Agent Skill) and scored it 100/100 (green). The audit ran 55 deterministic rules across Security, Supply Chain, Maintenance, Transparency, and Community; it found 0 high-severity and 0 lower-severity findings. The full rule-by-rule trace and per-finding evidence are below. Free, methodology-open.
Findings & checks · 0 flagged
Every scanned point with the score it earned and what moved between them.
First recorded scan — no prior version to compare against.
The primary manifest — the file an agent reads to learn what this artifact does.
You are responsible for a production system on shared cPanel/Plesk hosting. The current backup story is one of these:
You need a backup system that:
exec functions.This skill produces all of that.
Run this before solo-engineer-pipeline. The deploy pipeline is a tool that can break things; backups are the prerequisite that lets you safely use that tool.
┌──────────────────────────┐
│ cPanel Cron (nightly) │
│ 03:00 server time │
│ /home/user/scripts/ │
│ backup-db.sh │
└──────────────┬───────────┘
│
├─ mysqldump --single-transaction
├─ gzip
└─ curl PUT (Authorization: Bearer ...)
▼
┌─────────────────────────┐
│ Backblaze B2 (free) │
│ bucket: yourdomain-bkp │
│ retention: 30 days │
└─────────────────────────┘
┌──────────────────────────┐
│ cPanel Cron (weekly) │
│ Sun 04:00 │
│ /home/user/scripts/ │
│ backup-files.sh │
└──────────────┬───────────┘
│
├─ tar czf - <upload-dirs>
└─ curl PUT (chunked, large_file API)
▼
┌─────────────────────────┐
│ Backblaze B2 (same) │
│ retention: 90 days │
└─────────────────────────┘Why Backblaze B2:
Alternatives (functionally equivalent for our purposes): AWS S3 (12-month free tier, then watch costs), Cloudflare R2 (10 GB free, no egress fees, less mature), Wasabi (no free tier, $6/mo flat for 1 TB).
<yourdomain>-backups. Default lifecycle: keep last 30 days for db/, 90 days for files/. (You can refine this later via lifecycle rules.)<yourdomain>-cron. Allow access to: only the new bucket. Allowed capabilities: listBuckets, listFiles, readFiles, writeFiles. Save the `keyID` and `applicationKey` — they're shown ONCE.You'll need three values from B2:
B2_KEY_IDB2_APP_KEYB2_BUCKET_NAMEWhen invoked, ask:
wp-content/uploads, sitefiles/studentfiles, or whatever the upload dirs are for your specific app. AVOID backing up the entire public_html (it's mostly code that's already in git).user in /home/user/).backup-db.shGoes at /home/user/scripts/backup-db.sh. Made executable via cPanel File Manager (right-click → Permissions → 0700).
#!/usr/bin/env bash
# backup-db.sh — nightly DB backup to Backblaze B2
# Runs from cPanel cron. No shell access required to set this up;
# you upload this file via cPanel File Manager.
set -euo pipefail
# === config (edit these) ===
DB_HOST="localhost"
DB_USER="${DB_USER:-CHANGE_ME}"
DB_PASS="${DB_PASS:-CHANGE_ME}"
DB_NAME="${DB_NAME:-CHANGE_ME}"
B2_KEY_ID="${B2_KEY_ID:-CHANGE_ME}"
B2_APP_KEY="${B2_APP_KEY:-CHANGE_ME}"
B2_BUCKET="${B2_BUCKET:-CHANGE_ME}" # bucket name, e.g. mydomain-backups
NOTIFY_EMAIL="${NOTIFY_EMAIL:[email protected]}"
RETENTION_DAYS=30
# === derived ===
TIMESTAMP=$(date +%Y-%m-%d_%H-%M-%S)
BACKUP_FILE="/tmp/db-${DB_NAME}-${TIMESTAMP}.sql.gz"
B2_OBJECT_NAME="db/${DB_NAME}-${TIMESTAMP}.sql.gz"
LOG="/home/${USER:-$(whoami)}/scripts/backup-db.log"
log() { echo "[$(date -Iseconds)] $*" >> "$LOG"; }
bail() {
log "FATAL: $*"
if [[ -n "$NOTIFY_EMAIL" ]]; then
echo "Backup of $DB_NAME failed: $*" | mail -s "[BACKUP FAIL] $DB_NAME" "$NOTIFY_EMAIL" || true
fi
exit 1
}
trap 'rm -f "$BACKUP_FILE"' EXIT
log "=== START ==="
log "Dumping DB: $DB_NAME"
# 1. Dump the database
mysqldump --single-transaction --quick --lock-tables=false \
-h "$DB_HOST" -u "$DB_USER" -p"$DB_PASS" "$DB_NAME" \
| gzip --best > "$BACKUP_FILE" || bail "mysqldump failed"
DUMP_SIZE=$(stat -c %s "$BACKUP_FILE" 2>/dev/null || stat -f %z "$BACKUP_FILE")
log "Dump complete: ${DUMP_SIZE} bytes"
if [[ "$DUMP_SIZE" -lt 1000 ]]; then
bail "Dump suspiciously small (${DUMP_SIZE} bytes). Aborting upload."
fi
# 2. Get B2 auth token
log "Authenticating with B2..."
B2_AUTH=$(curl -s -u "${B2_KEY_ID}:${B2_APP_KEY}" \
https://api.backblazeb2.com/b2api/v3/b2_authorize_account)
API_URL=$(echo "$B2_AUTH" | grep -o '"apiUrl":"[^"]*"' | cut -d'"' -f4)
AUTH_TOKEN=$(echo "$B2_AUTH" | grep -o '"authorizationToken":"[^"]*"' | cut -d'"' -f4)
[[ -z "$API_URL" || -z "$AUTH_TOKEN" ]] && bail "B2 auth failed: $B2_AUTH"
# 3. Get bucket ID
BUCKET_INFO=$(curl -s -H "Authorization: $AUTH_TOKEN" \
-d "{\"accountId\":\"$(echo "$B2_AUTH" | grep -o '"accountId":"[^"]*"' | cut -d'"' -f4)\",\"bucketName\":\"$B2_BUCKET\"}" \
"$API_URL/b2api/v3/b2_list_buckets")
BUCKET_ID=$(echo "$BUCKET_INFO" | grep -o '"bucketId":"[^"]*"' | cut -d'"' -f4)
[[ -z "$BUCKET_ID" ]] && bail "Could not find bucket: $B2_BUCKET"
# 4. Get an upload URL
UPLOAD_INFO=$(curl -s -H "Authorization: $AUTH_TOKEN" \
-d "{\"bucketId\":\"$BUCKET_ID\"}" \
"$API_URL/b2api/v3/b2_get_upload_url")
UPLOAD_URL=$(echo "$UPLOAD_INFO" | grep -o '"uploadUrl":"[^"]*"' | cut -d'"' -f4)
UPLOAD_AUTH=$(echo "$UPLOAD_INFO" | grep -o '"authorizationToken":"[^"]*"' | cut -d'"' -f4)
[[ -z "$UPLOAD_URL" ]] && bail "Could not get upload URL"
# 5. Compute SHA1 (B2 requires it)
SHA1=$(sha1sum "$BACKUP_FILE" | awk '{print $1}')
# 6. Upload
log "Uploading ${B2_OBJECT_NAME} (${DUMP_SIZE} bytes)..."
UPLOAD_RESULT=$(curl -s \
-H "Authorization: $UPLOAD_AUTH" \
-H "X-Bz-File-Name: $(printf '%s' "$B2_OBJECT_NAME" | sed 's:/:%2F:g')" \
-H "Content-Type: application/x-gzip" \
-H "X-Bz-Content-Sha1: $SHA1" \
--data-binary "@$BACKUP_FILE" \
"$UPLOAD_URL")
if echo "$UPLOAD_RESULT" | grep -q '"fileId"'; then
FILE_ID=$(echo "$UPLOAD_RESULT" | grep -o '"fileId":"[^"]*"' | cut -d'"' -f4)
log "OK: uploaded $B2_OBJECT_NAME (fileId=$FILE_ID)"
else
bail "Upload failed: $UPLOAD_RESULT"
fi
log "=== END ==="
exit 0Critical configuration steps:
The script reads credentials from environment variables. Do not hardcode them in the file (they end up in backups otherwise). Instead, set them in the cron command:
0 3 * * * DB_USER='reg_user' DB_PASS='secret' DB_NAME='reg_main' B2_KEY_ID='001abc...' B2_APP_KEY='K001xyz...' B2_BUCKET='mydomain-backups' /home/user/scripts/backup-db.shOr, more securely, source them from a separate file outside web root:
0 3 * * * source /home/user/.backup-secrets && /home/user/scripts/backup-db.shWhere /home/user/.backup-secrets (chmod 600) contains:
export DB_USER='reg_user'
export DB_PASS='secret'
# etcbackup-files.shSame pattern, weekly. Slightly different upload path because file backups can be larger (B2 has a "large file API" for >100 MB).
#!/usr/bin/env bash
# backup-files.sh — weekly file backup to Backblaze B2
set -euo pipefail
# === config (set via env or .backup-secrets) ===
B2_KEY_ID="${B2_KEY_ID:-CHANGE_ME}"
B2_APP_KEY="${B2_APP_KEY:-CHANGE_ME}"
B2_BUCKET="${B2_BUCKET:-CHANGE_ME}"
NOTIFY_EMAIL="${NOTIFY_EMAIL:[email protected]}"
# Directories to back up. AVOID backing up everything — code is in git.
# Back up only user-uploaded content / data.
BACKUP_DIRS=(
"/home/${USER:-$(whoami)}/public_html/sitefiles/studentfiles"
"/home/${USER:-$(whoami)}/public_html/sitefiles/mediafiles"
"/home/${USER:-$(whoami)}/public_html/student"
# Add your upload dirs here
)
TIMESTAMP=$(date +%Y-%m-%d)
BACKUP_FILE="/tmp/files-${TIMESTAMP}.tar.gz"
B2_OBJECT_NAME="files/files-${TIMESTAMP}.tar.gz"
LOG="/home/${USER:-$(whoami)}/scripts/backup-files.log"
log() { echo "[$(date -Iseconds)] $*" >> "$LOG"; }
bail() {
log "FATAL: $*"
echo "File backup failed: $*" | mail -s "[BACKUP FAIL] files" "$NOTIFY_EMAIL" || true
exit 1
}
trap 'rm -f "$BACKUP_FILE"' EXIT
log "=== START ==="
# 1. Build the tarball
EXISTING_DIRS=()
for d in "${BACKUP_DIRS[@]}"; do
[[ -d "$d" ]] && EXISTING_DIRS+=("$d")
done
[[ ${#EXISTING_DIRS[@]} -eq 0 ]] && bail "No backup dirs exist. Check BACKUP_DIRS in script."
log "Backing up ${#EXISTING_DIRS[@]} directories..."
tar czf "$BACKUP_FILE" "${EXISTING_DIRS[@]}" 2>>"$LOG" || bail "tar failed"
SIZE=$(stat -c %s "$BACKUP_FILE" 2>/dev/null || stat -f %z "$BACKUP_FILE")
log "Tarball size: $SIZE bytes"
# 2. Auth + upload (same B2 dance as backup-db.sh)
# (For brevity, use the same auth/upload functions extracted into a shared
# /home/user/scripts/lib-b2.sh and sourced from both scripts.)
source /home/${USER:-$(whoami)}/scripts/lib-b2.sh
b2_auth || bail "auth"
b2_get_upload_url || bail "upload URL"
b2_upload "$BACKUP_FILE" "$B2_OBJECT_NAME" || bail "upload"
log "OK: $B2_OBJECT_NAME uploaded"
log "=== END ==="(For files larger than ~100 MB, replace the simple upload with B2's b2_start_large_file + b2_upload_part chunked upload. The full implementation is in the Backblaze docs.)
In cPanel → Cron Jobs:
# Nightly DB backup at 03:00 server time
0 3 * * * source /home/user/.backup-secrets && /home/user/scripts/backup-db.sh
# Weekly file backup on Sunday at 04:00
0 4 * * 0 source /home/user/.backup-secrets && /home/user/scripts/backup-files.sh
# Monthly retention check (deletes B2 files older than 30 / 90 days)
# B2 has lifecycle rules in the bucket UI — prefer those over a script.Tip: cPanel cron emails the cron output to the account email by default. The scripts redirect their normal output to a log file and only mail on failure. Adjust based on whether you want "all good" daily emails or only failure emails.
restore-runbook.mdA document. The most underrated artifact. Goes at /home/user/scripts/RESTORE.md and also in your local notes (so it survives the host being lost).
# Restore runbook
## To restore the database
1. Download the latest backup from B2:
- Log into Backblaze (https://secure.backblaze.com)
- Buckets → <yourdomain>-backups → db/ → click latest .sql.gz → Download
- OR: `curl` it down using the B2 download API (see "Programmatic restore" below)
2. Decompress:gunzip db-<DB_NAME>-<TIMESTAMP>.sql.gz
3. Restore. From cPanel → phpMyAdmin → select the target database → Import → choose the .sql file.
For databases >50 MB, use SSH if available, or split the file:split -l 1000000 db.sql db-part-
And import each part.
4. Verify. Run a few key queries:
- `SELECT COUNT(*) FROM users;`
- `SELECT MAX(created_at) FROM transactions;`
The latest record date should match the backup timestamp.
## To restore files
1. Download the .tar.gz from B2.
2. Extract:tar xzf files-<DATE>.tar.gz -C /home/user/
3. Verify file counts and a few specific files exist.
## To programmatically restore (for automation)
B2_AUTH=$(curl -s -u "${B2_KEY_ID}:${B2_APP_KEY}" https://api.backblazeb2.com/b2api/v3/b2_authorize_account) API_URL=$(...) AUTH_TOKEN=$(...)
curl -H "Authorization: $AUTH_TOKEN" \ "$API_URL/file/<bucket>/db/<filename>" \ -o restored.sql.gz
## When did we last test this?
- Last restore-test: <DATE>
- Result: <PASS/FAIL>
- Notes: ...Once a quarter, do this:
restore-runbook.md with: today's date, PASS/FAIL, any issues encountered.If it fails, fix immediately. If it passes, you've confirmed your backups are real.
A backup that has never been restored is an unverified hope. The quarterly restore-test converts hope into evidence.
The standard answer to "how do I back up shared hosting?" online is:
There's a gap: a free, self-hosted-friendly, restorable backup solution that works without shell access. This skill fills it.
The pattern (cron + curl + B2) is simple, reliable, and has been used in production by enough people that the edge cases are well-known. It scales from a tiny WordPress site to multi-GB legacy systems. It's also instantly portable to any other S3-compatible storage if you want to switch providers.
| Step | Time |
|---|---|
| Create Backblaze account + bucket + App Key | 5 min |
Adapt backup-db.sh to your DB credentials | 5 min |
Adapt backup-files.sh to your upload dirs | 5 min |
| Upload scripts via cPanel File Manager, set permissions | 5 min |
| Configure cron jobs | 5 min |
| Run first manual test of each script | 10 min |
| Verify backups appeared in B2 | 5 min |
Write restore-runbook.md | 10 min |
| Do first restore test | 30 min |
| Total | ~80 min |
After that, it runs forever. Quarterly restore tests: 30 min each.
~30 seconds. Free. No account. Every finding cites a rule and a line of evidence.