One Source of Truth for Secrets: Self-Hosting Infisical on a Raspberry Pi
My journey from scattered .env files, through dotenvx, to a single self-hosted Infisical instance on a Raspberry Pi. Why it beats the cloud vaults, and how one copy of each key feeds both local dev and Vercel.
I have a handful of hobby apps. None of them are unicorns, but together they
share a surprising amount of plumbing: the same Gemini key, the same Groq key, a
GitHub token, an Expo token, an Apple App Store Connect key. For a long time
every one of those values lived in a .env.local file, copied between repos,
re-pasted whenever I moved machines, and quietly drifting out of sync.
This is the story of how I got rid of that. It has three stops: plain .env
files, then dotenvx, and finally a self-hosted
Infisical instance running on a Raspberry Pi. Each stop
fixed a real problem and exposed the next one.
Stop zero: the problem with plain .env files
A .env file is fine for one app on one machine. It stops being fine the moment
you have:
- Shared keys. My
GOOGLE_GENERATIVE_AI_API_KEYis the same in five repos. Five copies means five things to rotate and five things to leak. - Two environments per app. The dev key and the prod key need to live
somewhere that isn't a file you might
git addby accident. - More than one machine. PC, laptop, a VM. Every new checkout turns into a
scavenger hunt for "which
.envhad the right values".
The single scariest property of a plaintext .env is that one careless commit
puts your keys in git history forever. So my first move was to fix exactly that.
Stop one: dotenvx (encrypted .env, committed to git)
dotenvx, from the original author of dotenv, has a
genuinely clever idea: encrypt your .env so you can safely commit it.
It works with public-key cryptography (the same elliptic-curve scheme,
secp256k1, that Bitcoin uses, with ECIES and AES-256 under the hood). When you
encrypt, dotenvx puts a DOTENV_PUBLIC_KEY at the top of the .env file and
replaces every value with ciphertext. The matching private key goes into a
separate .env.keys file that you do not commit:
# .env (safe to commit; values are encrypted)
DOTENV_PUBLIC_KEY="03a1...b9"
GOOGLE_GENERATIVE_AI_API_KEY="encrypted:BFa2...=="
POSTGRES_URL="encrypted:Qk9p...=="# .env.keys (gitignored: the ONLY thing that decrypts the above)
DOTENV_PRIVATE_KEY="ef21...7c"
DOTENV_PRIVATE_KEY_PRODUCTION="9b04...1a"At runtime you wrap your command with dotenvx run -- <cmd> and it decrypts in
memory. Anyone with the public key can add a secret; only someone with the
private key can read one. Per-environment files get their own keys
(DOTENV_PRIVATE_KEY, DOTENV_PRIVATE_KEY_PRODUCTION, DOTENV_PRIVATE_KEY_CI,
and so on), and the suffix tells dotenvx which file to load.
This is great, and it genuinely solved the "plaintext in git" fear. The encrypted
.env lives right next to the code, and losing the repo no longer means leaking
the keys. For a single app, I'd still recommend it.
So why did I keep going? Because dotenvx moves the problem rather than removing it. The issues that pushed me off it:
- You still have to distribute a secret to every environment, it's just a
different secret. Instead of pasting the API key into each deploy target, you
now paste the
DOTENV_PRIVATE_KEYinto each one (Vercel, CI, every machine). The number of places you manage a secret didn't drop, the secret just changed shape. - Rotation is a multi-step ritual. Changing one value means re-encrypting and
re-committing the
.env. Rotating the keypair means re-encrypting everything and pushing the new private key out to every environment that had the old one. - Per-environment keys multiply. Dev, production, and CI each get their own private key to generate, store out of git, and keep straight. Lose or mix them up and a deploy quietly fails to decrypt.
- It doesn't fix the shared-key duplication. This was the big one for me. Each
repo has its own encrypted
.envand its own keypair, so my shared Gemini key still lives encrypted in five different files. Rotating it means re-encrypting and re-committing in all five repos. The duplication I started with didn't go away, it just became encrypted duplication. - No central anything. It's still files: no audit log of who read what, no rotation automation, no per-identity access scoping, no UI to see what exists where.
dotenvx nails "don't leak plaintext". It does not give you "one source of truth, synced everywhere, with rotation and access control". That gap is what sent me looking for an actual secrets manager.
Stop two: a real secrets manager
What I actually wanted was one place where each secret exists once, that both my local machine and my deploy platform read from, that I fully control, and that costs me nothing per secret. That's a secrets manager, and the question became which one.
Infisical is an open-source (MIT-licensed) secrets platform that does the obvious things (store secrets, organize them into projects and environments) and a lot of non-obvious ones: secret versioning and point-in-time recovery, automatic rotation, dynamic secrets, a Kubernetes operator, native syncs to platforms like Vercel, and built-in PKI/SSH management.
The part that matters for me: it can be fully self-hosted for free. The whole thing runs on a boring, well-understood stack (PostgreSQL for storage and Redis for cache), packaged as Docker images. That's exactly the kind of thing a Raspberry Pi is good at.
Why not Azure Key Vault, AWS, or HashiCorp Vault?
I priced out the usual suspects. For a solo developer with a pile of side projects, the economics and ergonomics are lopsided.
| Tool | Pricing model | What it costs me | Self-host |
|---|---|---|---|
| AWS Secrets Manager | ~$0.40 per secret / month + $0.05 / 10k API calls | Per-secret billing punishes "lots of small keys across lots of apps" | No |
| Azure Key Vault | ~$0.03 / 10k operations (Standard); Premium HSM ~$1 / key / month | Cheap, but Azure-shaped; best when you're already all-in on Azure | No |
| HashiCorp Vault | HCP from ~$22/mo (dev) up to ~$1,000/mo (prod), plus per-client fees | Powerful, operationally heavy, and per-client pricing balloons fast | OSS yes, but a chore to run |
| Infisical | Cloud ~$22 / user / month, or $0 self-hosted | One Pi, unlimited secrets, unlimited projects, no per-secret tax | Yes, first-class |
The takeaways:
- AWS bills per secret. That's the exact wrong shape for my usage. I have many small keys spread across many tiny apps, so the bill scales with the thing I have most of.
- Azure Key Vault is genuinely cheap (no per-secret fee, just per-operation), and if I lived in Azure I'd think hard about it. But its mental model is Azure's, the DX is enterprise-IT-flavored, and it's another cloud account to babysit for projects that don't otherwise touch Azure.
- HashiCorp Vault is the most capable of the bunch and the gold standard at scale, but it's also the heaviest to operate, and HCP's per-client pricing is built for companies, not hobby projects.
- Infisical self-hosted is free, unlimited, and mine. No per-secret line item, no per-seat fee, no vendor holding my keys. The trade is that I run it, which on a Pi is a one-time afternoon rather than an ongoing cost.
For a learning and side-project portfolio, "free, unlimited, self-owned, great DX" wins easily. The cloud vaults make sense when someone else is paying the AWS bill and compliance demands a managed HSM.
Why self-host (beyond the price)
The $0 is nice, but it isn't the main reason.
- I own the data. My API keys never leave hardware I physically control. There's no third party that can be breached, subpoenaed, or have an outage that locks me out of my own secrets.
- No artificial limits. Unlimited secrets, projects, and environments. I'm not rationing keys to stay under a free-tier cap.
- It's a learning project in itself. Running Postgres, Redis, a reverse proxy, and zero-trust access on a Pi is a genuinely useful thing to know how to do.
- It composes with the rest of my homelab. The same Pi, the same Cloudflare account, the same backup routine.
My setup: a Pi behind Cloudflare Access
The instance runs on a Raspberry Pi via Docker (the Infisical app container plus Postgres and Redis). The interesting part is how it's exposed to the internet, because a secrets manager is the last thing you want sitting on an open port.
Instead of forwarding a port on my router, I put it behind a Cloudflare Tunnel and gate it with Cloudflare Access. So there are two independent layers of auth before anything reaches a secret:
- Cloudflare Access, the zero-trust layer in front of the whole instance. Nothing reaches Infisical at all without passing the Access policy. Humans log in through Cloudflare; automation uses a service token.
- Infisical's own auth, a machine identity (Universal Auth) scoped read-only to a single environment. Even past Cloudflare, a leaked CI credential can only read the keys it was granted.
The only secret material that lives on my laptop is the bootstrap credential
to reach Infisical: the Cloudflare Access service token and the machine-identity
client id/secret, in a single gitignored .infisical.env. Every actual app
secret lives in Infisical and is fetched at runtime. (Notice the symmetry with
dotenvx: there's still one local secret, but now it unlocks a central store I can
audit, version, and rotate, instead of a pile of committed ciphertext.)
Why a tunnel beats a forwarded port
The detail worth dwelling on, because it is the part I would have gotten wrong a
year ago, is that the Pi has no inbound ports open at all. My router's
firewall stays shut. There is no :8080 forwarded to the Pi, no dynamic-DNS
record pointing at my home IP, nothing for an internet-wide port scan to find.
That works because a Cloudflare Tunnel is outbound-only. A small daemon,
cloudflared, runs next to Infisical on the Pi and dials out to Cloudflare's
network, holding open a connection that the Pi itself initiated. Requests for the
Infisical hostname hit Cloudflare first, and Cloudflare passes them back down that
already-open tunnel. The origin is never addressed directly, so my home IP stays
private and there is no listening port to attack.
In front of that tunnel sits Cloudflare Access, a zero-trust reverse proxy. Every request has to satisfy an Access policy before Cloudflare will forward it down the tunnel:
- Humans authenticate through an identity provider that Access trusts (an SSO login, or a one-time PIN mailed to an allow-listed address), and Access issues a short-lived session.
- Automation (my CLI and CI) presents a service token: a client id and
secret, sent as the
CF-Access-Client-IdandCF-Access-Client-Secretheaders, that Access verifies and swaps for a token of its own.
Only after that check does a request reach cloudflared, and only then does it
reach Infisical and meet the machine identity check from layer two. A request
has to clear Cloudflare's policy and Infisical's own auth, two independent
systems, before it can read a single key. The Pi could be a laptop on hotel wifi
and the security model would not change, because nothing depends on where the box
sits or what its IP is.
One copy of each key: shared vs. per-project
This is the structure that finally killed my duplication problem, the one dotenvx couldn't. I split secrets into two kinds:
- Per-project secrets live in that project's own Infisical project: its database URL, its auth secret, its OAuth credentials.
- Shared keys live once in a dedicated
Global-Secretsproject: the Gemini key, the Groq key, the Anthropic and OpenAI keys, the GitHub and Expo tokens, the Apple App Store Connect key. Things every app uses.
Rotate the Gemini key once in Global-Secrets, and every project picks up the new
value on its next run. No fan-out, no re-encrypting five repos, no "did I update
all of them".
A nuance worth knowing: cross-project secret imports are a paid feature, so on a free self-hosted tier you can't have a project natively pull from
Global-Secrets. The fix is to do the merge at the two points where secrets are actually consumed, local dev and the deploy platform, instead of inside Infisical. That's exactly what the wrapper below does.
Development: the with-env wrapper
Locally, I never read a .env. Every package.json script that needs secrets
runs through a tiny wrapper that shells out to infisical run. The clever bit is
that it nests two runs, the shared project on the outside and the project's
own secrets on the inside, so a single command gets both sets, with the project's
values winning on any collision:
// tooling/with-env/with-env.mjs (the core decision: unit-tested, no I/O)
export function resolveExecution({ env, args }) {
const [command, ...commandArgs] = args;
// On Vercel the platform already injects env (and the CLI isn't installed),
// so just run the command untouched. This guard keeps `next build` working
// in CI even though the same script runs through Infisical locally.
if (env.VERCEL) return { mode: "passthrough", command, commandArgs };
const projectRun = buildRunArgs(env, env.INFISICAL_PROJECT_ID, "dev");
const sharedId = env.INFISICAL_SHARED_PROJECT_ID;
// Nest: infisical run <Global-Secrets> -- infisical run <project> -- <cmd>
// Shared is outermost, so the project run executes LAST and wins on collisions.
if (sharedId && sharedId !== env.INFISICAL_PROJECT_ID) {
const sharedRun = buildRunArgs(env, sharedId, "prod");
return {
mode: "infisical",
command: "infisical",
commandArgs: [...sharedRun, "--", "infisical", ...projectRun, "--", command, ...commandArgs],
};
}
return { mode: "infisical", command: "infisical", commandArgs: [...projectRun, "--", command, ...commandArgs] };
}So pnpm dev becomes, under the hood:
infisical run --env=prod --projectId=<Global-Secrets> -- \
infisical run --env=dev --projectId=<this-app> -- \
next devTwo things I baked in from experience:
- Fail loudly. If the bootstrap credentials are missing, the wrapper throws a clear error instead of silently running with no secrets. A missing key should be a loud crash, not a mysterious 500 later.
- Short-lived tokens. The machine identity's client id/secret is exchanged for a fresh, short-lived access token on each run. Nothing long-lived is written to disk.
The .env file isn't gone entirely. It survives as documented break-glass: if
the Pi is ever unreachable, dotenv-cli -e .env -- <cmd> still works. But the
.env.example committed to the repo is now just a contract, a list of which
keys must exist in Infisical, with placeholder values, not a place real secrets
live.
Production: syncing to Vercel
Update: this portfolio has since moved off Vercel to Cloudflare Workers, and I wrote up why in From Vercel to Cloudflare. The Vercel specifics in this section describe that earlier setup.
The apps deploy on Vercel, and here Infisical does the opposite of what it does
locally. On Vercel I don't run the CLI (that's the VERCEL passthrough above).
Instead Infisical pushes secrets into the Vercel project ahead of the build,
using a native Secret Sync.
The same two-tier split applies:
- Each project gets a
Global-Secrets/prodto<project>/productionsync, so the shared keys land in Vercel's environment automatically. - The project's own secrets sync into the same Vercel project.
A couple of hard-won notes:
- Secret Syncs default to "deletion disabled." Removing a secret from the Infisical source does not delete it from Vercel. Usually what you want, but worth knowing so you're not surprised by orphaned values.
- Let the database integration own DB vars. My Postgres lives on Neon, and the
Vercel-Neon integration writes
POSTGRES_URLdirectly into Vercel. I do not mirror that into Infisical, because a static copy goes stale the moment Neon rotates the pooler password. Infisical owns the hand-managed app secrets; the integration owns the database connection string. Each thing has exactly one owner.
So the full picture for any given key:
Local dev → with-env nests two `infisical run`s → process.env
Production → Infisical Secret Sync → Vercel env → process.env
Same secret, one source of truth, two delivery mechanisms, and my code just reads
process.env.GOOGLE_GENERATIVE_AI_API_KEY in both places, none the wiser.
Gotchas I hit (so you don't)
A few things cost me time:
- Infisical's UI stages changes. Adding a secret only stages it; you have to click Save Changes or it's discarded on navigation. A staged-but-unsaved secret reads as "0 secrets" from the CLI, which sends you chasing a ghost.
- Shared keys must live in the environment the shared run reads. My wrapper
reads
Global-Secretsat theprodenvironment. A key I once added to the shared project'sdevelopmentenvironment was simply invisible: the command ran, the variable just wasn't there. Keep shared keys inprod. - App Connections are per-project on Vercel. Each Vercel project needs its own connection before you can create its Global-Secrets sync. You can't reuse one project's connection for another.
Takeaway
My path went .env (leaks in git), then dotenvx (encrypted, committable, but the
key distribution and shared-key duplication never went away), then self-hosted
Infisical (one source of truth, synced everywhere). For a portfolio of small apps,
a Pi behind Cloudflare Access hits a sweet spot the cloud vaults can't: free and
unlimited, fully under my control, with one copy of every shared key
feeding both local dev and production.
The cloud vaults are the right call when someone else pays the bill and compliance
wants a managed HSM. dotenvx is the right call when you want zero infrastructure
and one app's secrets safe in git. But for me, juggling many apps with overlapping
keys, the winner was a Raspberry Pi nesting two infisical runs locally and
syncing to Vercel in prod.
The best part: the next time I rotate a key, I do it once.
Tools mentioned, for the curious: dotenvx (encrypted
.env), Infisical (MIT, self-hostable),
AWS Secrets Manager,
Azure Key Vault, and
HashiCorp Vault.
Comments
Loading comments…