TL;DR: We replaced .env files with a self-hosted Infisical instance behind a VPN. Developers run infisical run --env=local -- pnpm dev and secrets are injected at runtime. No .env files to manage, share, or accidentally commit.
The Problem
Every team eventually hits the same pain points with .env files:
- New developer joins, spends an hour asking teammates for env vars
- Someone updates a secret, nobody else knows until things break
- .env files get committed by accident despite .gitignore
- Different developers have different values. "Works on my machine"
- Secrets scattered across Slack DMs, Notion pages, and sticky notes
We wanted a single source of truth for development secrets that:
- Doesn't require a SaaS subscription
- Works offline (within our network)
- Doesn't change how developers run code
- Has a fallback for when it's unavailable
Why Self-Hosted Infisical
We evaluated several options:
| Option | Pros | Cons |
|---|---|---|
| Infisical Cloud | Zero setup | Monthly cost, secrets leave your network |
| HashiCorp Vault | Battle-tested, enterprise features | Complex setup, overkill for dev secrets |
| Doppler | Great DX, CLI is solid | SaaS-only, per-seat pricing |
| AWS Secrets Manager | Already using AWS | Awkward for local dev, IAM overhead |
| Self-hosted Infisical | Free, simple, stays in-network | You maintain it |
Infisical's Community Edition is free, runs as a single Docker container, has a clean dashboard, and ships with a CLI that wraps any command with injected env vars. That last part is what sold us. Zero code changes required.
Architecture
flowchart TB
subgraph dev["Developer Machine"]
cmd["infisical run --env=local\n--path=/api -- pnpm dev"]
cli["Infisical CLI"]
inject["env vars injected"]
app["pnpm dev"]
cmd --> cli
cli --> inject
inject --> app
end
subgraph server["Devtools Server (VPN-only)"]
infisical["Infisical (Docker)\n:8080"]
end
cli -- "Tailscale VPN" --> infisical
Infisical runs on a utility server that's only reachable via our Tailscale VPN mesh. No public endpoint, no exposed ports. If you're not on the VPN, you can't reach it.
Setting Up the Server
1. Deploy Infisical
On your devtools/utility instance:
# Create a directory for Infisical
mkdir -p /opt/infisical && cd /opt/infisical
# Download the docker-compose file
curl -o docker-compose.yml https://raw.githubusercontent.com/Infisical/infisical/main/docker-compose.prod.yml
# Generate encryption keys
openssl rand -hex 16 # ENCRYPTION_KEY
openssl rand -hex 32 # AUTH_SECRET
# Create .env with your keys
cat > .env <<EOF
ENCRYPTION_KEY=<generated-hex-16>
AUTH_SECRET=<generated-hex-32>
SITE_URL=http://<vpn-ip>:8080
EOF
# Start
docker compose up -d
2. Initial Configuration
- Open
http://<vpn-ip>:8080(via VPN) - Create your organization
- Create your first project (one per repository)
- Set up environments:
local,dev,staging,prod - Create folders per service (for monorepos)
3. Lock Down Access
- Prod environment: read-only for most developers, write access for leads only
- No public endpoint: VPN is the only way in
- Auth keys: create per-integration, rotate quarterly
Organizing Secrets
One Project Per Repo
Don't mix repositories in a single project. Access control, audit logs, and environment separation all work at the project level.
graph TD
org["Organization"]
org --> backend["Project: backend"]
org --> frontend["Project: frontend"]
org --> mobile["Project: mobile"]
Folders for Monorepos
If your repo has multiple services, use folders. No inheritance. Duplicate shared keys across folders. This sounds redundant, but it eliminates surprises. Each folder is a self-contained set of everything that service needs.
/api
/worker
/admin
/infra # Internal IPs, endpoints
Four Environments
| Environment | Purpose |
|---|---|
local |
Points to localhost (Docker Postgres, Redis) |
dev |
Points to dev infrastructure |
staging |
Points to staging infrastructure |
prod |
Production values (restricted access) |
Integrating With Your Repository
The Config File
Add .infisical.json to your repo root (this is safe to commit, no secrets):
{
"workspaceId": "<project-id-from-dashboard>",
"defaultEnvironment": "local",
"gitBranchToEnvironmentMapping": null
}
Setting defaultEnvironment to local means running infisical run without --env defaults to local. This prevents anyone from accidentally pulling prod secrets.
Package Scripts
infisical run does the heavy lifting. It fetches secrets, injects them as env vars, and runs your command. No SDK, no code changes, no dotenv import.
Single-service repo:
{
"scripts": {
"dev": "infisical run --env=local -- next dev",
"dev:staging": "infisical run --env=staging -- next dev",
"dev:local": "next dev"
}
}
Monorepo:
{
"scripts": {
"dev:api": "infisical run --env=local --path=/api -- nest start --watch",
"dev:api:dev": "infisical run --env=dev --path=/api -- nest start --watch",
"dev:api:local": "nest start --watch"
}
}
The :local variant is the escape hatch. It runs without Infisical using whatever .env files are present. Always provide this.
Env Example Files
Keep .env.*.example files committed. They document what each service needs without containing values:
# .env.api.example
DATABASE_URL=
REDIS_URL=
JWT_SECRET=
These serve as the reference when onboarding, and as the fallback template when Infisical isn't available.
Pre-commit Hook
Block .env files from ever being committed:
#!/bin/sh
if git diff --cached --name-only | grep -E '(^|/)\.env($|\.)' | grep -qv '\.example$'; then
echo ".env files cannot be committed. (.env.*.example files are allowed)"
exit 1
fi
Developer Workflow
Onboarding (One-Time)
# Install the CLI
brew install infisical/get-cli/infisical
# Connect to VPN
# (follow your team's VPN setup guide)
# Login to Infisical
infisical login --domain=http://<vpn-ip>:8080
# Clone and run
git clone <repo>
pnpm install
pnpm dev:api
No .env files to hunt down. No "ask Rahul for the JWT secret".
Day-to-Day
# Start with local env (default)
pnpm dev:api
# Point to dev infrastructure
pnpm dev:api:dev
# Check what secrets are set
infisical secrets --env=local --path=/api
Adding a New Secret
- Add to Infisical dashboard, all 4 environments and the relevant folder
- Add to
.env.*.examplewith an empty value - Reference in application config
- Commit the
.examplefile change
When Infisical Is Unavailable
VPN down? Infisical server restarting? Use the fallback:
# One-time: export from a teammate who has access
infisical export --env=local --path=/api --format=dotenv > .env.api.local
# Run without Infisical
pnpm dev:api:local
The .env.api.local file is gitignored. It drifts over time, so treat it as a temporary escape hatch, not a primary workflow.
What About CI/CD and Production?
Infisical is for local development only. We don't use it in CI/CD or production. Deployed environments use cloud-native secret managers:
- AWS Secrets Manager for database credentials
- SSM Parameter Store for non-secret config
- EC2 instance roles / IAM for service-level access
This keeps the blast radius small. If the Infisical instance goes down, production is unaffected. Developers just fall back to .env files until it's back.
Gotchas
Session expiry. Infisical CLI sessions expire. When you see invalid signature errors, re-login:
infisical login --domain=http://<vpn-ip>:8080
Export creates .env files. The export command is useful for debugging, but it reintroduces the very files you're trying to eliminate. Add a comment in your docs:
# Debugging only, don't commit or leave .env files around
infisical export --env=local --format=dotenv > .env.local
No inheritance. If /api and /worker both need DATABASE_URL, add it to both folders. Inheritance seems convenient but creates hidden dependencies and makes it hard to reason about what a service actually needs.
VPN is a hard dependency. If your team works across unreliable networks, consider running Infisical on a cloud instance with IP whitelisting instead of VPN-only access.
Was It Worth It?
For a team of 5-15 developers working on a monorepo with multiple services and environments, yes. The ROI showed up in:
- Onboarding: went from "ask 3 people for env vars" to
infisical login && pnpm dev - Secret rotation: update once in the dashboard, everyone gets it on next run
- No more .env drift: the source of truth is the dashboard, not someone's laptop
- Zero code changes:
infisical runwraps any command, no SDK imports needed
The setup took about 2 hours (Docker deploy + VPN route + CLI setup). Ongoing maintenance is near-zero.
Secrets management for local dev doesn't need to be enterprise-grade. It needs to be convenient enough that developers actually use it instead of falling back to Slack DMs and .env files.