Backup strategy
Skill OmarEltak/legacy-prod-survival-kit/skills/backup-strategy
Claude Code skills for the solo engineer who just inherited a 17-year-old production system. Audit, deploy, monitor, and report — without a DevOps team.
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.
What its author says it does
Copied from the file, not written here
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.
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.
Gives 0 of the 12 instructions most roadmap strategy skills give in ~4.5k tokens
Counted across 591 of the 672 authors here whose files we hold, read 2026-08-07
- read product marketing context before asking questionsin 21 of 591, across 10 files
- base price on perceived value, not costin 15 of 591, across 4 files
- compact after finalizing a planin 14 of 591, across 9 files
- differentiate tiers using features, limits, or supportin 14 of 591, across 3 files
- use Van Westendorp to find acceptable price rangein 13 of 591, across 2 files
- use MaxDiff to identify highly valued featuresin 13 of 591, across 2 files
- map topics to buyer journey stagesin 12 of 591, across 6 files
- Extract domain capabilities and classify subdomainsin 11 of 591, across 1 file
- Define bounded contexts around consistency and ownershipin 11 of 591, across 1 file
- Establish a ubiquitous language glossary and anti-termsin 11 of 591, across 1 file
- Capture context boundaries in ADRs before implementationin 11 of 591, across 1 file
- Open the strategic design template if neededin 11 of 591, across 1 file
Said here and by no other author read
- ask for database credentials and directories to back up
- store backups off the primary hosting account
- run database backups nightly
- run file backups weekly
- upload backups to an S3-compatible object store
- set script credentials via environment variables
Grouped from the skills themselves: near-identical wordings counted once, and counted by distinct author, so one author publishing three of these counts once. Length counted with cl100k_base; the agent that loads this file may tokenize it differently.