One Source of Truth for Secrets: Self-Hosting Infisical on a Raspberry Pi
Why I moved every API key out of scattered .env files into a single self-hosted Infisical instance running on a Raspberry Pi — how 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.
That setup has three problems. Secrets end up in more places than you can track,
rotating a shared key means editing N files, and the moment one .env lands in a
commit you have a bad day. So I did the thing I'd been putting off: I stood up a
single secrets manager that every project reads from, in both development and
production. I picked Infisical, and I self-host it on a Raspberry Pi.
This post is why I chose it over the cloud vaults, and exactly how the setup works.
The problem with .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 is a
scavenger hunt for "which
.envhad the right values".
What I 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.
What Infisical is
Infisical is an open-source (MIT-licensed) secrets platform — think "the API-key manager you'd build if you cared about DX". It 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 the 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. 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, not an ongoing cost.
For a learning/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 + 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.
┌──────────┐ CF Access ┌───────────────┐ Infisical auth ┌─────────┐
│ client │ ─────────────▶ │ Cloudflare │ ─────────────────▶ │ Pi: │
│ (CLI/CI) │ service token │ Tunnel/Access │ machine identity │ Infisical│
└──────────┘ └───────────────┘ └─────────┘
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.
One copy of each key: shared vs. per-project
This is the structure that finally killed my duplication problem. 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/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 "did I update all five repos".
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, 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
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/prod → <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 — 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
For a portfolio of small apps, a self-hosted Infisical on a Raspberry Pi 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 — but for me, a Pi behind Cloudflare Access,
nesting two infisical runs locally and syncing to Vercel in prod, replaced a
sprawl of .env files with a single source of truth.
The best part: the next time I rotate a key, I do it once.
Tools mentioned, for the curious: Infisical (MIT, self-hostable), AWS Secrets Manager, Azure Key Vault, and HashiCorp Vault.
Comments
Loading comments…