Nightly A2 → S3 backups¶
Fruit Plug's source of truth lives on A2 Hosting (MySQL + wp-content/uploads).
Keeping a second copy elsewhere is non-negotiable — if the A2 account is
compromised, suspended, or the machine just loses a disk, we need to be able
to stand the site back up inside an afternoon.
This page describes the automated nightly pipeline that ships a fresh DB dump
and uploads tarball from A2 to an S3 bucket in eu-west-2.
Pipeline at a glance¶
flowchart LR
A[cron / systemd timer<br/>03:00 UTC] -->|invokes| B[infra/a2/backup_to_s3.py]
B -->|SSH (paramiko)| C[A2 wp-host]
C -->|wp db export + tar| D[/home/fruitplu/backups/daily-YYYYMMDD-HHMMSS/]
D -->|SFTP pull| E[/tmp/fruitplug-backups/<stamp>/]
E -->|boto3.upload_file| F[(s3://fruitplug-backups/a2/YYYY-MM-DD/)]
F -->|lifecycle policy| G{{30 daily + 12 monthly via Glacier IR}}
The script is deliberately thin: all the clever retention logic lives in the
S3 lifecycle policy (infra/aws/lifecycle.json) rather than in Python, so
rerunning the driver is always idempotent.
What gets backed up¶
| Artifact | Source | Archive name |
|---|---|---|
| WordPress database | wp db export |
db.sql |
| Uploaded media | wp-content/uploads |
wp-content-uploads.tar.gz |
Themes, plugins, and mu-plugins are not part of the nightly — they live in
wp-plugin/ in this repo and are redeployable from source. If that ever
changes, add the relevant tar -czf steps in
infra/a2/backup_to_s3.py::run_remote_backup().
Where things live¶
| Concern | Path |
|---|---|
| Python driver | infra/a2/backup_to_s3.py |
| S3 lifecycle rules | infra/aws/lifecycle.json |
| Terraform skeleton (inactive) | infra/aws/s3-bucket-template.tf |
| Cron / systemd templates | infra/aws/cron-template.txt |
| Env vars | .env.example (AWS_* block) |
| Existing A2 SSH helpers | infra/a2/a2.py |
First-time setup¶
1. Provision the S3 bucket¶
Because we don't (yet) run Terraform anywhere else in the repo, the bucket is
created by hand. Commands are documented in
infra/aws/s3-bucket-template.tf — the short version:
aws s3api create-bucket --bucket fruitplug-backups \
--region eu-west-2 \
--create-bucket-configuration LocationConstraint=eu-west-2
aws s3api put-bucket-versioning --bucket fruitplug-backups \
--versioning-configuration Status=Enabled
aws s3api put-public-access-block --bucket fruitplug-backups \
--public-access-block-configuration \
BlockPublicAcls=true,IgnorePublicAcls=true,BlockPublicPolicy=true,RestrictPublicBuckets=true
aws s3api put-bucket-lifecycle-configuration --bucket fruitplug-backups \
--lifecycle-configuration file://infra/aws/lifecycle.json
2. Create a scoped IAM user¶
Create fruitplug-backup-writer with an inline policy that only allows
PutObject / ListBucket / DeleteObject / GetObject on the a2/*
prefix. The exact JSON is in the Terraform skeleton.
Copy the generated access key into /etc/fruitplug/backup.env:
A2_PASSPHRASE=...
AWS_ACCESS_KEY_ID=AKIA...
AWS_SECRET_ACCESS_KEY=...
AWS_REGION=eu-west-2
AWS_S3_BUCKET=fruitplug-backups
AWS_S3_PREFIX=a2/
chmod 0600 /etc/fruitplug/backup.env — the file contains two sets of
credentials.
3. Schedule the driver¶
See infra/aws/cron-template.txt. The short version for a Linux ops box:
0 3 * * * set -a && . /etc/fruitplug/backup.env && set +a && \
cd /opt/fruitplug/fruitplug-web/infra/a2 && \
/opt/fruitplug/fruitplug-web/.venv/bin/python backup_to_s3.py \
>> /var/log/fruitplug-backup.log 2>&1
A systemd timer variant is in the same file and is preferred on any machine running systemd.
4. Smoke test¶
Before enabling the schedule, run once by hand with DRY_RUN=1 so nothing
reaches S3:
You should see the remote wp db export, the SFTP pull, and a
DRY-RUN would upload ... line per artifact. No PUTs hit AWS.
Retention¶
infra/aws/lifecycle.json codifies two rules:
a2/*— daily archives expire after 30 days. Noncurrent versions expire 7 days after being superseded. Incomplete multipart uploads are aborted after 3 days.a2/monthly/*— transitioned to Glacier IR after 30 days and expired after 365. The monthly prefix is populated by a separate cron that copies the 1st-of-month daily archive sideways (not yet wired — track under roadmap 4.5).
Apply the policy with:
aws s3api put-bucket-lifecycle-configuration \
--bucket fruitplug-backups \
--lifecycle-configuration file://infra/aws/lifecycle.json
Restoring¶
- Pull the archive for the desired day:
aws s3 cp s3://fruitplug-backups/a2/2026-04-24/db.sql .
aws s3 cp s3://fruitplug-backups/a2/2026-04-24/wp-content-uploads.tar.gz .
- On the A2 host (or a replacement WP install):
wp db import db.sql
tar -xzf wp-content-uploads.tar.gz -C /path/to/wp
wp search-replace 'https://old-domain' 'https://new-domain'
- Spot-check the homepage, a product page, and
/wp-admin.
Alarms¶
Not yet wired — once CloudWatch is set up, add:
- Missed upload alarm — no new object under
a2/for > 36 h. - Size regression alarm — daily archive < 50 % of the trailing 7-day
median (usually means the
tarstep failed silently).
Until then, keep an eye on /var/log/fruitplug-backup.log on the ops box.