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. Both project-level vars AND cross-service vars (${hostname_varname}) auto-inject as OS env vars into every container in the project — no declaration required. run.envVariables exists only for mode flags and framework-convention renames. Re-declaring an auto-injected var under its own name creates a literal-string self-shadow. Secret vars are write-only after creation. Changes require service restart.


Scope Hierarchy

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

Variable Precedence

When the same key exists at multiple levels:

  1. Service basic (build/runtime) wins over service secret
  2. Service-level wins over project-level
  3. Build and runtime are separate environments -- same key can have different values in each

DO NOT create a secret and a basic runtime variable with the same key expecting both to persist. The basic runtime variable from zerops.yml silently overrides the secret.

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 accessFromUse prefix
Runtime var API_KEYBuild container${RUNTIME_API_KEY}
Build var BUILD_IDRuntime container${BUILD_BUILD_ID}
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 — Auto-Injected Project-Wide

Every service's variables are automatically injected as OS environment variables into every other service's containers — both runtime and build. A worker container sees db_hostname, db_password, queue_user, storage_apiUrl, etc. as real OS env vars at container start. Zero declaration in zerops.yml required.

Read them directly in application code:

// Node — lowercase native names match the platform
const host = process.env.db_hostname;
const pwd = process.env.db_password;
const natsUser = process.env.queue_user;
// PHP
$host = getenv('db_hostname');

run.envVariables and build.envVariables have two legitimate uses only:

  1. Mode flags — per-setup values that don't come from another service:

    run:
    envVariables:
    NODE_ENV: production
    APP_ENV: local
  2. Framework-convention renames — forward a platform var under a different name because the framework config expects it. The key on the left MUST DIFFER from the source var name on the right:

    run:
    envVariables:
    DB_HOST: ${db_hostname} # TypeORM expects uppercase DB_HOST
    DATABASE_URL: ${db_connectionString}

Do NOT re-declare auto-injected vars under their own name. It is always wrong and never useful:

run:
envVariables:
db_hostname: ${db_hostname} # SELF-SHADOW — see next section
db_password: ${db_password} # SELF-SHADOW
queue_hostname: ${queue_hostname} # SELF-SHADOW
STAGE_API_URL: ${STAGE_API_URL} # SELF-SHADOW (project-level variant)

The referenced variable does not need to exist at definition time — Zerops resolves at container start.

Self-Shadow Trap

Writing varname: ${varname} in run.envVariables creates a literal-string self-shadow. The platform's interpolator sees the service-level variable of that name first, can't recurse back to the auto-injected value, and the resolved OS env var becomes the literal string ${varname}:

run:
envVariables:
db_hostname: ${db_hostname} # OS env: db_hostname='${db_hostname}' (literal)
db_password: ${db_password} # OS env: db_password='${db_password}' (literal)

At runtime, the worker tries to connect to "${db_hostname}:5432" and crashes. The fix is to delete the entire block — those vars are already in the container's env without any declaration.

This applies identically to project-level vars (${STAGE_API_URL}, ${APP_SECRET}) and cross-service vars (${db_hostname}, ${queue_user}) — both auto-propagate, both self-shadow under the same rule.

Hostname transformation: dashes become underscores. Service my-db variable port is ${my_db_port}.

Cross-Service References in API vs Runtime

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

  • zerops_discover with includeEnvs=true returns the literal template (e.g., ${db_password}), NOT the resolved value. This is expected — the API stores templates, not resolved values.
  • 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 (e.g., via SSH or application endpoint).

Isolation Modes (envIsolation)

envIsolation does NOT control whether cross-service vars auto-inject — they do, in every mode. It controls something narrower: how ${hostname_varname} templates inside zerops.yml and import.yml resolve during platform interpolation.

ModeBehavior
service (default)Service-scoped: ${hostname_varname} templates inside that service's YAML resolve by following the hostname prefix. The OS env in every container still contains every other service's vars as auto-injected keys.
none (legacy)Cross-service references can be written without the ${hostname_varname} prefix (e.g. ${password} resolves to the nearest match). Do not use for new projects — ambiguous, error-prone.

Set in import.yml at project or service level:

project:
envIsolation: none # legacy — avoid
services:
- hostname: db
envIsolation: none # legacy — avoid

Default (service) is the right choice. The auto-inject behavior above applies under the default.

Project Variables -- Auto-Inherited

Project variables are automatically available in every service, in both runtime AND build containers. The platform injects them as OS env vars at container start in every service's runtime container and also in every service's build container during the build phase. From zerops.yaml's point of view they are referenced directly by name with ${VAR_NAME}no RUNTIME_ prefix in either scope. The RUNTIME_ prefix is reserved for a different use case: lifting a single service's service-level runtime variable into that same service's build context. Project-scope vars are broader than service-scope and do not need lifting.

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

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

In build.envVariables YAML (to compose a derived var that the bundler consumes) reference the project var directly without prefix:

build:
envVariables:
VITE_API_URL: ${STAGE_API_URL} # project var STAGE_API_URL read as-is, NO RUNTIME_ prefix

In run.envVariables YAML (to forward a project var under a framework-conventional name without creating a shadow), reference directly without prefix:

run:
envVariables:
FRONTEND_URL: ${STAGE_FRONTEND_URL} # project var STAGE_FRONTEND_URL forwarded as FRONTEND_URL

DO NOT re-reference an auto-injected variable under its SAME name — that's a self-shadow loop. Applies to BOTH project-level vars AND cross-service vars:

envVariables:
PROJECT_NAME: ${PROJECT_NAME} # project-level self-shadow
STAGE_API_URL: ${STAGE_API_URL} # project-level self-shadow
db_hostname: ${db_hostname} # cross-service self-shadow
queue_user: ${queue_user} # cross-service self-shadow

All four resolve to the literal string ${VAR_NAME} inside the container — the framework tries to connect to "${db_hostname}:5432" and crashes. The fix is to delete those lines entirely — the platform already injects the real value as an OS env var.

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

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

Dual-runtime recipes (frontend SPA + backend API on the same platform) use project-level URL constants as the single source of truth for cross-service URLs. The constants are derived from ${zeropsSubdomainHost} (a platform-generated project-scope env var present from project creation) and the services' known hostnames:

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

The platform resolves ${zeropsSubdomainHost} when injecting the value into services at container start. The frontend consumes STAGE_API_URL via plain ${STAGE_API_URL} in build.envVariables (baking it into the bundle at compile time) — no RUNTIME_ prefix. The API consumes STAGE_FRONTEND_URL via plain ${STAGE_FRONTEND_URL} in run.envVariables (for CORS allow-list). The same names must be set on the workspace project via zerops_env project=true action=set after provision, so workspace verification doesn't see literal ${STAGE_FRONTEND_URL} strings.

Secret Variables

  • Defined via GUI, import.yml envSecrets, or dotEnvSecrets
  • Write-only after creation -- values masked in GUI, cannot be read back via API
  • Can be updated without redeploy, but service must be restarted
  • Overridden by basic (zerops.yml) variables with the same key

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

Env var changes (secret or project) take effect only on container start. The running process does not receive updated values.

DO NOT expect hot-reload of env vars. After changing secrets or project vars in GUI, restart the service. For zerops.yml envVariables changes, a full redeploy is required.

System-Generated Variables

Zerops auto-generates variables per service (e.g., hostname, PATH, DB connection strings). Cannot be deleted. Some read-only (hostname), others editable (PATH). Can be referenced by other services using ${hostname_varname}.

Common Mistakes

  • DO NOT re-reference auto-injected vars under their own name — self-shadow loop. Applies to BOTH project-level (STAGE_API_URL: ${STAGE_API_URL}) AND cross-service (db_hostname: ${db_hostname}, queue_user: ${queue_user}).
  • DO NOT declare cross-service vars you only want to READ — they are already in the container's OS env. Read via process.env.db_hostname / getenv('db_hostname') directly. Declare in run.envVariables only to RENAME (e.g. DB_HOST: ${db_hostname}) or to set mode flags.
  • DO NOT forget restart after GUI/API env changes — process won't see new values
  • DO NOT expect envReplace to recurse subdirectories — it does not
  • DO NOT rely on reading secret values back — they are write-only after creation
  • DO NOT create both secret and basic vars with same key — basic silently wins