All Articles
Infrastructure

Encrypted, Deduplicated Backups for Tabeer.ai With Restic and S3

Shahid MalikBy Shahid MalikSeptember 4, 20267 min read

Tabeer.ai had a production database with no backup story beyond RDS's own snapshots. Here's how I wired up Restic for encrypted, deduplicated, off-server backups — including the IAM permissions dead-end that stalled it for a day.

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 binaryapt install restic and 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.

Related Articles

Security

The Encryption Checklist for a Small Django Production Stack

Thirteen encryption checks, framed as 'encryption at multiple layers, not just HTTPS.' Most of the layers already existed — TLS to Postgres, client-side-encrypted backups — one item doesn't apply to this architecture at all, and two needed an AWS console check I couldn't run myself.

7 min read
Security

Rate Limiting, Token Blacklisting, and Admin MFA — Auth Hardening for Tabeer.ai

Part two of the production-readiness checklist: authentication and account security. Five of fifteen items were genuinely missing — rate limiting, real logout, admin MFA, consent records, and self-service data export/deletion — and here's exactly how each got fixed without a new dependency for most of them.

10 min read
Shahid Malik - AI-First Odoo Consultant

Shahid Malik

AI-First Odoo ERP Specialist

Shahid Malik is an AI-first Odoo consultant helping businesses solve complex ERP and business process challenges. His work combines Odoo consulting, process optimization, automation, integrations, migrations, and practical AI solutions to build scalable and reliable business systems.

Book a consultation for your Odoo project
Discuss Your Odoo Project