Back to work
In Development2026Developer & Architect60% complete

Secrets Backup

A self-hosted, tamper-evident secrets vault built on a single S3 bucket — replaces Vault/Infisical/Doppler for individual developers at zero infra cost.

Summary

Secrets Backup is a Next.js 16 dashboard plus a Bash CLI that turn a single AWS S3 bucket into a Git-style, environment-scoped, encrypted vault for `.env` files, SSH keys, AWS credentials, and other on-disk secrets. The server is authoritative — every save writes an immutable, content-addressed, hash-chained version under `_index/<project>/<env>/<file>/`, the manifest is the source of truth (no client hash cache to drift), and a tamper-evident `chain_sha256` chains every version to its predecessor so a single re-ordered or rewritten history entry is detectable. AES-256-GCM encryption is applied via the Web Crypto API with a `$SB_ENC_V1$` magic prefix, so a single bucket can transparently hold both legacy plaintext and encrypted blobs during migration. Auth is HMAC-signed session cookies plus a Firebase Google sign-in gated by a server-side email allowlist. Deployed on ECS Fargate behind nginx with a Cloudflare-only IP allowlist.

Target user

Individual developers and small teams who outgrew committing `.env` files but can't justify the per-seat pricing or operational overhead of Vault, Infisical, or Doppler — and don't want their secrets sitting in a vendor's database.

§ 01Stack
01Primary
Next.jsReactTypeScriptTailwind CSS
02Infrastructure
AWS S3AWS ECS FargateAWS ECRDockerGitHub Actions CI/CDnginx + Cloudflare IP allowlistfile.shinobidata.com
03Integrations
@aws-sdk/client-s3Firebase Authfirebase-adminWeb Crypto API
04UI / Frontend
shadcn/uilucide-reactreact-iconsnext-themestw-animate-css
§ 02Key features
  1. 01

    Built a server-authoritative version store with content-addressed blobs (`blobs/<sha256>.bin`), zero-padded immutable version records (`versions/v0001.json`), and a single mutable manifest (`manifest.json`) as the atomic source of truth — clients never maintain a local hash cache, so state cannot drift.

  2. 02

    Implemented a tamper-evident hash chain (`chain_sha256 = sha256(prev_chain || this_sha256)`) over every version, plus a `verifyChain()` walker that recomputes every hash and detects re-ordered, missing, or rewritten history entries with a precise `brokenAt` pointer — the same property Git provides, applied to secrets.

  3. 03

    Wrote AES-256-GCM encryption (Web Crypto, 12-byte random IV, base64-wrapped, `$SB_ENC_V1$` magic prefix) that is fully backward-compatible — the dashboard reads pre-encryption plaintext objects untouched and only upgrades on rewrite, so a live bucket can be migrated incrementally with zero downtime.

  4. 04

    Built environment-scoped storage (default / development / staging / production) keyed by an `_env/<env>/` S3 prefix, with a `detectEnvironments()` reducer that surfaces only the environments actually present per project — no schema, no migration when a new env appears.

  5. 05

    Shipped an append-only audit log written to `_audit/<YYYY-MM-DD>/<HH-MM-SS>_<action>_<rand>.json` in the same bucket — every secret read, write, delete, login, and logout captures user, IP (extracted from `x-forwarded-for` / `x-real-ip`), and target key, with a dedicated dashboard page that filters by action chip and full-text searches across user / IP / key / details.

  6. 06

    Wrote a Bash backup CLI (~790 LOC across `sync.sh`, `sync-secrets.sh`, `sync-git-meta.sh`, `restore.sh`, `list.sh`, `rebuild-cache.sh`) that scans `~/Code`, `~/Projects`, `~/Developer`, `~/Desktop` for any `.env*` file, computes SHA-256, diffs against a local cache, and uploads only changed files — plus a hardcoded sweep of SSH keys, AWS creds, GitHub CLI tokens, Docker/K8s configs, and shell dotfiles to `_system/<category>/`.

  7. 07

    Built a GitHub-quality diff view from scratch: O(m·n) LCS table + backtrack to produce `added`/`removed`/`unchanged` line records with dual line numbers, plus an inline-change renderer that detects `KEY=value` shape, splits at the `=`, and highlights only the value when the key matches (so a rotated secret highlights one token, not the whole line).

  8. 08

    Wrote a custom Next.js proxy (formerly middleware) that runs `verifySessionToken` in the Edge runtime against the same HMAC scheme used by API routes in the Node runtime — the single `auth/session.ts` module works in both runtimes by using Web Crypto rather than `node:crypto`.

§ 03Hardest problems
  1. The chain rule `chain[n] = sha256(chain[n-1] || sha256(content[n]))` means every version's hash is a function of every prior version — a single rewritten history entry breaks the chain at that point and is caught by `verifyChain()`. The manifest's `current_chain_sha256` is a compact integrity check for the entire history, so a watcher can verify the whole vault by reading one tiny JSON file.

  2. The `$SB_ENC_V1$` magic prefix means `maybeDecrypt()` can transparently handle a bucket with mixed encrypted and plaintext objects — flipping `ENCRYPTION_KEY` from unset to set causes future writes to encrypt and existing plaintext reads to pass through untouched. No backfill job, no schema column, no two-phase deploy. The same pattern allows future key rotation by adding a `$SB_ENC_V2$` prefix and a per-blob key id without touching anything tagged V1.

§ 04What I learned
  • L01
    Server-authoritative beats client-cached for state that has a correctness invariant.

    The earlier Bash-script-only version maintained a per-machine hash cache and would happily over-write a newer S3 version if the cache disagreed; moving the manifest to the server made the entire class of drift bugs unrepresentable. Worth the extra round-trip on every save.

  • L02
    Append-only data shapes give you optionality.

    The audit log defines `secret.delete` as an action type even though there's no delete UI yet — when delete ships, no schema change, no migration. Same for the version store: nothing in the on-disk layout would change if delete-with-tombstone shipped tomorrow.

  • L03
    Magic-prefix sniffing is a better encryption boundary than a metadata flag.

    Putting `$SB_ENC_V1$` in the first 11 bytes of each blob means the storage layer is dumb (bytes in, bytes out) and the encryption decision lives entirely in `maybeDecrypt()`. A `is_encrypted` column on a manifest would have required a coordinated migration and a half-state where some objects are flagged correctly and some aren't.

§ 05By the numbers
ts source loc
~7400
bash source loc
~790
commits
31
development span days
43
api routes shipped
14
hardcoded login credentials
1