Backup strategy
Skill OmarEltak/legacy-prod-survival-kit/skills/backup-strategy
Use when setting up off-server backups for a legacy production system on shared cPanel/Plesk hosting. Generates a working nightly DB backup + weekly file backup that runs entirely from cPanel cron and pushes to a free off-server destination (Backblaze B2 or S3). No shell access required. No PHP exec functions required. Includes the restore-test runbook because a backup never restored doesn't actually exist.From its SKILL.md
npx -y skills add OmarEltak/legacy-prod-survival-kit --skill backup-strategyAssembled from the repository path, not quoted from the project. Check it against their README if it does not work.
One thing to look at
- 0 stars0 stars. Stars are a popularity signal and not a quality one, but at this level it is likely that nobody has read this closely except its author, and you would be relying on your own review.
SKILL.md
16.3 KB, ~4.5k tokens by cl100k_base, as published. Nobody here has run it
Backup strategy for shared hosting
When to use this
You are responsible for a production system on shared cPanel/Plesk hosting. The current backup story is one of these:
- "The host does backups." (You don't know how often, where they're stored, or whether they're restorable. You don't control them.)
- "The previous developer set up something." (You can find a script. You can't tell if it's still running. You can't tell where it puts the backups. Last successful run is unknown.)
- Nothing.
You need a backup system that:
- You control end-to-end.
- Runs nightly for the database, weekly for files.
- Stores backups off the same hosting account (so a compromised or deleted account doesn't take the backups with it).
- Costs $0 (free tier of a real backup-friendly storage provider).
- Works on shared hosting with no SSH and disabled PHP
execfunctions. - Has a documented, tested restore procedure. A backup that has never been restored doesn't exist.
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.
Architecture
┌──────────────────────────┐
│ 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:
- 10 GB free storage (then $0.005/GB/mo).
- 1 GB/day free egress (then $0.01/GB).
- S3-compatible API.
- No surprise bills (you can hard-cap the bucket size).
- Has been around long enough to trust.
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).
Pre-requisites
- cPanel access with Cron Jobs enabled. (Almost always available.)
- A Backblaze account (free signup) with one bucket + an Application Key scoped to that bucket.
- Knowledge of: your DB credentials, your DB name, the directories you want backed up.
Pre-work: Backblaze B2 setup (one-time, ~5 min)
- Sign up at backblaze.com/b2. Confirm email.
- Buckets → Create a Bucket. Type: Private. Name:
<yourdomain>-backups. Default lifecycle: keep last 30 days fordb/, 90 days forfiles/. (You can refine this later via lifecycle rules.) - App Keys → Add a New Application Key. Name:
<yourdomain>-cron. Allow access to: only the new bucket. Allowed capabilities:listBuckets,listFiles,readFiles,writeFiles. Save thekeyIDandapplicationKey— they're shown ONCE.
You'll need three values from B2:
B2_KEY_IDB2_APP_KEYB2_BUCKET_NAME
Skill questions
When invoked, ask:
- DB credentials and name. Username, password, database name (and host if not localhost).
- What directories should be backed up? Default:
wp-content/uploads,sitefiles/studentfiles, or whatever the upload dirs are for your specific app. AVOID backing up the entirepublic_html(it's mostly code that's already in git). - B2 keyID, B2 application key, B2 bucket name.
- Server username (the
userin/home/user/). - Notification email for backup failures (default: account email).
- Schedule (defaults: DB nightly at 03:00, files Sunday 04:00).
Artifact 1 — backup-db.sh
Goes 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 0
Critical 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.sh
Or, more securely, source them from a separate file outside web root:
0 3 * * * source /home/user/.backup-secrets && /home/user/scripts/backup-db.sh
Where /home/user/.backup-secrets (chmod 600) contains:
export DB_USER='reg_user'
export DB_PASS='secret'
# etc
Artifact 2 — backup-files.sh
Same 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.)
Artifact 3 — Cron jobs
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.
Artifact 4 — restore-runbook.md
A 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)
```bash
# Auth
B2_AUTH=$(curl -s -u "${B2_KEY_ID}:${B2_APP_KEY}" https://api.backblazeb2.com/b2api/v3/b2_authorize_account)
API_URL=$(...)
AUTH_TOKEN=$(...)
# Download specific file
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: ...
## The restore-test ritual
Once a quarter, do this:
1. Pick a recent backup (not the latest — pick yesterday's).
2. Restore it to a non-production environment (a Docker container, a local MySQL, a separate cPanel database with a different name).
3. Run your top 5 read queries against the restored DB.
4. Verify file counts match expectations.
5. Update `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.
## Common mistakes
- **Hardcoding credentials in `backup-db.sh`.** They end up backed up. Source from a separate file outside web root.
- **Backing up `public_html` wholesale.** Most of it is code (in git). You're paying B2 to store the same bytes 30 times. Back up data only.
- **Not checking dump size.** A failed mysqldump can produce a 0-byte file that uploads "successfully." The script above bails if dump < 1 KB.
- **Skipping the restore test.** This is the only mistake that matters. The other ones are recoverable.
- **Using cPanel's own backup product as your only backup.** It's their backup of their copy. It vanishes if the account is suspended/deleted/migrated. Off-server matters.
- **Storing B2 keys with full account access** instead of bucket-scoped App Keys. Limit blast radius.
## Why this skill exists
The standard answer to "how do I back up shared hosting?" online is:
- "Use cPanel's backup feature." (You don't control retention, location, or restorability.)
- "SSH in and rsync." (You don't have SSH.)
- "Use [paid backup service]." ($10-50/mo; eats budget.)
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.
## Estimated setup time
| 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.
What ships with it
Read from the repository
Just SKILL.md. No reference files, no scripts.