Skip to content

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:

DRY_RUN=1 python infra/a2/backup_to_s3.py

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

  1. 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 .
  1. 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'
  1. 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 tar step failed silently).

Until then, keep an eye on /var/log/fruitplug-backup.log on the ops box.