Skip to content

Backups with restic and R2

The starter’s backup stack couldn’t work

Section titled “The starter’s backup stack couldn’t work”

Two bugs, each enough on its own:

  1. Credentials arrive as literal text. Its environment used AWS_ACCESS_KEY_ID=$${R2_ACCESS_KEY_ID}. In Compose, $$ is the escape for a literal $, so restic would receive the string ${R2_ACCESS_KEY_ID}.

  2. Wrong variable name. It set RESTIC_REPO. restic reads RESTIC_REPOSITORY — confirmed from the binary itself:

    Terminal window
    docker run --rm restic/restic:0.19.1 backup --help | grep -E -- '--repo|--password-file'
    # -r, --repo repository … (default: $RESTIC_REPOSITORY)
    # -p, --password-file file … (default: $RESTIC_PASSWORD_FILE)

It also kept secrets in plain env vars, backed up volumes that don’t exist here (and not Gitea), and swallowed failures with || echo "backup failed (continuing)".

Included Why
pg_dump of the Gitea database A dump is consistent; copying live database files isn’t
gitea-data volume Repositories, attachments, container registry
traefik-acme volume Certificates (re-issuable, but rate-limited)
/opt/vps-infra/starter Compose files, .env, secrets/, Traefik config — encrypted by restic
Left out Why
The runner’s volumes A cache and a re-creatable registration
The site container Stateless; rebuilt from git
The pre-rebuild archives Deleted when the old system is retired

nightly-backup.sh, run from the admin user’s crontab:

  1. docker exec gitea-db pg_dump -U gitea -d gitea -Fc into a dump file, written atomically, keeping the newest 3.
  2. restic backup of the list above, with RESTIC_REPOSITORY=s3:https://<account-id>.r2.cloudflarestorage.com/<bucket>, RESTIC_PASSWORD_FILE, and the R2 keys in an AWS-style credentials file (AWS_SHARED_CREDENTIALS_FILE).
  3. Sundays: restic forget --keep-daily 14 --keep-weekly 8 --keep-monthly 12 --prune and restic check.

Any failure stops the run and is logged. It initializes the repository only when restic cat config fails, so a network error can’t trigger a second init.

  1. Save the restic password (generated on the server with openssl rand -base64 32) in a password manager. Without it, no backup can ever be read.

  2. R2 → Manage API Tokens → Create Account API token: permission Object Read & Write, limited to the backup bucket. Copy the Access Key ID and Secret Access Key (shown once) and note the Account ID. R2 requires a paid plan before it issues tokens.

  3. Write secrets/r2_credentials:

    Terminal window
    read -rp 'R2 Access Key ID: ' K && read -rsp 'R2 Secret Access Key: ' S && echo \
    && (umask 077; printf '[default]\naws_access_key_id = %s\naws_secret_access_key = %s\n' "$K" "$S" > secrets/r2_credentials) \
    && unset K S
  4. First run, then test-restore into a scratch directory, compare with the live data, and pg_restore --list a dump.

  5. Only then install the cron line.

Whether restic honours AWS_SHARED_CREDENTIALS_FILE isn’t in its man pages, so step 4 is also that test.