Environment Variable Management
This document explains the environment variable model and the reasoning behind it. For concise syntax reference, see Configuration and Services.
This document explains the environment variable model and the reasoning behind it. For concise syntax reference, see Configuration and Services.
Goals
Zapper should make local development environment variables easy to share across services without forcing users to copy the same values into many places.
The original model is still sound:
- Load environment variables from a central source.
- Treat that source as the local source of truth.
- Decide what each service receives.
The problem is that explicit routing is too much ceremony for many local projects. Zapper should support careful routing, but the common path should be small enough that most projects can understand it at a glance.
Recommended Model
Use one field: env.
At the root level, env defines the global environment file stack:
env: [.env.local, .env.user]Root-level env_files remains as a compatibility alias:
env_files: [.env.local, .env.user]At the service level, env chooses how that service receives environment
variables:
env: "*" # Pass all values from the global env stack
env: [.env.api] # Replace global env with this service file stack
env: api.env.yaml # Route global env through this strict whitelist fileThere is no inline whitelist array in zap.yaml. Arrays in zap.yaml are file
stacks. Variable allowlists live only in external whitelist files.
This gives Zapper one concept with three levels of power:
env: "*"for the default local developer.env: [files...]for the power user who wants direct file assignment.env: whitelist.yamlfor the large-team user who needs central storage plus explicit routing.
Resolution Rules
Root env and root env_files both mean "load these environment files as the
global stack." If both are present, Zapper rejects the config instead of
guessing which one wins.
Service-level env resolves as follows:
env: "*"passes every value from the resolved global env stack.env: [files...]loads those files for that service and exposes every value from that service stack. This replaces the global stack for that service.env: path/to/whitelist.yamlloads a strict whitelist file and exposes only the listed variables from the global env stack.- Missing
envmeans no Zapper-managed env for that service.
The service file-stack rule is an override, not a merge. That keeps precedence straightforward:
- Root
envdefines the default source. - Service
env: "*"uses the default source. - Service
env: [files...]replaces the default source. - Service
env: whitelist.yamlfilters the default source.
Generated Zapper values, such as assigned ports, are part of the resolved
environment source before either * or whitelist filtering is applied.
Persona Stress Test
The model is useful only if it handles the common case without ceremony and still has credible answers for more demanding setups.
Persona 1: Default Local Developer
This user has a few services and a manageable number of variables. Most values are local-only coordination values: ports, local URLs, feature toggles, and container credentials that are not meaningful outside the dev machine.
They want:
- One obvious place to put shared values.
- One gitignored place to put personal overrides.
- No per-service env bookkeeping.
They should use root env and service env: "*":
project: myapp
env: [.env.local, .env.user]
native:
frontend:
cmd: pnpm dev
env: "*"
backend:
cmd: pnpm dev
env: "*"
docker:
postgres:
image: postgres:15
env: "*"This is intentionally permissive. It is the right default because local dev often values alignment more than isolation. The star is visible enough to signal broad access.
How the model handles it:
- Strong fit.
- Small
zap.yaml. - No variable duplication.
- No extra routing files.
- Easy migration path if one service later needs a custom file stack.
Persona 2: Security-Conscious Power User
This user has enough secrets that they do not want every service receiving the same gitignored user file. They also prefer ordinary env files over Zapper-owned whitelist policy.
They want:
- Direct file assignment per service.
- Shared non-sensitive files where useful.
- Separate user secret files for sensitive services.
- No central whitelist registry in
zap.yaml.
They should use service-level file stacks:
project: myapp
native:
frontend:
cmd: pnpm dev
env: [.env.common, .env.frontend, .env.frontend.user]
backend:
cmd: pnpm dev
env: [.env.common, .env.db, .env.backend, .env.backend.user]
worker:
cmd: pnpm worker
env: [.env.common, .env.db, .env.worker, .env.worker.user]This is direct file assignment. The service's env array is the source stack
for that service, and all values from that stack are exposed to that service.
How the model handles it:
- Strong fit.
- Security boundaries are represented by file boundaries.
zap.yamlstays readable because it names file stacks rather than individual variables.- The main weakness is that file composition becomes the routing system. If the project grows to hundreds or thousands of variables, this can become hard to maintain.
Persona 3: Large-Team Platform Owner
This user has a large environment surface, possibly hundreds or thousands of variables. Many values need to line up across services. Copying variables into service-specific files would be risky and tedious.
They want:
- Central env files as the source of truth.
- Explicit routing so services receive only what they need.
- Routing policy outside the core service config.
- A strict schema for routing files.
They should use a global stack and service-level whitelist files:
project: enterprise-app
env: [.env.company, .env.local, .env.user]
native:
frontend:
cmd: pnpm dev
env: .zap/env/frontend.yaml
backend:
cmd: pnpm dev
env: .zap/env/backend.yaml
worker:
cmd: pnpm worker
env: .zap/env/worker.yamlWith .zap/env/backend.yaml:
vars:
- DATABASE_URL
- REDIS_URL
- JWT_SECRETThis is the most complex setup, but it earns that complexity. The environment values remain centralized, while routing policy moves into dedicated files that can be reviewed separately from service definitions.
How the model handles it:
- Good fit for very large projects.
- Avoids copying values across service files.
- Keeps
zap.yamlfrom being polluted by long whitelist definitions. - The complexity is isolated to projects that actually need it.
Whitelist Files
A service string other than * should be interpreted as a whitelist file path,
not a named whitelist embedded in zap.yaml.
Whitelist files have a strict schema:
vars:
- DATABASE_URL
- REDIS_URL
- JWT_SECRETRules:
- The top level must be an object.
varsmust be an array of non-empty variable names.- Unknown keys are rejected.
*is not a valid whitelist file path or variable name.- Whitelist files require a global env stack. If root
envorenv_filesis missing, serviceenv: some-whitelist.yamlerrors because there is no central source to filter.
The last rule is important: a whitelist does not load values. It only selects values from the global env source.
Weird Cases
Root env and Root env_files
Invalid:
env: [.env.local]
env_files: [.env.local]Both fields mean the same thing. Supporting both at once creates unnecessary precedence rules, so this should be a validation error.
Service env: "*" Without Global Env
Valid, but usually empty unless generated values such as assigned ports exist:
native:
api:
cmd: pnpm dev
env: "*"Because * means "all currently available values", this produces an empty env
when there is no root env source and no generated values. Whitelist files are
stricter: they require a root env source because they filter central values.
Service File Stack With Global Env
Valid:
env: [.env.local, .env.user]
native:
api:
cmd: pnpm dev
env: [api/.env.local, api/.env.user]The service stack replaces the global stack for this service. It does not merge with the global stack. Users who want shared values can include the shared file directly:
native:
api:
cmd: pnpm dev
env: [.env.common, api/.env.local, api/.env.user]Inline Variable Arrays
Invalid:
env:
- DATABASE_URL
- JWT_SECRETThere is no inline whitelist array in zap.yaml. A service env array is a
file stack, so entries are interpreted as file names or paths. Zapper accepts
ordinary file names such as .env.something and service-env, but rejects
entries that look like uppercase variable names such as DATABASE_URL.
Explicit variable routing belongs in a whitelist file:
native:
api:
cmd: pnpm dev
env: .zap/env/api.yamlWith .zap/env/api.yaml:
vars:
- DATABASE_URL
- JWT_SECRETMixing File Stacks and Whitelist Files
Invalid:
native:
api:
cmd: pnpm dev
env: [.env.common, .zap/env/api.yaml]An env array is a file stack. A string is a whitelist file path. Mixing those
concepts in one value makes resolution unclear and should be rejected.
Why This Is the Middle Ground
This is technically three capabilities, but only one needs to be common:
- Common path: root
env, serviceenv: "*". - Power-user path: service
env: [files...]. - Large-team path: root
env, serviceenv: whitelist.yaml.
The benefit is that all three use one field and one idea:
- Root
envdefines the default source. - Service
envdefines how the service receives env. *is shorthand for "all values from the default source."- A service file stack is an explicit source override.
- A whitelist file filters the default source.
Current State
The implemented model is:
- Most projects use root
envand serviceenv: "*". - Projects with existing env-file conventions use service
env: [files...]. - Security-conscious large projects use service
env: whitelist.yaml. - Root
env_filesremains a compatibility alias. - Inline
whitelistsand inline service variable arrays disappear from the core YAML spec.
Project Roots
This is an internal note for reasoning about Zapper project identity, especially for the system registry.
Local Runtime Compatibility
This document tracks how Zapper should be distributed, why we are making the current packaging choices, and what options remain on the table to make Zapper work