better-env
v0.2.0
Published
Runtime + CLI to sync local .env files with remote providers and validate env configs.
Downloads
254
Readme
better-env
Better environment variable management for agents and humans with full type safety, CLI-based remote environment synchronization and validation.
Introduction
Don't you hate it when your production build fails because you forgot to upload a new env var to your hosting provider? Isn't it super furstrating when your on another machine and you want to work on your app only to realize your env variables are not up to date or missing? I think we deserve a better way. Enter better-env.
better-env is a toolkit for environment and runtime configuration management, including:
config-schemafor typed env declarations- a CLI for remote variable operations
- provider adapters to sync local dotenv files with hosted platforms (Vercel, Netlify, Railway, Cloudflare, Fly.io, and Convex)
Setup
1) Install the coding agent skill
Install the better-env skill first so coding agents can apply the recommended conventions and workflows:
npx skills add neondatabase/better-env2) Add typed config modules for environment variables
Use better-env/config-schema to define typed config objects. This gives runtime validation and typed access for both server and public values.
Example for managing a database connection string:
import { configSchema, server } from "better-env/config-schema";
export const databaseConfig = configSchema("Database", {
databaseUrl: server({ env: "DATABASE_URL" }),
});import { drizzle } from "drizzle-orm/node-postgres";
import { Pool } from "pg";
import { databaseConfig } from "@/lib/database/config";
const pool = new Pool({
connectionString: databaseConfig.databaseUrl,
});
export const db = drizzle({ client: pool });Example with feature flags and public environment variables:
import { configSchema, server, pub } from "better-env/config-schema";
export const sentryConfig = configSchema(
"Sentry",
{
token: server({ env: "SENTRY_AUTH_TOKEN" }),
dsn: pub({
env: "NEXT_PUBLIC_SENTRY_DSN",
value: process.env.NEXT_PUBLIC_SENTRY_DSN,
}),
},
{
flag: {
env: "NEXT_PUBLIC_ENABLE_SENTRY",
value: process.env.NEXT_PUBLIC_ENABLE_SENTRY,
},
},
);Best practice: keep one config.ts per feature or infrastructure service.
src/lib/auth/config.tssrc/lib/database/config.tssrc/lib/sentry/config.ts
This keeps ownership clear and allows validation to discover config declarations consistently.
3) Add and run environment validation
If your project follows the config.ts convention, you can use the better-env validate command to validate your current app enviornment against your application's config schemas.
{
"scripts": {
"env:validate": "better-env validate --environment development",
"dev": "npm run env:validate && next dev",
"build": "better-env validate --environment production && next build"
}
}Remote Environment Management
If you're using a supported hosting provider, you can use the better-env CLI to manage your remote environment variables and keep your local dotenv files in sync.
Requirements
- Provider CLI available in
$PATH- Vercel adapter:
vercel(or setvercelBin) - Netlify adapter:
netlify(or setnetlifyBin) - Railway adapter:
railway(or setrailwayBin) - Cloudflare adapter:
wrangler(or setwranglerBin) - Fly adapter:
fly(or setflyBin) - Convex adapter:
convex(or setconvexBin)
- Vercel adapter:
Configure better-env.ts
Create better-env.ts in your project root:
import { defineBetterEnv, vercelAdapter } from "better-env";
export default defineBetterEnv({
adapter: vercelAdapter(),
});Or let the CLI generate it for you:
npx better-env init- If
better-env.tsis missing,initnow opens a provider selection prompt. - It pre-selects based on project markers (
.vercel,.netlify,.railway,wrangler.toml/.wrangler,fly.toml). npx better-env init --yesskips prompts and uses the inferred provider (fallback: Vercel).
Netlify example:
import { defineBetterEnv, netlifyAdapter } from "better-env";
export default defineBetterEnv({
adapter: netlifyAdapter(),
});Cloudflare Workers example:
import { cloudflareAdapter, defineBetterEnv } from "better-env";
export default defineBetterEnv({
adapter: cloudflareAdapter(),
});Railway example:
import { defineBetterEnv, railwayAdapter } from "better-env";
export default defineBetterEnv({
adapter: railwayAdapter(),
});Fly.io example:
import { defineBetterEnv, flyAdapter } from "better-env";
export default defineBetterEnv({
adapter: flyAdapter({
app: process.env.BETTER_ENV_FLY_APP,
}),
});Convex example:
import { convexAdapter, defineBetterEnv } from "better-env";
export default defineBetterEnv({
adapter: convexAdapter(),
});Run initial setup and first sync:
npx better-env init
npx better-env pull --environment developmentEnvironments
By default, better-env provides these environment names:
development→ writes.env.development, pulls from Verceldevelopmentpreview→ writes.env.preview, pulls from Vercelpreviewproduction→ writes.env.production, pulls from Vercelproductiontest→ writes.env.test, local-only (no remote mapping)local→ writes.env.local, local-only (no remote mapping)
For Netlify adapter, the same local names map to:
development→ Netlifydevpreview→ Netlifydeploy-previewproduction→ Netlifyproductiontest→ local-only (no remote mapping)
For Cloudflare adapter, the same local names map to Workers environments:
development→ Wrangler--env developmentpreview→ Wrangler--env previewproduction→ Wrangler default environment (no--envflag)test→ local-only (no remote mapping)
For Railway adapter, the same local names map to Railway environments by name:
development→ Railwaydevelopmentpreview→ Railwaypreviewproduction→ Railwayproductiontest→ local-only (no remote mapping)
For Fly adapter, local names map to one Fly app secret store:
development→ Fly app secrets (single remote target)preview→ Fly app secrets (single remote target)production→ Fly app secrets (single remote target)test→ local-only (no remote mapping)
For Convex adapter, local names map to Convex deployments:
development→ Convex development deploymentproduction→ Convex production deployment (--prod)preview→ local-only by default (no remote mapping)test→ local-only (no remote mapping)
You can override (or add) environments in better-env.ts:
import { defineBetterEnv, vercelAdapter } from "better-env";
export default defineBetterEnv({
adapter: vercelAdapter(),
environments: {
development: { envFile: ".env.development", remote: "development" },
preview: { envFile: ".env.preview", remote: "preview" },
production: { envFile: ".env.production", remote: "production" },
test: { envFile: ".env.test", remote: null },
},
});Notes: better-env never writes to .env.local (use it as your local override).
Cloudflare + better-env
better-env pullis not supported for Cloudflare secrets (Wrangler cannot read back secret values).wrangler dev(local mode) does not inject remote secrets into local dotenv files so it's on you to keep your local env vars in sync with the remote environment. Or runwrangler dev --remoteto use deployed environment bindings at runtime.- Recommended workflow: keep local files (
.env.*/.dev.vars) as source of truth, then push withbetter-env load.
Fly + better-env
better-env pullis not supported for Fly secrets (fly secrets listdoes not expose secret values).- Set
BETTER_ENV_FLY_APPor configureflyAdapter({ app: "your-app-name" }), unless yourfly.tomlalready definesapp = "..." - Fly currently has one secret store per app, so
development,preview, andproductionall target the same remote secret set by default.
Convex + better-env
better-env pullis supported for Convex.- Default mappings support
developmentandproductionremotes;previewis local-only unless you define a custom mapping inbetter-env.ts.
CLI Command Reference
init: createsbetter-env.tswhen missing (prompt or--yes), then validates provider CLI availability and verifies project linkage when required (.vercel/project.jsonor.netlify/state.json)pull: fetches remote variables and ensures local.gitignorecoveragevalidate: validates required variables by loadingconfig.tsmodulesadd|upsert|update|delete: applies single-variable mutations to the remote providerload: applies dotenv file contents usingadd|update|upsert|replacemodesenvironments list: prints configured local/remote environment mappings
better-env init [--yes]
better-env pull [--environment <name>]
better-env validate [--environment <name>]
better-env add <key> <value> [--environment <name>] [--sensitive]
better-env upsert <key> <value> [--environment <name>] [--sensitive]
better-env update <key> <value> [--environment <name>] [--sensitive]
better-env delete <key> [--environment <name>]
better-env load <file> [--environment <name>] [--mode add|update|upsert|replace] [--sensitive]
better-env environments listContribution
Run local checks:
npm run build
npm run typecheck
npm testRun adapter e2e coverage:
- Live Vercel adapter test (creates and removes a real project):
- Requires authenticated
vercelCLI and project create/remove permissions - Run with
npm run test:e2e:vercel
- Requires authenticated
- Live Netlify adapter test (creates and removes a real project):
- Requires authenticated
netlifyCLI and project create/remove permissions - Run with
npm run test:e2e:netlify
- Requires authenticated
- Live Railway adapter test (creates and removes a real project):
- Requires authenticated
railwayCLI and project create/remove permissions - Optionally set
BETTER_ENV_RAILWAY_WORKSPACE=<workspace-id>when multiple workspaces exist - Run with
npm run test:e2e:railway
- Requires authenticated
- Live Fly adapter test (creates and removes a real app):
- Requires authenticated
flyCLI and app create/remove permissions - Run with
npm run test:e2e:fly
- Requires authenticated
- Netlify adapter runtime e2e test (fake CLI binary):
- Run with
bun test test/e2e/runtime-netlify.test.ts
- Run with
- Railway adapter runtime e2e test (fake CLI binary):
- Run with
npm run test:e2e:railway:runtime
- Run with
- Cloudflare adapter runtime e2e test:
- Run with
bun test test/e2e/runtime-cloudflare.test.ts
- Run with
- Fly adapter runtime e2e test:
- Run with
npm run test:e2e:fly:runtime
- Run with
