Skip to main content
Skip to main content

Environment Variables

Zerops manages environment variables at two scopes (project and service) with strict build/runtime isolation. Variables are set via zerops.yml, import.yml, or GUI. Project vars auto-inherit into every service — read them directly, no declaration. Cross-service (sibling) vars do NOT auto-inject under the default envIsolation=service — reference a sibling's value explicitly as ${hostname_varname} in run.envVariables (siblings are injected as bare <host>_KEY vars only when the owning service shares them via none or service@<reader-hostname>). Secret reads are privilege-gated (an admin token returns the value; a read-only token gets REDACTED). A running process keeps its boot-time env — restart it (not reload) to pick up a changed value.


Scope Hierarchy​

ScopeDefined InVisibilityEditable Without Redeploy
Projectimport.yml project.envVariables, GUIAll services (auto-inherited)Yes (restart to apply)
Service secretimport.yml envSecrets, GUISingle serviceYes (restart to apply)
Service basic (build)zerops.yml build.envVariablesBuild container onlyNo (redeploy required)
Service basic (runtime)zerops.yml run.envVariablesRuntime container onlyNo (redeploy required)

Variable Precedence​

Total order for the bare key (highest wins): system/platform > yaml-baked run.envVariables > service secret/userData > project.

  1. yaml-baked run.envVariables owns its key — a service secret/userData set on the same key is rejected (userDataDuplicateKey); the two never coexist. To change a yaml-baked value, edit zerops.yml and redeploy.
  2. Service-level wins over project-level.
  3. Build and runtime are separate environments -- same key can have different values in each.

DO NOT set a secret/service var on a key already declared in run.envVariables — the platform rejects it. The yaml var owns the key; edit the yaml and redeploy instead.

Build/Runtime Isolation​

Build and runtime run in separate containers. Variables from one phase are not visible in the other unless explicitly referenced with prefixes:

Want to accessFromHow
Runtime var API_KEYBuild container${RUNTIME_API_KEY} — runtime run.envVariables are known at build time, so the build can read them
Build var BUILD_IDRuntime containerNot available. The build container is destroyed after build and its vars are not carried into the runtime env store — ${BUILD_BUILD_ID} reaches the runtime process as the literal string ${BUILD_BUILD_ID}. Persist the value into a deployed file, or recompute it at runtime.
zerops:
- setup: app
build:
envVariables:
API_KEY: ${RUNTIME_API_KEY} # reads runtime API_KEY during build
run:
envVariables:
API_KEY: "12345-abcde"

Cross-Service References — Explicit ${hostname_varname} Required​

Under the default envIsolation=service, a service does NOT automatically see another service's variables — not even a managed database's connection vars. To use a sibling's value, reference it explicitly in run.envVariables; the left-hand key is the name your app reads, the right-hand ${hostname_varname} is the source:

run:
envVariables:
DB_HOST: ${db_hostname}
DB_PASS: ${db_password}
DATABASE_URL: ${db_connectionString}
CACHE_URL: ${cache_connectionString}
  • The reference resolves at container start, independent of isolation mode — the referenced var does not need to exist at definition time.
  • An unresolved ref stays literal (${db_hostname} reaches the process verbatim) — no error, no blank. A wrong hostname/var on the right-hand side becomes a literal string and the app fails at connect time.
  • Hostname charset: service hostnames are lowercase alphanumeric only ([a-z0-9]) — the platform rejects dashes, underscores, and uppercase with serviceStackNameInvalid. So a ref is simply ${hostname_varname} with the literal hostname (service cache → ${cache_port}); there is no dash-to-underscore rewrite to reason about, because a dashed hostname cannot exist.

Sibling vars are auto-injected as bare <host>_KEY OS env vars only when the owning service shares them, via none or a service@<reader-hostname> grant — see Isolation Modes. New projects are service; rely on explicit refs.

Cross-Service References in API vs Runtime​

Cross-service references (${hostname_varname}) are resolved at container start time, not at definition time:

  • zerops_discover with includeEnvs=true returns the literal template (e.g., ${db_password}), NOT the resolved value. The service-env API stores templates, not resolved values, and returns only the service's own user-set + intrinsic vars — yaml-baked run.envVariables come from the app-version, project vars from the project scope. Assemble across scopes (or read in-container) for the effective env.
  • Inside the running container, environment variables contain the actual resolved values.
  • Restarting a service does NOT change what zerops_discover returns — it always shows templates. To verify resolved values, check from inside the container.

Isolation Modes (envIsolation)​

envIsolation is a space-separated rule list set at project scope and optionally overridden per service (the service value replaces the project value, no merge). It is owner-side and directional: a service's own rules decide who sees ITS vars. The reader's rules never widen what the reader receives, so a compromised container cannot grant itself access to sibling secrets.

RuleBehavior
service (default)Siblings are isolated. A service sees only its own vars + project vars + the explicit ${hostname_varname} refs it declares in run.envVariables. Managed-service connection vars also require an explicit ref.
noneEvery service sees this service's vars as bare <host>_KEY OS env vars. Broad — prefer service@name for new projects.
service@nameThe service with hostname name additionally sees this service's vars as bare <host>_KEY vars.
-service@nameThe service with hostname name never sees this service's vars, even when none is present.

Evaluation for reader R against owner O's rules: -service@R → hidden, else service@R → visible, else none → visible, else hidden. A service always sees its own vars.

Set in import.yml at project or service level:

project:
envIsolation: service@zcp # zcp sees every service that inherits this value
services:
- hostname: db
envIsolation: none # per-service: expose THIS service's vars to all siblings
- hostname: api
envIsolation: service # per-service: opt out of the project grant, nobody sees api vars
- hostname: cache
envIsolation: none -service@api # everyone except api sees cache vars

Default (service) is the right choice. Wire cross-service explicitly with ${hostname_varname} — it works in every mode, so code stays correct if isolation ever changes. When one tooling or admin service needs broad read access, grant it with service@<hostname> at project level instead of switching to none.

Project Variables -- Auto-Inherited​

Project variables are automatically available in every service, in both runtime AND build containers — injected as OS env vars at container start. From application code, read them directly (process.env.API_URL). From zerops.yaml, reference them by name with ${VAR_NAME} — no RUNTIME_ prefix (that prefix is only for lifting a service's own runtime var into its build context).

In shell commands (buildCommands, initCommands, start) project vars are directly readable:

build:
buildCommands:
- echo "building for $API_URL" # shell reads the OS env var
- VITE_API_URL=$API_URL npm run build # or pass it forward by shell prefix

In build.envVariables / run.envVariables YAML (to forward a project var under a framework-conventional name) reference it directly without prefix:

build:
envVariables:
VITE_API_URL: ${API_URL} # project var API_URL read as-is
run:
envVariables:
CORS_ALLOWED_ORIGIN: ${FRONTEND_URL} # forwarded under a different name

To override a project variable for one service, define a service-level variable with the same key and a DIFFERENT VALUE (not a reference to the project var):

run:
envVariables:
LOG_LEVEL: debug # overrides project-level LOG_LEVEL for this service

Self-Shadow Trap — same name on both sides​

Writing varname: ${varname} (the left key identical to the source on the right) is always wrong:

run:
envVariables:
API_URL: ${API_URL} # project-level self-shadow
db_hostname: ${db_hostname} # cross-service self-shadow

The interpolator sees the service-level variable of that name first, can't recurse back to the inherited/referenced value, and the OS env var resolves to the literal string ${varname} — the app then connects to "${db_hostname}:5432" and crashes.

  • For a project var you only want to read: delete the line — it already auto-inherits.
  • For a cross-service value: use a different left-hand name (DB_HOST: ${db_hostname}), never the same name.

Typical pattern: project-level URL constants for dual-runtime recipes​

Dual-runtime recipes (frontend SPA + backend API) use project-level URL constants as the single source of truth for cross-service URLs, derived from ${zeropsSubdomainHost} (a platform-generated project-scope var present from project creation):

project:
envVariables:
API_URL: https://apistage-${zeropsSubdomainHost}-3000.prg1.zerops.app
FRONTEND_URL: https://appstage-${zeropsSubdomainHost}.prg1.zerops.app

The frontend consumes API_URL via plain ${API_URL} in build.envVariables (baked into the bundle at compile time). The API consumes FRONTEND_URL via plain ${FRONTEND_URL} in run.envVariables (CORS allow-list). Set the same names on the workspace project via zerops_env project=true action=set after provision.

Secret Variables​

  • Defined via GUI, import.yml envSecrets, or dotEnvSecrets
  • Read is privilege-gated -- masked in GUI; via API an admin/write token returns the value verbatim, a read-only token returns REDACTED (keyed on sensitive=true). In-container the value is plaintext (the app needs it). Project-level sensitive=true does NOT persist — only service-level is a true secret surface.
  • Can be updated without redeploy, but the service must be restarted to pick it up.
  • A yaml-baked run.envVariables key and a secret on the same key cannot coexist — the platform rejects the secret with userDataDuplicateKey. The yaml owns the key; edit the yaml and redeploy to change it.

dotEnvSecrets​

Import secrets in .env format within import.yml:

services:
- hostname: app
dotEnvSecrets: |
APP_KEY=generated_value
DB_PASSWORD=secure123

All entries become secret variables. Requires #zeropsPreprocessor=on if using generator functions.

envReplace -- File-Level Substitution​

Replaces placeholders in deployed files with environment variable values during deployment (not at runtime).

run:
envReplace:
delimiter: "%%"
target:
- ./config/
- ./templates/settings.json
ParameterRequiredDescription
delimiterYesWrapping characters (e.g., %% makes %%VAR%%). String or array
targetYesFiles or directories to process. String or array

DO NOT expect directory targets to recurse into subdirectories. ./config/ processes only files directly in config/, not config/jwt/. Specify each subdirectory explicitly.

Naming Restrictions​

Key: must match [a-zA-Z_]+[a-zA-Z0-9_]*. Case-sensitive. Must be unique within scope regardless of case.

Value: ASCII only. No EOL characters.

Restart Requirement​

An env-store change (secret or project) propagates to the container in ~5–10s without a redeploy, but the running process keeps its boot-time environ — only newly-spawned processes see it.

DO NOT expect hot-reload of env vars. After changing secrets or project vars, restart the service (not reload — reload does not re-read env for the running process; PHP-FPM keeps its boot config). For zerops.yml run.envVariables changes, a full redeploy is required (they are baked into the app version).

System-Generated Variables​

Zerops auto-generates variables per service (e.g., hostname, PATH, DB connection strings). Some are hard-reserved and rejected if you try to set them — PATH (uppercase) returns userDataUseOfSystemKey in any envVariables block. Overridable platform vars include envIsolation / sshIsolation / zeropsSubdomainHost (and the CDN URLs) — but never PATH. Reference any of them from another service with an explicit ${hostname_varname}.