Encrypted off-site backup of my companion's brain with AWS S3 + restic — moving memory off-site without ever uploading it in plaintext
Introduction
I run a personal system that works like an external long-term memory: I feed it daily work logs and records of decisions, and it retrieves whatever is semantically closest through search. It’s called RAG (a mechanism that recalls past records via vector search), and I’ve already written, in separate articles, about its design philosophy and about the two times I broke its data while operating it. I’ll leave those premises to those articles.
I decided to “take a backup” but never actually made one — and broke my memory vector DB twice
At the end of that backup article, I ended by saying only that I would “design a remote encrypted backup.” This article is the follow-up record of actually building that design, running it, and confirming it all the way through to restore. I used only two things: AWS S3 (Amazon’s object storage service) and restic (a dedicated backup tool). Here are the key points up front.
- Move off-site without breaking the principle of never uploading plaintext (unencrypted raw data) to the cloud
- Don’t make it a one-off backup — fold it into the daily batch that’s already running, so it works unattended
- Don’t stop at “I put it there” — only call it done after confirming it can actually be restored somewhere else
Why keeping only a local copy still isn’t enough
This memory has been broken twice in the past. Both times, the cause was touching a DB format that opens its file directly, from multiple processes. The full story is in a separate article.
Every time it broke, I restored from the local backup. But the fact that it only existed locally kept nagging at me. A local copy protects you from “I broke it by an operational mistake,” but if the PC itself fails, is stolen, or is caught in a disaster, that local copy disappears right along with it. My externalized memory was tied to the lifespan of a single machine. That was the limit of keeping only a local copy.
So I wanted to place it remotely. But here, two requirements collide that don’t naturally coexist.
How to reconcile “don’t upload plaintext” with “place it remotely”
The content of these records is personal conversation logs and a history of decisions. I don’t want to upload them to the cloud as-is. On the other hand, surviving a disaster means placing them somewhere other than my own location — off-site. “I don’t want to upload it” and “I can’t protect it unless I upload it” seem, at first glance, to contradict each other.
What resolved it was restic’s built-in client-side encryption. Client-side encryption means “encrypting it locally before sending it.” restic turns the data into an encrypted blob using AES-256 (a cipher strength widely used today) before sending it to S3. The decryption key exists only on my machine and in an offline copy that I hold myself.
data/ (plaintext = primary source of conversation logs and lessons)
│
▼
┌──────────────────────────┐
│ restic encrypts locally │ AES-256 / key stays local only
└─────────────┬────────────┘
│ encrypted blob (contents can no longer be read)
┌────────┴───────────────────┐
▼ ▼
Local copy Send to AWS S3
│ │
│ ▼
│ Anyone without the key
│ (including S3 itself)
└──────────────────▶ can never read the contents
Since only the encrypted blob is placed on S3, the principle of “never upload plaintext” isn’t broken. Even the cloud provider can’t see the contents. What looked like a contradiction disappeared once I fixed the point of encryption at “before sending.”
I chose restic as the method because it’s a single executable that runs as-is on Windows, with incremental backup (sending only the diff from last time) and encryption built in by default. A comparable well-known tool is borg, but its Windows support is weak, so I passed on it.
Don’t bet on one method — keep it in three layers
Rather than just adding one off-site copy, I built a configuration where “memory survives even if any single one of these dies.” What I protect is scoped to data/ (the actual Markdown files and DB that are the primary source of the records, about 16MB). The vector search index is a derived artifact that can be rebuilt from this primary source, so there’s no need to carry it all the way off-site.
| Layer | Target | Location | Nature |
|---|---|---|---|
| ① | The vector DB itself | Local (keeps past generations) | A snapshot taken right before a destructive operation |
| ② | data/ primary source | Local restic repository | No AWS needed — always runs |
| ③ | data/ primary source | restic on S3 (encrypted) | True off-site |
data/ ends up held in triplicate: ② + ③ + (git history, same as for code). ② and ③ each automatically prune old generations, keeping only about the last 30. Not betting everything on one service called S3 — that’s a deliberate design choice. Having an off-site copy tends to make you complacent about the local one, so I did the opposite. Local (②) always runs regardless of AWS’s state, and S3 (③) is positioned as an extra layer of insurance on top of it.
A side effect — folding it straight into the existing daily batch
I didn’t make this backup a standalone script. I built it in as a later stage of the daily batch that already runs every day at 04:00 (a process called distill, which promotes notes into lessons — details in a separate article).
I want to make one point clear here, since it’s the easiest thing for readers to conflate. This batch does not run on GitHub. It runs on my own PC. The code sits on GitHub, but all GitHub does is “store the data/ that got pushed” — it doesn’t run the computation at 04:00. The actor that encrypts on schedule and sends it to S3 is, without exception, the local Windows Task Scheduler.
┌── missed the 04:00 run ──┐ ← PC was off/asleep
│ │
Windows Task ─────┴─ caught up at next logon ┘ (catch-up)
(daily 04:00, local PC)
│
▼
(1) Evacuate the vector DB ──fail──▶ abort the rest (no point without a foundation)
│ success
▼
(2) distill (promote notes → lessons)
│
├── fail ───────────────┐ ← non-fatal: doesn't bring down the whole batch
▼ │
(3) Multi-layer backup of data/ ◀─┘
├─▶ ② local restic (no AWS needed — always runs)
└─▶ ③ S3 restic ─┬─ auth profile present ─▶ send
│ └─ absent / expired ─────▶ don't skip silently — log "skipped"
▼
(4) Retraction of wrong lessons (see the companion article)
The return edge of “catch up at the next logon if it’s missed” and the branch inside the S3 send are the crux of this design. I didn’t build it to always wake the machine at a fixed time. I’ll explain why in the next two sections.
To run it unattended — why a minimal-privilege long-lived key instead of SSO
Sending to S3 requires authenticating to AWS. For ordinary manual work, SSO (sign in once, usable for a fixed period) is enough. But this batch runs unattended at 04:00. SSO tokens (a temporary pass) expire over time, so by the time the batch runs unattended, it may already have expired.
What’s troublesome is how it fails when expired. Authentication fails and the send doesn’t go through, yet the batch finishes as if nothing happened, and no error even appears in the log — the failure silently looks like success (a silent false negative). For a backup, this is the worst case: it produces “I thought it was being taken, but in fact it hadn’t been for months.”
So I created a dedicated user on the AWS side that can only read and write this one bucket (the storage container), and placed a long-lived key that doesn’t expire in my local profile. Permissions are scoped down to only the operations needed (least privilege). Here’s roughly what the IAM (AWS’s permission management) policy looks like.
// Allow only list / get / put / delete on this bucket (nothing else is permitted)
{
"Effect": "Allow",
"Action": ["s3:ListBucket", "s3:GetObject", "s3:PutObject", "s3:DeleteObject"],
"Resource": [
"arn:aws:s3:::mint-chroma-backup-<アカウントID>",
"arn:aws:s3:::mint-chroma-backup-<アカウントID>/*"
]
}
Even if this key leaked, all that could be done is read/write on this one backup bucket — it can’t reach any other AWS resource. This choice satisfies both “don’t fail silently” and “contain the damage even if it leaks,” at the same time. If the auth profile is unset or expired, ③ is logged as “skipped” rather than silently dropped, as shown in the diagram above, and ② is not stopped.
Tolerate failure, make it up through recovery — the choice not to wake the machine on schedule
Another design decision concerns two switches in Task Scheduler.
- If missed, catch up at the next startup (catch-up enabled)
- Don’t wake the PC on schedule (no auto-start at 4am)
In other words: “if the PC is asleep at 04:00, simply give up on that run — but catch up the next time I log on.” Rather than insisting on running exactly on schedule by waking the machine in the middle of the night, I adopted a policy of tolerating failure but always recovering. For personal operation, I judged that the real cost (power, wear, noise) of waking a single machine every night at 4am doesn’t match the urgency of a daily backup. I confirmed how it actually behaves during sleep by letting it sleep and checking the log the next morning.
repository does not exist — check reality before jumping to complicated hypotheses
This may sound like a tidy design so far, but when I first set up the S3 side, restic stubbornly refused to go through. All it kept returning was repository does not exist (the backup location doesn’t exist).
At first I suspected an authentication problem, then the way the endpoint was written (S3’s address notation), then even differences in how file paths are handled. My hypotheses kept ballooning toward more complexity. I stopped and went back to the most basic check of all — is the storage location actually there?
# Before jumping to complicated hypotheses, check in one shot whether it actually exists
aws s3 ls s3://mint-chroma-backup-<アカウントID>/ --recursive
It was empty. Neither authentication nor the endpoint was the problem — the initialization that creates the backup location (restic init) simply hadn’t been done. That was all. Once I cleanly re-initialized it, it went through without a hitch. The lesson is clear: when you see repository does not exist, check whether it actually exists once before jumping to complicated hypotheses like authentication or path notation.
There’s one more thing I decided during this work. For processes that depend on a local key, like restic, don’t mix shells. Going back and forth between PowerShell and a different shell within the same procedure makes it easy to create an accident where an empty passphrase ends up in the repository, due to environment mismatches. I fixed the procedure to standardize on PowerShell.
When running it manually just once, the actual invocation looks like this (some key values and part of the address are masked).
# Read the key from a file outside git management (don't leave plaintext in the repository)
$env:RESTIC_PASSWORD = (Get-Content "<鍵ファイル>")[0]
$env:RESTIC_REPOSITORY = "s3:s3.ap-northeast-1.amazonaws.com/mint-chroma-backup-<アカウントID>"
restic backup "<data ディレクトリ>" --tag chroma-s3
restic snapshots # Check the list of snapshots taken
Don’t stop at “I put it there” — done only once it can be restored
I don’t define backup completion as “it was sent.” I call it done only once I’ve actually restored it into a separate empty directory and confirmed the file count and byte count match the original. The backbone of this definition is the lesson from having broken things twice in the past after merely deciding to “take a backup” without ever actually creating one.
In this restore test, what came back from S3 was 235 files, 16,154,894 bytes, matching the original with not a single byte’s difference. The number itself isn’t what matters. What matters is the fact that “it comes back without losing even one byte” — that’s the only evidence that “this backup is real, and I can genuinely get my memory back when it counts.”
Here is the actual restore-verification output (bucket name and repo ID redacted). This line confirms not that it was sent, but that it came back.
$ restic snapshots
ID Time Tags Paths
--------------------------------------------------
xxxxxxxx 2026-07-28 04:01:58 chroma-s3 .../data
$ restic restore latest --target <empty-dir>
restored 235 files / 16,154,894 bytes
Here is the definition of done, laid out:
- Initialize the repository (
restic init) - Confirm the key works (can the encryption settings be decrypted and read)
- Send the backup
- Actually restore it to a different location and reconcile the file count and byte count
- Set up a budget alert (next section)
Not a declaration — only the measured log from item 4 counts as evidence of completion.
Nearly free to keep — but kill the fixed-cost trap first
The cost, which is what everyone worries about, comes to under $0.01/month — effectively zero. restic’s incremental backups and deduplication (a mechanism that never stores the same content twice) do their job, so the encrypted data that lands on S3 fits into just a few MB after compression. Object storage is pay-as-you-go based on “GB stored plus number of accesses,” with no fixed cost like an always-on server.
But it’s exactly this “fixed cost” that has burned me before on AWS. If you carelessly leave an always-on element running, the charges keep accruing even when you’re not using it. The specifics are in separate articles.
Discovering ECS Fargate Fixed Costs (ALB + NAT Gateway) and Rethinking the Architecture
Deploying to the Wrong Region with AWS CDK + PowerShell + SSO, and How We Cleaned It Up
So this time, even knowing it would be cheap, I attached a safety valve first. I’ve set a budget alert so that reaching 80% of $1/month (≈$0.80) triggers a warning email. Given an expected cost of under $0.01, if that alert ever fires, something is off by an order of magnitude. It’s not about the dollar amount itself — it’s a mechanism to catch, before hitting any hard limit, a situation where “a bill that’s off by an order of magnitude from what I expected quietly grows.”
Memory, decoupled from the lifespan of a single machine
With this, my externalized memory is no longer tied to the life or death of a single local PC. If I break it through an operational mistake, I can restore from the local copy; if I lose the PC itself entirely, the encrypted blob remains remotely. And that blob can’t be read by anyone without the key. I didn’t have to give up on either “never upload plaintext” or “survive a disaster.”
What I’ve put in place so far is the “doesn’t break” side of preparedness. Memory also needs a “doesn’t get contaminated” side — even if it was captured correctly, if a wrong lesson gets mixed into its contents, my companion recalls it every single time and makes flawed judgments. How I automatically retract that contamination is written in the companion article that follows.
And the weekly checkup that keeps all of this healthy is in this piece.