№ 13: Our First Varlock Migration

From naked .env to schema-validated, type-checked configuration — and why type texture matters more than string matching.

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 env or reads /proc/self/environ and your API keys end up in a log file, a context window, or a training dataset.
  • Someone pastes REDIS_PORT=notanumber and 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:

  1. 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.
  2. Leak prevention. Values marked @sensitive are automatically redacted in logs and process listings. Varlock shows sk█████ instead of your actual key.
  3. Agent visibility. AI agents can read .env.schema to understand what keys exist, what types they are, and what constraints they must satisfy — without ever seeing actual secret values.
  4. Self-documenting configuration. The schema is the documentation. No more .env.example files 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:

AnnotationMeaning
@requiredStartup fails if missing or empty
@sensitiveValue is redacted in output
@sensitive=falseExplicitly non-secret (shown in clear)
@type=string(startsWith=...)String prefix validation
@type=string(minLength=N)Minimum length enforcement
@type=portInteger 0–65535
@type=urlValid 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:

  1. Sensitive values show only their first two characters, then █████.
  2. Non-sensitive values with @sensitive=false display in full.
  3. Optional values that are undefined show undefined but still pass — only @required values 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:

  1. Varlock with local encrypted secrets. Secrets encrypted at rest, decrypted only into process memory at startup. For single-node and small-team deployments.
  2. 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-name means the value depends on the local /etc/hosts file — it won’t resolve on a fresh machine.
  • A dns-fqdn means there’s a DNS dependency — the service might fail during DNS outages, but can failover to different IPs.
  • An ip-dotted-quad means 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.