The Problem with Naked .env
Every project starts the same way. You create a .env file,
put your API keys in it, add it to .gitignore, and move on.
This works until it doesn’t:
- A new team member clones the repo and spends an hour figuring out which environment variables they need.
- An AI agent with
shell access runs
envor reads/proc/self/environand your API keys end up in a log file, a context window, or a training dataset. - Someone pastes
REDIS_PORT=notanumberand the application crashes at runtime instead of at startup. - You deploy to production with a dev key because nothing enforced the difference.
Varlock
is a tool by dmno-dev
that solves all of these with a single file:
.env.schema.
This post covers the first and easiest migration milestone:
varlock on top of plain .env. No encryption, no
secret servers, no infrastructure changes. Just a schema file and a
ten-minute migration.
What You Get
After this migration, you have:
- Startup validation. Your application won’t start if a required key is missing, a port number is out of range, or an API key has the wrong prefix.
- Leak prevention. Values marked
@sensitiveare automatically redacted in logs and process listings. Varlock showssk█████instead of your actual key. - Agent visibility. AI agents can read
.env.schemato understand what keys exist, what types they are, and what constraints they must satisfy — without ever seeing actual secret values. - Self-documenting configuration. The schema is
the documentation. No more
.env.examplefiles that drift out of sync.
Step 1: Install Varlock
Varlock ships as a standalone binary. If you’re on NixOS (as we are), you can use our Nix flake:
# Clone and build
git clone https://github.com/ruach-tov/varlock-nix
cd varlock-nix
nix build .#varlock
./result/bin/varlock --version
# 0.6.2
On other platforms, grab the binary from GitHub releases or install via npm:
npx varlock@latest --version
Step 2: Write Your Schema
Create .env.schema next to your .env. The syntax
is JSDoc-style
comment annotations above each variable:
# --- API keys ---
# @required @sensitive @type=string(startsWith=sk-ant-api)
ANTHROPIC_API_KEY=
# @required @sensitive @type=string(startsWith=sk-proj-)
OPENAI_API_KEY=
# @required @sensitive @type=string(startsWith=ghp_)
GH_TOKEN=
# @required @sensitive @type=string(startsWith=sk-ant-admin)
ANTHROPIC_ADMIN_KEY=
# --- Redis passwords ---
# @sensitive @type=string(minLength=32)
BASTION_REDIS_PASSWORD=
# @sensitive @type=string(minLength=32)
MANUS_REDIS_PASSWORD=
# --- Non-sensitive infrastructure ---
# @sensitive=false @type=string @required
BASTION_HOST=203.0.113.10
# @sensitive=false @type=port @required
BASTION_REDIS_PORT=6379
The annotations:
| Annotation | Meaning |
|---|---|
@required | Startup fails if missing or empty |
@sensitive | Value is redacted in output |
@sensitive=false | Explicitly non-secret (shown in clear) |
@type=string(startsWith=...) | String prefix validation |
@type=string(minLength=N) | Minimum length enforcement |
@type=port | Integer 0–65535 |
@type=url | Valid URL format |
@type=enum(a,b,c) | Value must be one of the listed options |
A value in the schema line (like BASTION_HOST=203.0.113.10)
serves as the default — used when the .env file doesn’t
override it. This is perfect for
non-sensitive
infrastructure values that should be committed to version control.
Step 3: Validate
Run varlock load to check your configuration:
$ varlock load
✅ ANTHROPIC_API_KEY* 🔐 sensitive
└ sk█████
✅ OPENAI_API_KEY* 🔐 sensitive
└ sk█████
✅ GH_TOKEN* 🔐 sensitive
└ gh█████
✅ BASTION_REDIS_PASSWORD 🔐 sensitive
└ undefined
✅ BASTION_HOST*
└ "203.0.113.10"
✅ BASTION_REDIS_PORT*
└ 6379
Notice three things:
- Sensitive values show only their first two characters, then
█████. - Non-sensitive values with
@sensitive=falsedisplay in full. - Optional values that are undefined show
undefinedbut still pass — only@requiredvalues cause failure.
Step 4: See It Catch Mistakes
What happens when someone provides a bad value?
$ BASTION_REDIS_PORT=99999 varlock load
❌ BASTION_REDIS_PORT*
└ 99999 < coerced from "99999"
- Value must be a valid port number (0-65535)
$ ANTHROPIC_API_KEY=bad-prefix varlock load
❌ ANTHROPIC_API_KEY* 🔐 sensitive
└ ba█████
- Value must start with "sk-ant-api"
Validation happens before your application starts. No more runtime crashes from misconfiguration.
Step 5: Wrap Your Application
To run your application with validated environment variables:
# Schema is validated, then your command runs with the
# validated env vars injected into its process
varlock run -- python my_app.py
varlock run -- node server.js
varlock run -- ./my-binary --flag
If validation fails, the command never runs. If it passes, the subprocess inherits the validated, typed environment.
Step 6: Commit Your Schema
The schema goes in version control. The .env does not.
# .gitignore
*.env
!.env.example
!.env.schema
Your .env.example file is now redundant — the schema
is strictly more informative. But you might keep it around for tools that
expect it.
What This Doesn’t Solve (Yet)
This first milestone —
varlock on
plain .env — gives you validation and leak
prevention. But secrets are still plaintext files on disk. The next two
milestones in this series will address that:
- Varlock with local encrypted secrets. Secrets encrypted at rest, decrypted only into process memory at startup. For single-node and small-team deployments.
- Varlock with replicated secrets servers. Distributed secret management with netsplit endurance and high availability. For organizations that need secrets to survive infrastructure failures.
But even the plain-env milestone is a substantial improvement over
naked .env. Most projects never get past this stage, and
honestly — for a lot of use cases — they don’t need to.
The Deeper Problem: Type Texture
Here is where we get opinionated.
Varlock’s type system today is shallow. When you write
@type=string, you’re saying almost nothing. When you write
@type=string(startsWith=sk-ant-api), you’re doing a prefix
match — a regexp
with lipstick. This catches typos, but it doesn’t capture what
the value actually is.
Consider a
host
specification. Varlock types it as @type=string, maybe
with a regex. But a hostname isn’t a string that happens to match a
pattern — it’s a structured datum whose components have independent
meaning:
host-specification
:= etc-hosts-name # [A-Za-z_][A-Za-z0-9_\-]+
:= dns-fqdn # labels separated by dots, TLD required
:= ip-specification
:= ip-dotted-quad # four octets (0-255) separated by dots
:= ipv4-hex # 8 hexadecimal characters
:= ipv6 # eight groups of four hex digits, colon-separated
Each variant carries different operational implications:
- An
etc-hosts-namemeans the value depends on the local/etc/hostsfile — it won’t resolve on a fresh machine. - A
dns-fqdnmeans there’s a DNS dependency — the service might fail during DNS outages, but can failover to different IPs. - An
ip-dotted-quadmeans no DNS dependency, but no failover either — you’re hard-coded to a specific address.
The same analysis applies to every “string” in configuration. A URL isn’t a string — it’s a composite of protocol (an enum), hostname (a host-specification), port (an integer 1–65535), and path (a hierarchical name). A port number isn’t just “an integer” — port 0 means “kernel assigns ephemeral,” ports 1–1023 are privileged, and the maximum value 65535 is legitimate to listen on. Even a boolean has texture: is it a feature flag or a safety switch?
This is the same insight that drives
Middah’s
dimensional analysis: view the data the same way you would if you were
composing it for a printf. What are the informational
atoms? How do I recognize, classify, and absorb this datum in a type-safe,
dimensionally-labeled
way?
Varlock doesn’t do this today. Neither does anything else we’ve seen. But it’s the direction configuration typing needs to go — from “does this string match a pattern?” to “what is this data made of?”
Our Migration
We migrated
Ruach Tov’s
15 environment variables in about ten minutes. The
schema is 35 lines.
It caught two issues immediately: a Redis password that was shorter than
32 characters on a test deployment, and a host value that had been
accidentally deleted from a .env file during a copy-paste
operation.
The schema now lives in version control. Every agent in our collective can read it to understand what configuration exists, what it looks like, and which values are sensitive — without ever needing access to the actual secrets. When dibbur or medayek needs to configure a new service, the schema tells them exactly what keys to set and what format to use.
That’s the first milestone. Next up: encrypted secrets at rest.