Build Your Own Secrets Manager: Private Git, Zero-Dependency CLI, and NPM Packaging
As a solo developer managing 100+ repositories, you face a unique secrets management dilemma.
Your repositories are public, so you cannot commit plaintext .env files. You
have shared secrets (like a Cloudflare API token or an OpenAI API key) that you
reuse across dozens of projects. You also have project-specific secrets (like a
database URL or an API webhook token) that are unique to single repositories.
If you rotate a shared key, you want to change it in one central place, and have it instantly update across every local project and every GitHub Actions CI/CD runner. You do not want to maintain 100 separate secret configurations, and you want a solution that is 100% free, runs locally with zero server overhead, and works seamlessly with AI coding agents like Cursor, Windsurf, and Claude Code.
Let us evaluate why the popular tools fall short, and then build, package, and publish a custom secrets manager that solves all of these constraints.
Why Existing Tools Fall Short for Solo Developers
Every major secrets management platform operates on specific trade-offs that do not align with a solo developer managing 100+ public repositories on a budget.
1. In-Repository Encryption (dotenvx, SOPS, git-crypt)
Tools like dotenvx, SOPS, and git-crypt encrypt your secrets and commit
them to Git. While this is highly secure and Git-native, it violates the DRY
(Don't Repeat Yourself) principle.
If you have 40 repositories using the same OPENAI_API_KEY, rotating that key
means:
- Navigating to all 40 repositories locally.
- Decrypting the
.envfile. - Updating the key.
- Re-encrypting the
.envfile. - Committing and pushing.
This is a maintenance nightmare. A key rotation should be a single change, not a 40-step manual process.
2. Managed SaaS Tiers (Doppler, Infisical Cloud)
SaaS secrets managers like Doppler and Infisical are polished and support secret inheritance/linking (defining a secret once and pulling it into multiple configs). However, they enforce strict limits on their free tiers:
- Doppler Free Tier: Limited to 10 projects. To scale to 100+ projects, you must upgrade to the Team tier, which costs $252/year.
- Infisical Cloud Free Tier: Limited to 3 projects. The Pro plan costs $216+/year.
For a solo developer, paying 250+ per year just to sync environment variables across side projects is highly inefficient.
3. Self-Hosted Servers (Infisical, Vault)
You can self-host Infisical or HashiCorp Vault on your own VPS. While this solves the project limits and is free (excluding VPS costs of ~$5/month), it introduces infrastructure debt:
- You must secure, update, and manage a VPS.
- You must configure database backups (PostgreSQL/Redis).
- If your server goes down, you lose the ability to pull secrets on a new machine.
The Solution: A Hybrid Central Git Secrets Manager
The ideal architecture combines the best of all worlds:
- Centralized Storage: A single private GitHub repository (
secrets) acting as the database of truth, holding a structuredsecrets.jsonfile. - Local Client CLI: A zero-dependency Node.js CLI script (
secenv) that pulls from the private repository, parses the local project's.env.example, resolves any shared references, and generates a local plaintext.envfile. - Automated CI/CD Syncing: The CLI uses the GitHub CLI (
gh) to push resolved secrets directly to GitHub repository secrets in a single command. - AI-Agent Friendly: Standard
.envfiles are generated locally on disk (and added to.gitignore), allowing Cursor, Windsurf, and Claude Code to work out of the box with zero integration friction.
graph TD
subgraph Central Storage (GitHub Private Repo)
S[secrets.json]
end
subgraph Local Development Machine
CLI[secenv CLI]
E[env.example]
V[.env]
GH[gh CLI]
end
subgraph Target Repositories (GitHub Public Repos)
GA1[Repo 1 Actions Secrets]
GA2[Repo 2 Actions Secrets]
end
S -->|git pull| CLI
E -->|defines schema| CLI
CLI -->|resolves & prompts| V
CLI -->|writes| V
CLI -->|syncs| GH
GH -->|gh secret set| GA1
GH -->|gh secret set| GA2
Creating the Central Secrets Repository
Step 1: Initialize the Private Repository
Create a new private repository on GitHub named secrets and clone it locally:
git clone [email protected]:YOUR_USERNAME/secrets.git ~/.secenv/secrets
Step 2: Define the Schema (secrets.json)
Inside your private secrets repository, create a secrets.json file:
{
"shared": {
"CLOUDFLARE_API_TOKEN": "cl_abcdef1234567890...",
"OPENAI_API_KEY": "sk-proj-...",
"RESEND_API_KEY": "re_..."
},
"projects": {
"blog.oriz.in": {
"CLOUDFLARE_API_TOKEN": "shared.CLOUDFLARE_API_TOKEN",
"SITE_URL": "https://blog.oriz.in",
"DATABASE_URL": "postgres://user:pass@host/db"
},
"my-cool-saas": {
"OPENAI_API_KEY": "shared.OPENAI_API_KEY",
"STRIPE_API_KEY": "sk_live_...",
"PORT": "3000"
}
}
}
Commit and push:
git add secrets.json
git commit -m "initial secrets database"
git push
Coding the Zero-Dependency CLI (secenv.cjs)
Here is the complete CommonJS Node.js script. It does not require any node_modules to run, making it extremely lightweight and portable.
#!/usr/bin/env node
const fs = require('fs');
const path = require('path');
const { execSync } = require('child_process');
const readline = require('readline');
// Directory paths
const HOME_DIR = process.env.USERPROFILE || process.env.HOME || process.env.HOMEPATH;
const CONFIG_DIR = path.join(HOME_DIR, '.secenv');
const SECRETS_DIR = path.join(CONFIG_DIR, 'secrets');
const SECRETS_FILE = path.join(SECRETS_DIR, 'secrets.json');
const rl = readline.createInterface({
input: process.stdin,
output: process.stdout
});
const question = (query) => new Promise((resolve) => rl.question(query, resolve));
function runCmd(cmd, cwd = process.cwd()) {
try {
return execSync(cmd, { cwd, stdio: ['pipe', 'pipe', 'ignore'] }).toString().trim();
} catch (err) {
return null;
}
}
function ensureDirectories() {
if (!fs.existsSync(CONFIG_DIR)) {
fs.mkdirSync(CONFIG_DIR, { recursive: true });
}
}
async function syncCentralRepo() {
ensureDirectories();
if (!fs.existsSync(SECRETS_DIR)) {
console.log('\x1b[36m%s\x1b[0m', 'No central secrets store found.');
const gitUrl = await question('Enter the Git URL of your private secrets repository: ');
if (!gitUrl) {
console.error('Git URL is required to set up secenv.');
process.exit(1);
}
console.log(`Cloning central secrets repository into ${SECRETS_DIR}...`);
try {
execSync(`git clone ${gitUrl} "${SECRETS_DIR}"`, { stdio: 'inherit' });
} catch (err) {
console.error('Failed to clone repository. Check your SSH keys/Git credentials.', err.message);
process.exit(1);
}
} else if (fs.existsSync(path.join(SECRETS_DIR, '.git'))) {
console.log('Syncing latest changes from central secrets repository...');
try {
execSync('git pull', { cwd: SECRETS_DIR, stdio: ['ignore', 'pipe', 'ignore'] });
} catch (err) {
console.warn('\x1b[33m%s\x1b[0m', 'Warning: Could not pull latest changes. Using cached secrets.');
}
}
}
function getRepoInfo() {
const gitUrl = runCmd('git config --get remote.origin.url');
const fallbackName = path.basename(process.cwd());
if (!gitUrl) {
return { name: fallbackName, fullName: fallbackName };
}
const match = gitUrl.match(/[:/]([^/]+)\/([^/]+?)(?:\.git)?$/);
if (match) {
return {
owner: match[1],
name: match[2],
fullName: `${match[1]}/${match[2]}`
};
}
return { name: fallbackName, fullName: fallbackName };
}
function parseEnvFile(filePath) {
if (!fs.existsSync(filePath)) {
return {};
}
const content = fs.readFileSync(filePath, 'utf-8');
const env = {};
content.split(/\r?\n/).forEach((line) => {
line = line.trim();
if (!line || line.startsWith('#')) return;
const match = line.match(/^([^=]+)=(.*)$/);
if (match) {
const key = match[1].trim();
const value = match[2].trim().replace(/^['"]|['"]$/g, '');
env[key] = value;
} else if (line && !line.includes('=')) {
env[line.trim()] = '';
}
});
return env;
}
async function main() {
const args = process.argv.slice(2);
const isGithubSync = args.includes('--github') || args.includes('-g');
await syncCentralRepo();
let secretsConfig = { shared: {}, projects: {} };
if (fs.existsSync(SECRETS_FILE)) {
try {
secretsConfig = JSON.parse(fs.readFileSync(SECRETS_FILE, 'utf-8'));
} catch (err) {
console.error('Error parsing secrets.json:', err.message);
process.exit(1);
}
}
const repo = getRepoInfo();
console.log(`\nIdentifying project: \x1b[32m${repo.name}\x1b[0m`);
if (!secretsConfig.projects) secretsConfig.projects = {};
if (!secretsConfig.shared) secretsConfig.shared = {};
if (!secretsConfig.projects[repo.name]) {
secretsConfig.projects[repo.name] = {};
}
const localExamplePath = path.join(process.cwd(), '.env.example');
if (!fs.existsSync(localExamplePath)) {
console.error('Error: .env.example not found in the current directory.');
rl.close();
process.exit(1);
}
const neededKeys = parseEnvFile(localExamplePath);
const resolvedEnv = {};
let modified = false;
console.log(`Resolving environment variables...`);
for (const key of Object.keys(neededKeys)) {
let rawValue = secretsConfig.projects[repo.name][key];
if (rawValue === undefined) {
console.log(`\x1b[33mMissing secret:\x1b[0m ${key}`);
const val = await question(`Enter value for ${key} (or 'shared.KEY_NAME' to link to a shared secret): `);
rawValue = val.trim();
secretsConfig.projects[repo.name][key] = rawValue;
modified = true;
}
if (typeof rawValue === 'string' && rawValue.startsWith('shared.')) {
const sharedKey = rawValue.substring(7);
let sharedValue = secretsConfig.shared[sharedKey];
if (sharedValue === undefined) {
console.log(`\x1b[33mShared secret not defined:\x1b[0m ${sharedKey}`);
const val = await question(`Enter value for shared secret ${sharedKey}: `);
sharedValue = val.trim();
secretsConfig.shared[sharedKey] = sharedValue;
modified = true;
}
resolvedEnv[key] = sharedValue;
} else {
resolvedEnv[key] = rawValue;
}
}
const localEnvPath = path.join(process.cwd(), '.env');
let envFileContent = `# Generated by secenv - DO NOT COMMIT\n`;
for (const [key, val] of Object.entries(resolvedEnv)) {
const safeVal = /[^\w.-]/.test(val) ? `"${val.replace(/"/g, '\\"')}"` : val;
envFileContent += `${key}=${safeVal}\n`;
}
fs.writeFileSync(localEnvPath, envFileContent, 'utf-8');
console.log('\x1b[32m%s\x1b[0m', 'Successfully generated .env file!');
if (modified) {
console.log('Updating secrets.json in central store...');
fs.writeFileSync(SECRETS_FILE, JSON.stringify(secretsConfig, null, 2), 'utf-8');
if (fs.existsSync(path.join(SECRETS_DIR, '.git'))) {
console.log('Pushing updates to private secrets repository...');
try {
execSync('git add secrets.json', { cwd: SECRETS_DIR });
execSync(`git commit -m "sync: update secrets for ${repo.name}"`, { cwd: SECRETS_DIR });
execSync('git push', { cwd: SECRETS_DIR });
console.log('\x1b[32m%s\x1b[0m', 'Pushed central secrets successfully.');
} catch (err) {
console.error('Failed to push secrets.json to remote repo:', err.message);
}
}
}
if (isGithubSync) {
console.log('\n\x1b[35m%s\x1b[0m', `Syncing secrets to GitHub repository: ${repo.fullName}`);
const ghCheck = runCmd('gh --version');
if (!ghCheck) {
console.error('Error: GitHub CLI (gh) is not installed or not in PATH.');
rl.close();
process.exit(1);
}
try {
for (const [key, val] of Object.entries(resolvedEnv)) {
console.log(`Setting GitHub secret: ${key}...`);
execSync(`gh secret set ${key} --body "${val.replace(/"/g, '\\"')}"`, { stdio: 'inherit' });
}
console.log('\x1b[32m%s\x1b[0m', 'Successfully synced all secrets to GitHub Actions!');
} catch (err) {
console.error('Failed to sync secrets to GitHub Actions.');
}
}
rl.close();
}
main().catch((err) => {
console.error('An unexpected error occurred:', err);
rl.close();
process.exit(1);
});
Packaging the CLI for NPM (npx secenv)
To make this script easily reusable across all your projects without copy-pasting
it into every directory, package it as an open-source NPM library. Anyone can
then run it via npx.
Step 1: Create a Dedicated CLI Repository
Create a new public repository on GitHub named secenv (or whatever name is
available on NPM, e.g., secenv-cli or @username/secenv).
Initialize a local directory:
mkdir secenv-cli && cd secenv-cli
npm init -y
Step 2: Configure package.json
Your package.json should declare the entry point and register the command
in the bin field:
{
"name": "secenv-cli",
"version": "1.0.0",
"description": "Zero-dependency CLI to sync environment variables from a central private Git repository",
"main": "bin/secenv.js",
"bin": {
"secenv": "./bin/secenv.js"
},
"scripts": {
"test": "echo \"Error: no test specified\" && exit 1"
},
"keywords": ["secrets", "env", "dotenv", "git", "cli", "secrets-manager"],
"author": "YOUR_NAME",
"license": "MIT",
"repository": {
"type": "git",
"url": "git+https://github.com/YOUR_USERNAME/secenv.git"
},
"bugs": {
"url": "https://github.com/YOUR_USERNAME/secenv/issues"
},
"homepage": "https://github.com/YOUR_USERNAME/secenv#readme"
}
Step 3: Add the Executable Script
Create a folder named bin and place the secenv.cjs code inside bin/secenv.js.
Add the following line to the very top of bin/secenv.js if it isn't there:
#!/usr/bin/env node
This is the shebang line that tells Unix-like systems to run the script using Node.js.
Make it executable locally to test:
chmod +x bin/secenv.js
npm link
Now you can run secenv directly in your terminal to test it locally.
Step 4: Publish to NPM
- Log into your NPM account from your CLI:
npm login - Publish your package to the registry:
npm publish --access public
Now, on any laptop, you can immediately generate your .env by running:
npx secenv-cli
And sync it to GitHub Actions using:
npx secenv-cli --github
Organizing Your Open-Source GitHub Repository
To make your solution professional and usable by the open-source community,
create a detailed README.md in your public secenv CLI repository.
Your repository structure should look like this:
secenv-cli/
├── bin/
│ └── secenv.js # The main CLI script
├── package.json # NPM configuration
├── LICENSE # MIT License file
└── README.md # Documentation
Writing the README.md
Ensure your README.md covers:
- Concept: Explain the architectural idea (using a private Git repository as the central database of truth).
- Prerequisites: Note that users need Git and the GitHub CLI (
gh) if they want to sync to actions. - Quick Start:
- Create a private repository with
secrets.json. - Run
npx secenv-cli.
- Create a private repository with
- JSON Schema: Provide a copy-paste example of
secrets.json. - Command Flags: Outline
-g/--githubfor Action syncing.
Key Rotation Workflow
When you need to rotate a secret (e.g. your OpenAI API Key is compromised or expires):
- Open the
secrets.jsonfile in your centralsecretsrepository. - Edit the single line under
shared.OPENAI_API_KEY:"OPENAI_API_KEY": "sk-proj-NEW_API_KEY_HERE" - Commit and push:
git commit -am "rotate openai api key" && git push - Now, go to the project directory of any repo that uses it, and run
secenv. Your local.envis updated instantly. - If the project runs on GitHub Actions, run
secenv --githubto automatically update the secret on GitHub without navigating to the settings UI.
AI Agents Integration
One of the largest benefits of this architecture is its compatibility with AI coding agents.
AI coding agents (like Cursor, Cline, or Windsurf) frequently write code that requires new configuration variables. During development:
- The AI agent appends a new variable (e.g.,
RESEND_API_KEY=) to the project's.env.examplefile. - The developer runs the command:
secenv - The script detects that
RESEND_API_KEYis missing. It prompts you in the terminal. - You input
shared.RESEND_API_KEY(if defined centrally) or type in the specific key. - The script automatically updates the local
.env(so the AI agent can continue testing the code immediately) and updates the private central configuration file, committing and pushing it to Git.
This loop keeps the development speed extremely fast, ensuring that AI-generated projects are fully backed up with zero manual bookkeeping.
Comments
Comments are powered by giscus. Set
PUBLIC_GISCUS_REPO_IDandPUBLIC_GISCUS_CATEGORY_IDin your environment to enable them.