Tabeer.ai runs on RDS Postgres, which already takes automated snapshots. That's a safety net, but it's not a backup strategy on its own — it's one copy, in one account, in one region, controlled by the same AWS account that could be compromised or misconfigured. I wanted a second, independent, encrypted copy that lives outside RDS entirely. Restic was the tool for that.
Why Restic, Specifically
Three things mattered for a small production box already running close to its resource limits:
- Client-side encryption — the backup is encrypted before it ever leaves the server, so the storage backend (S3, in this case) never sees plaintext. Even a compromised bucket or leaked access key doesn't hand over the data — the Restic repository password is a separate secret.
- Deduplication — Restic chunks data content-aware and only stores unique chunks, so repeated daily backups of a mostly-unchanged database don't multiply storage cost linearly.
- A single static binary —
apt install resticand it's done. No daemon, no separate service to keep patched, nothing that competes for the 908MB this EC2 box has to work with.
The Actual Pipeline
Restic backs up files, not a live database connection, so the real backup unit is a
pg_dump custom-format dump, and Restic's job is to
take that dump file (plus media/, if present) and get it encrypted and off the server safely:
#!/usr/bin/env bash
set -euo pipefail
TMPDIR=$(mktemp -d)
trap 'rm -rf "$TMPDIR"' EXIT
DUMP="$TMPDIR/tabeer-db-$(date +%Y%m%d-%H%M%S).dump"
echo "==> Dumping database"
pg_dump --format=custom --file="$DUMP" "$DATABASE_URL"
echo "==> Ensuring the Restic repository exists"
restic snapshots >/dev/null 2>&1 || restic init
echo "==> Backing up the dump (and media/, if present)"
restic backup "$DUMP" $([ -d backend/media ] && echo backend/media) --tag tabeer-db
echo "==> Applying retention policy (7 daily, 4 weekly, 6 monthly)"
restic forget --keep-daily 7 --keep-weekly 4 --keep-monthly 6 --prune
Wired into systemd as a oneshot service plus a daily timer with jitter, so it runs unattended at 03:00 server time without a cron entry to remember exists:
[Unit]
Description=Tabeer.ai encrypted database backup (pg_dump + Restic)
[Service]
Type=oneshot
EnvironmentFile=/opt/tabeerdotai/backend/backup.env
ExecStart=/opt/tabeerdotai/backend/ops/backup.sh
[Unit]
Description=Run Tabeer.ai's encrypted backup daily
[Timer]
OnCalendar=*-*-* 03:00:00
RandomizedDelaySec=600
Persistent=true
[Install]
WantedBy=timers.target
Where It Actually Stores To
I looked at S3 versus Google Drive for the remote target. Restic has a native S3 backend —
RESTIC_REPOSITORY=s3:s3.<region>.amazonaws.com/<bucket> and it just works with standard AWS
credentials. Google Drive isn't a native Restic backend at all; it needs rclone as a bridge,
with its own OAuth flow and refresh-token lifecycle to keep alive. Given the choice was between
"one more AWS credential" and "an OAuth integration that can silently expire," S3 won without much
debate.
The Part That Actually Took a Day
Storage and encryption weren't the hard part — IAM was. The IAM user I was given credentials for had no attached policy at all. Every S3 call failed:
An error occurred (AccessDenied) when calling the CreateBucket operation: User:
arn:aws:iam::[account]:user/tabeer-claude is not authorized to perform: s3:CreateBucket
And it couldn't even diagnose its own permissions — a permissions boundary blocked
iam:ListAttachedUserPolicies, iam:ListUserPolicies, and iam:ListGroupsForUser too, so there
was no way to introspect what was actually allowed from the API side. The fix needed a human in
the AWS console: IAM → Users → [user] → Add permissions → Attach policies directly →
AmazonS3FullAccess. Once that was attached, bucket creation, versioning, encryption, and the
public-access block all went through immediately.
The bucket itself is locked down beyond just "private by default":
aws s3api put-bucket-versioning --bucket "$BUCKET" --versioning-configuration Status=Enabled
aws s3api put-public-access-block --bucket "$BUCKET" \
--public-access-block-configuration BlockPublicAcls=true,IgnorePublicAcls=true,BlockPublicPolicy=true,RestrictPublicBuckets=true
aws s3api put-bucket-encryption --bucket "$BUCKET" \
--server-side-encryption-configuration '{"Rules":[{"ApplyServerSideEncryptionByDefault":{"SSEAlgorithm":"AES256"}}]}'
Versioning plus a 90-day noncurrent-version expiration lifecycle rule means even an accidental
restic forget --prune run against the wrong flags has a recovery window before S3 itself
permanently deletes anything.
Verifying It Actually Works, Not Just That It Ran
A backup script that exits 0 isn't proof of anything by itself. I ran it manually once before trusting the timer:
==> Dumping database
728K written to /tmp/tmp.xxx/tabeer-db-20260904-211414.dump
==> Backing up the dump (and media/, if present)
Files: 1 new, 0 changed, 0 unmodified
Added to the repository: 727.810 KiB (582.699 KiB stored)
snapshot 779ae485 saved
Then confirmed the objects actually landed in S3, encrypted, under the account's own key — not trusting Restic's own "success" output alone:
aws s3 ls s3://tabeer-ai-backups-.../ --recursive
2026-09-04 23:14:18 155 Bytes config
2026-09-04 23:14:19 934 Bytes data/68/68c67149...
2026-09-04 23:14:19 581.9 KiB data/cc/cc4f8f0d...
2026-09-04 23:14:19 415 Bytes snapshots/779ae485...
That data/ layout, not a single readable .sql file, is the visible proof the encryption and
chunking are actually happening — anyone with bucket read access sees opaque chunks, not a
database dump.
What's Next
Restic can restore individual files out of any snapshot, not just the latest one, which matters if a bad migration only gets noticed a few days later. The retention policy (7 daily, 4 weekly, 6 monthly) exists specifically so that window isn't limited to "yesterday" — it reaches back months without keeping every single daily snapshot forever.
If you're setting up backups for a small production box and want them encrypted and off-server without paying for a managed backup service, get in touch.
By Shahid Malik