cf-envsync
v0.5.0
Published
Sync .env files to Cloudflare Workers secrets, .dev.vars, and more
Readme
.env.{environment} ──→ envsync ──→ Cloudflare Workers secrets
+ │ (per worker, per env)
.env.local ──→ │
(per-developer) ├──→ .dev.vars for each app
│ (local dev)
└──→ validation
(nothing missing)The Problem
If you're building on Cloudflare Workers with dotenvx encryption in a monorepo, you know the pain:
- dotenvx breaks Git — Same plaintext, different ciphertext every time. Two devs touch
.env= guaranteed merge conflict. - Three layers that don't sync — Vite reads
.env, wrangler reads.dev.vars, production reads from the dashboard. Forget to update one? Silent failure. - N Workers x M Environments x manual labor —
wrangler secret putone key at a time, per worker, per environment. - Per-developer secrets — OAuth callback URLs differ per dev tunnel. No way to enforce they're set.
- No way to verify what's deployed — "Is production using the new key or the old one?" Push and pray.
Every existing tool solves one piece. envsync connects them all.
Quick Start
# bun
bun add -d cf-envsync
# npm
npm install -D cf-envsync
# pnpm
pnpm add -D cf-envsync# Initialize (scans wrangler.jsonc files in monorepos)
npx envsync init --monorepo
# Set up Claude Code integration (recommended)
npx envsync claude-setup
# Generate .dev.vars for local development
npx envsync dev
# Push secrets to staging
npx envsync push staging
# Validate nothing is missing before deploying
npx envsync validateRequirements
- Node.js >= 18 or Bun
- wrangler CLI (peer dependency, for push/pull/diff)
- dotenvx (optional, only if using
encryption: "dotenvx")
5-Minute Tutorial
A complete walkthrough: project setup → local dev → deploy to staging → validate.
1. Initialize your project
# In your monorepo root
npm install -D cf-envsync
npx envsync init --monorepoThis scans for wrangler.jsonc files, discovers your workers, and generates:
envsync.config.ts ← config with all apps/workers detected
.env.example ← key reference (committed to git)
.env ← local shared secrets
.env.staging ← staging secrets (empty, you'll fill these)
.env.production ← production secrets (empty)
.gitignore ← updated with .env.local, .env.password, **/.dev.vars2. Fill in your secrets
# .env — local development values (committed, encrypted)
DATABASE_URL=postgres://localhost:5432/mydb
JWT_SECRET=dev_jwt_secret
AUTH_SECRET=dev_auth_secret
API_URL=http://localhost:8787
# .env.staging — staging values
DATABASE_URL=postgres://staging-db.example.com/mydb
JWT_SECRET=staging_jwt_secret_abc
AUTH_SECRET=staging_auth_secret_xyz
API_URL=https://api-staging.example.com
# .env.local — YOUR dev-specific overrides (gitignored, each dev has their own)
OAUTH_REDIRECT_URL=https://my-tunnel.ngrok.io/callback
DEV_TUNNEL_URL=https://my-tunnel.ngrok.io3. Encrypt before committing (optional)
# Set a password
echo "ENVSYNC_PASSWORD=my-team-password" > .env.password
# Encrypt all plain values
npx envsync encrypt staging
npx envsync encrypt production
# Now .env.staging looks like:
# DATABASE_URL=envsync:v1:base64payload...
# JWT_SECRET=envsync:v1:base64payload...4. Local development
npx envsync devThis reads .env + .env.local, merges them, and writes .dev.vars into each app directory. Start wrangler as usual — it reads .dev.vars automatically.
apps/api/.dev.vars ← DATABASE_URL, JWT_SECRET, API_URL, OAUTH_REDIRECT_URL
apps/web/.dev.vars ← AUTH_SECRET, VITE_API_URL, VITE_OAUTH_REDIRECT_URLVite / non-wrangler apps:
.dev.varsis for wrangler only. If your app runs withvite dev, setdevFilein your config:apps: { web: { path: "apps/web", devFile: ".env.local", // Vite reads this // or generate both: // devFile: [".dev.vars", ".env.local"], }, }
If you forgot to set a per-dev override, envsync tells you:
⚠ Missing in .env.local: DEV_TUNNEL_URL (required per-dev override)
→ echo "DEV_TUNNEL_URL=https://your-tunnel.example.com" >> .env.local5. Push to staging
# Preview first
npx envsync push staging --dry-run
# Push for real
npx envsync push staging
# Push 4 secrets to worker "my-api-staging" (staging)? yes
# ✓ Pushed 4 secrets to my-api-staging
# Push 1 secrets to worker "my-web-staging" (staging)? yes
# ✓ Pushed 1 secrets to my-web-staging6. Validate before deploying
npx envsync validate
# Checks every app × every environment against .env.example
# Exit code 1 if anything is missing → safe for CI7. CI/CD integration
# GitHub Actions example
- name: Validate env vars
run: npx envsync validate
- name: Push secrets to production
run: npx envsync push production --force
env:
ENVSYNC_PASSWORD: ${{ secrets.ENVSYNC_PASSWORD }}
CLOUDFLARE_API_TOKEN: ${{ secrets.CF_API_TOKEN }}Claude Code Integration
envsync는 Claude Code 커스텀 슬래시 커맨드를 기본 제공합니다. 한 번만 설정하면 Claude Code가 환경 변수 관련 작업을 자동으로 envsync 커맨드로 처리합니다.
npx envsync claude-setup이 커맨드는 .claude/commands/envsync.md를 생성합니다. 이후 Claude Code에서 /project:envsync로 호출하거나, "환경 변수 추가해줘"같은 요청 시 자동으로 envsync set/unset을 사용합니다.
.claude/commands/envsync.md는 git에 커밋하세요. 팀원 모두 별도 설정 없이 바로 사용할 수 있습니다.
Commands
envsync set / get / unset — Add, read, remove env vars
The simplest way to manage individual keys.
npx envsync set staging API_KEY sk-123456 # Add or update (auto-encrypts)
npx envsync set staging API_KEY sk-123456 --raw # Store without encrypting
npx envsync get staging API_KEY # Read (auto-decrypts)
npx envsync get staging API_KEY --raw # Print raw stored value
npx envsync unset staging API_KEY # Removeenvsync dev — Generate .dev.vars
The command you'll use most. Merges .env + .env.local and writes .dev.vars for each app.
npx envsync dev # All apps
npx envsync dev api # Just api
npx envsync dev api web # Multiple apps
npx envsync dev --env staging # Use staging values for local dev$ envsync dev
apps/api/.dev.vars
├ DATABASE_URL ← .env
├ TWITCH_CLIENT_SECRET ← .env (shared)
├ TWITCH_CLIENT_ID ← .env (shared)
├ JWT_SECRET ← .env (shared)
├ API_URL ← .env
└ OAUTH_REDIRECT_URL ← .env.local (per-dev override)
apps/web/.dev.vars
├ AUTH_SECRET ← .env
├ VITE_API_URL ← .env
└ VITE_OAUTH_REDIRECT_URL ← .env.local (per-dev override)
⚠ Missing in .env.local: DEV_TUNNEL_URL (required per-dev override)
→ echo "DEV_TUNNEL_URL=https://your-tunnel.example.com" >> .env.local
Done!Every key shows exactly where its value came from. Missing per-dev overrides are caught immediately.
Vite / non-wrangler apps: Set
devFile: ".env.local"in your app config. See the tutorial.
envsync push — Deploy secrets
Push secrets to Cloudflare Workers via wrangler secret bulk. One command, all workers.
npx envsync push staging # All apps → staging workers
npx envsync push production # All apps → production workers
npx envsync push staging api # Just api's staging worker
npx envsync push production --shared # Only shared secrets (JWT_SECRET, etc.)$ envsync push staging --dry-run
Pushing secrets for api → my-app-api-staging (staging)...
Would push 4 secrets to worker "my-app-api-staging"
DATABASE_URL
TWITCH_CLIENT_ID (shared)
TWITCH_CLIENT_SECRET (shared)
JWT_SECRET (shared)
Pushing secrets for web → my-app-web-staging (staging)...
Would push 1 secrets to worker "my-app-web-staging"
AUTH_SECRET
Done!Use --dry-run to preview. Use --force to skip confirmation prompts (CI-friendly).
envsync diff — Compare environments
Two modes: local vs remote and env vs env.
# Local .env.production vs what's actually on Cloudflare
npx envsync diff production
npx envsync diff production api
# Compare two environments side-by-side
npx envsync diff staging production$ envsync diff staging production
stream-collector
TWITCH_CLIENT_ID stag**** prod**** ✔ expected
TWITCH_CLIENT_SECRET stag**** prod**** ✔ expected
YOUTUBE_API_KEY stag**** (missing) ✘ missing in production!
1 key(s) missingCatch missing keys before they break production.
envsync validate — Catch missing keys
Checks all apps across all environments against .env.example.
npx envsync validate # All environments, all apps
npx envsync validate staging # Just staging
npx envsync validate staging api # Just api in staging$ envsync validate
Checking against .env.example...
local
✔ api: all 8 keys present
✔ web: all 6 keys present
✔ stream-collector: all 6 keys present
staging
✔ api: all 6 keys present
✔ web: all 3 keys present
✔ stream-collector: all 4 keys present
production
✔ api: all 6 keys present
✔ web: all 3 keys present
✘ stream-collector: 3/4 keys
missing: YOUTUBE_API_KEY
⚠ 1 environment(s) have issuesExits with code 1 on failure — plug it into CI.
envsync pull — Scaffold from remote
Pull secret key names from Cloudflare and scaffold empty entries in your local .env file. (Values are not available via the API — only key names.)
npx envsync pull staging
npx envsync pull production apienvsync list — See the full picture
npx envsync list # Summary table
npx envsync list api --keys # Detailed key list for one app$ envsync list
App local staging production
──────────────── ───────────────── ─────────────────────────── ───────────────────
api (dev) my-app-api-staging my-app-api
4 secrets, 2 vars 4 secrets, 2 vars 4 secrets, 2 vars
web (dev) my-app-web-staging my-app-web
1 secret, 2 vars 1 secret, 2 vars 1 secret, 2 vars
stream-collector (dev) my-app-collector-staging my-app-collector
4 secrets 4 secrets 3 secrets
Shared secrets (3): JWT_SECRET, TWITCH_CLIENT_ID, TWITCH_CLIENT_SECRET
Per-dev overrides (local only): OAUTH_REDIRECT_URL, DEV_TUNNEL_URL, VITE_OAUTH_REDIRECT_URL
.env files status:
├ .env ✔ (11 keys)
├ .env.staging ✔ (11 keys)
├ .env.production ✔ (10 keys)
└ .env.local ✔ (3 overrides)envsync init — Project setup
Interactive setup that scans your repo and generates everything.
npx envsync init # Single project
npx envsync init --monorepo # Scans for wrangler.jsonc filesWhat it does:
- Asks for encryption method (
password,dotenvx, ornone) - Scans
wrangler.jsoncfiles to discover workers and environments - Detects shared secrets across apps
- Creates
envsync.config.ts,.env.example, and empty.env.{environment}files - Adds
.env.local,.env.keys,.env.password,**/.dev.varsto.gitignore - Registers the custom Git merge driver in
.gitattributes
envsync normalize — Sort keys
Alphabetically sorts keys in all .env* files. Reduces diff noise, prevents merge conflicts.
npx envsync normalize # All .env* files recursively
npx envsync normalize .env.staging # Specific fileenvsync encrypt — Encrypt plain values
Encrypts plain-text values in a .env file using password-based encryption (AES-256-GCM). Only available when encryption: "password".
npx envsync encrypt staging # Encrypt all plain values in .env.staging
npx envsync encrypt production # Encrypt .env.production
npx envsync encrypt staging --dry-run # Preview without writing$ envsync encrypt staging
DATABASE_URL: encrypted
API_KEY: encrypted
JWT_SECRET: encrypted
Encrypted 3 values in .env.staging (0 skipped)Already-encrypted and empty values are skipped automatically.
envsync merge — Git merge driver
A 3-way merge driver that understands both dotenvx and password encryption. Registered automatically by envsync init.
# .gitattributes (auto-generated)
.env merge=envsync
.env.* merge=envsyncHow it works:
- Decrypts all three versions (base, ours, theirs) — supports both dotenvx and password encryption
- 3-way merge at the key level — not the encrypted ciphertext
- Only real conflicts get conflict markers
- Re-encrypts the merged result (password mode uses
encryptEnvMap, dotenvx usesdotenvx encrypt)
No more fake conflicts from identical values with different ciphertext.
Configuration
envsync.config.ts
The recommended way to configure envsync. Full type checking, autocomplete, and comments.
import { defineConfig } from "cf-envsync";
export default defineConfig({
environments: ["local", "staging", "production"],
envFiles: {
pattern: ".env.{env}", // local → .env, staging → .env.staging
local: ".env.local", // per-developer overrides (gitignored)
perApp: true, // allow apps/api/.env.staging etc.
},
encryption: "dotenvx",
apps: {
api: {
path: "apps/api",
workers: {
staging: "my-api-staging",
production: "my-api",
},
secrets: ["DATABASE_URL", "JWT_SECRET"],
vars: ["API_URL", "ENVIRONMENT"],
},
web: {
path: "apps/web",
workers: {
staging: "my-web-staging",
production: "my-web",
},
secrets: ["AUTH_SECRET"],
vars: ["VITE_API_URL", "VITE_APP_URL"],
},
},
shared: ["JWT_SECRET"],
local: {
overrides: ["DEV_TUNNEL_URL"],
perApp: {
api: ["OAUTH_REDIRECT_URL"],
web: ["VITE_OAUTH_REDIRECT_URL"],
},
},
});envsync.json and envsync.jsonc are also supported:
{
"environments": ["local", "staging", "production"],
"envFiles": {
"pattern": ".env.{env}",
"local": ".env.local",
"perApp": true
},
"encryption": "dotenvx",
"apps": {
"api": {
"path": "apps/api",
"workers": { "staging": "my-api-staging", "production": "my-api" },
"secrets": ["DATABASE_URL", "JWT_SECRET"],
"vars": ["API_URL", "ENVIRONMENT"]
}
}
}If you prefer plain JavaScript, use JSDoc for type checking:
// envsync.config.js
/** @type {import("cf-envsync").EnvSyncConfig} */
export default {
environments: ["local", "staging", "production"],
// ...
};| Field | Type | Description |
|-------|------|-------------|
| environments | string[] | Available environments |
| envFiles.pattern | string | File naming pattern. {env} is replaced. local falls back to .env |
| envFiles.local | string | Per-developer override file (gitignored) |
| envFiles.perApp | boolean | Allow per-app .env.{env} files for app-specific overrides |
| encryption | "password" \| "dotenvx" \| "none" | Encryption method for .env files |
| apps.{name}.path | string | Path to app directory relative to project root |
| apps.{name}.workers | Record<string, string> | Worker name per environment |
| apps.{name}.secrets | string[] | Secret keys pushed via wrangler secret bulk |
| apps.{name}.vars | string[] | Non-secret env vars (not pushed as secrets) |
| apps.{name}.devFile | string \| string[] | Output file(s) for envsync dev. Default: ".dev.vars". Use ".env.local" for Vite apps, or an array for both |
| shared | string[] | Keys with the same value across multiple apps |
| local.overrides | string[] | Keys each developer must set in .env.local |
| local.perApp | Record<string, string[]> | Per-app developer override keys |
Config file search order: envsync.config.ts > .js > .mjs > envsync.json > envsync.jsonc
Encryption
envsync supports three encryption modes:
| Mode | How it works | Dependencies |
|------|-------------|--------------|
| "password" | AES-256-GCM, per-value encryption with a shared password. Values stored as envsync:v1:{base64}. | None (uses node:crypto) |
| "dotenvx" | ECIES public/private key encryption via dotenvx CLI. | dotenvx CLI |
| "none" | No encryption. .env files stored in plain text. | None |
Password encryption
Per-value encryption means each key-value pair is encrypted independently — git diffs are readable at the key level, and merges work cleanly.
# .env.staging (committed, encrypted)
DATABASE_URL=envsync:v1:base64encodedpayload...
API_KEY=envsync:v1:base64encodedpayload...Password source (checked in order):
ENVSYNC_PASSWORD_{ENV}env var (e.g.ENVSYNC_PASSWORD_STAGING)ENVSYNC_PASSWORDenv var (generic fallback).env.passwordfile withENVSYNC_PASSWORD_{ENV}=xxx(env-specific).env.passwordfile withENVSYNC_PASSWORD=xxx(generic fallback)
# Quick setup
echo "ENVSYNC_PASSWORD=your-strong-password" > .env.password
# Or per-environment passwords
cat > .env.password << 'EOF'
ENVSYNC_PASSWORD_STAGING=staging-password
ENVSYNC_PASSWORD_PRODUCTION=production-password
EOF
# Encrypt plain values
npx envsync encrypt staging
npx envsync encrypt productionFile structure
project/
├── envsync.config.ts # Config (committed)
│
├── .env # Local shared secrets (encrypted, committed)
├── .env.staging # Staging secrets (encrypted, committed)
├── .env.production # Production secrets (encrypted, committed)
├── .env.local # Per-developer overrides (gitignored)
├── .env.example # Key reference (committed)
├── .env.keys # dotenvx private keys (gitignored)
├── .env.password # Password encryption keys (gitignored)
│
├── apps/
│ ├── api/
│ │ ├── wrangler.jsonc
│ │ ├── .dev.vars # ← generated by envsync dev
│ │ └── .env.staging # [optional] api-specific staging overrides
│ ├── web/
│ │ ├── wrangler.jsonc
│ │ └── .dev.vars # ← generated
│ └── stream-collector/
│ ├── wrangler.jsonc
│ ├── .dev.vars # ← generated
│ └── .env # app-specific secrets (YOUTUBE_API_KEY, etc.)
│
└── .gitignore # .env.local, .env.keys, .env.password, **/.dev.varsMerge priority
Values are merged in this order (last wins):
root .env.{env} → app .env.{env} → .env.local (local env only)Single project
Works the same way. Just one app with path: ".":
import { defineConfig } from "cf-envsync";
export default defineConfig({
environments: ["local", "staging", "production"],
envFiles: { pattern: ".env.{env}", local: ".env.local", perApp: false },
encryption: "dotenvx",
apps: {
default: {
path: ".",
workers: { staging: "my-worker-staging", production: "my-worker" },
secrets: ["DATABASE_URL", "API_KEY"],
},
},
});Why?
envsync fills the gap: encrypted .env files as the single source of truth, synced to every target — Workers secrets, .dev.vars, validation — with monorepo and multi-environment support built in. Choose password encryption (zero dependencies, per-value, merge-friendly) or dotenvx (ECIES key pairs).
No SaaS. No dashboard. Just a CLI, your .env files, and Cloudflare's API.
Testing
180 tests across 21 files covering utils, core modules, and all commands.
bun test # Run tests
bun test --coverage # Run with coverage reportCoverage
| File | % Funcs | % Lines | |------|---------|---------| | All files | 83.93 | 76.13 | | src/core/resolver.ts | 100.00 | 100.00 | | src/core/wrangler.ts | 100.00 | 100.00 | | src/core/env-file.ts | 100.00 | 98.65 | | src/core/encryption.ts | 100.00 | 95.65 | | src/utils/fs.ts | 100.00 | 97.44 | | src/core/config.ts | 71.43 | 76.23 | | src/commands/merge.ts | 75.00 | 28.40 | | src/utils/output.ts | 25.00 | 12.70 |
merge.tsandoutput.tsline coverage is lower because their command handlers and print functions are tested via CLI integration tests (spawned subprocesses), which bun's coverage instrumentation does not trace into.
Tech Stack
| | |
|---|---|
| Runtime | Node.js >= 18 or Bun |
| CLI framework | citty |
| Output | consola |
| Config loading | jiti |
| Encryption | node:crypto AES-256-GCM (password mode) / @dotenvx/dotenvx (dotenvx mode) |
| CF Secrets | wrangler CLI (shell out) |
License
MIT
