dwe render env
Generate .env content from the merged config. Output goes to stdout by default; pass --out <path> to write to a file (parent directories are created).
Contents
Section titled “Contents”- Pipeline
- System variables
- Export rules
- Value resolution
- Truthiness
- Encrypted values and the marker guard
- Output format
- Worked example
- Common pitfalls
- Related references
Pipeline
Section titled “Pipeline”render env does not iterate services or read template files from disk. It walks the ordered list of export rules in the merged config and emits one line per rule.
flowchart TD
M["Merged config"] --> SYS["Emit system vars<br/>PROJECT, UID, GID,<br/>COMPOSE_PROJECT_NAME"]
SYS --> R{"For each rule in<br/>exports.env"}
R --> W["Evaluate when"]
W -- falsy --> R
W -- "truthy/absent" --> V["Resolve from<br/>via dot-path"]
V --> F["Format value<br/>by format hint"]
F --> O["Write line<br/>NAME=value"]
O --> R
R -- "end" --> OUT["stdout / file"]
The export rule list lives under exports.env in workspace/defaults.yml (see exports.env reference). The rule order in the YAML file determines the line order in the output.
System variables
Section titled “System variables”Four variables are emitted before any rule, regardless of exports.env. The first three are always present; COMPOSE_PROJECT_NAME is emitted whenever it resolves to a non-empty value:
| Variable | Source | Notes |
|---|---|---|
PROJECT | project.name from workspace.yml | The project name verbatim, including any uppercase. Used by Make targets and project-scoped naming |
UID | host UID — 1000 on macOS, the real host UID on Linux/WSL | Hard-coded 1000 on macOS because Docker Desktop runs containers in a Linux VM where host UIDs do not map directly |
GID | host GID — same platform logic as UID | Same rationale as UID |
COMPOSE_PROJECT_NAME | the compose project name dwe passes as -p | project_name from workspace/docker.yml (or docker.local.yml), otherwise <project.prefix>-<project.name>, always lowercased. Omitted when it resolves empty — dwe omits -p in that case too. A resolution error (a broken ${...} in project_name) fails the render |
COMPOSE_PROJECT_NAME is not PROJECT. PROJECT is the raw project.name; COMPOSE_PROJECT_NAME is the resolved, lowercased compose project name, which docker.yml can override outright. For project.name: cueBreaker with the default prefix the two read PROJECT=cueBreaker and COMPOSE_PROJECT_NAME=dwe-cuebreaker. It is the same value the type: shell command contract exports into a host command’s environment — one resolver, two delivery paths.
Because .env sits in the compose project directory, a raw docker compose … run from the project root now scopes to the same project as dwe, above any top-level name: in your compose file — see docker.md → project_name. Compose reads the value out of that file, so the alignment holds only from the project root and only while .env is current: with no generated .env, or with one written before a project_name change, compose falls back to its own naming and can pick a different project. dwe run regenerates it; dwe render env writes the file only with --out .env (bare, it prints to stdout).
The names PROJECT, UID, GID, and COMPOSE_PROJECT_NAME are reserved: any export rule that tries to use one of them as name is rejected at config-load time with a clear error. This applies to every command that loads the project config, not only dwe render env — so a typo is caught the first time you run any command after editing defaults.yml.
Export rules
Section titled “Export rules”Each rule maps a dot-path in the merged config to an env variable name. All per-service values — enabled, container, ports.<port-name>, hosts.<host-name> — are reachable under services.<name>.* regardless of type (app / tool / infra).
exports: env: - name: APP_PORT from: services.main.ports.http format: int
- name: APP_HOST from: services.main.hosts.web
- name: TOOL_ADMINER_ENABLED from: services.adminer.enabled format: bool
- name: ADMINER_PORT from: services.adminer.ports.http format: int when: services.adminer.enabled
- name: ADMINER_HOST from: services.adminer.hosts.web when: services.adminer.enabled
- name: APP_URL from: runtime.urls.app default: http://localhost comment: Public application URLRule fields
Section titled “Rule fields”| Field | Type | Required | Description |
|---|---|---|---|
name | string | yes | Env variable name written to .env |
from | string | yes | Dot-path navigated against the merged config (e.g. services.main.ports.http, services.main.container) |
default | string | no | Fallback when from resolves to nothing or to a falsy string (see Value resolution) |
required | bool | no | If true and from is absent and default is empty, rendering fails with an error |
format | string | no | One of string (default), bool, int — controls how the resolved value is rendered |
when | string | no | Dot-path; the rule is skipped entirely when this path resolves to a falsy value |
comment | string | no | Written as # comment on the line above the variable |
Evaluation order
Section titled “Evaluation order”For each rule, in source order:
whengate — ifwhenis set, resolve its dot-path against the merged config. If the value is falsy, skip the rule entirely (no line emitted, no comment).- Resolve
from— fetch the value at the dot-path. - Pick value — see Value resolution.
- Required check — if the path was absent and no
defaultis set andrequired: true, fail with an error naming the missing path. - Comment — if
commentis set, emit# <comment>on its own line. - Emit — write
<name>=<value>.
Unresolvable paths
Section titled “Unresolvable paths”A rule whose from: does not resolve, with no default: and no required: true, renders as a bare NAME= — an empty value that reaches every container as if it had been declared that way. dwe render env says so, once per rule, on stderr, before writing the content:
warning: exports.env[DB_PASSWORD]: from "vars.db.passwrod" does not resolve — rendered emptystderr keeps dwe render env > .env byte-identical, and --output json suppresses the warning. The other two shapes are not warned about here: with a default: the rendered value is the author’s own fallback, and required: true fails the render outright. dwe validate reports all three shapes as config.exports warnings (see validate.md), including an unresolvable when:, which this warning cannot cover — such a rule is skipped, so it renders no line at all.
Value resolution
Section titled “Value resolution”The picked value depends on three things: whether from resolved, the format hint, and the truthiness of the resolved value.
flowchart TD
A{"from resolves?"} -- no --> R{"required AND<br/>default empty?"}
R -- yes --> ERR["error"]
R -- no --> D["use default"]
A -- yes --> F{"format is bool<br/>or int?"}
F -- yes --> USE["use resolved value<br/>format applied"]
F -- no --> T{"resolved value truthy?"}
T -- yes --> USE
T -- no --> D
The asymmetry matters:
format: boolandformat: intalways use the resolved value, even when it isfalseor0. This guarantees thatTOOL_ADMINER=falseandPORT=0survive through to.envinstead of silently falling back to a default.format: string(the default) treats falsy resolved values as “not really set” and falls back todefault. So a YAML""atruntime.urls.appfalls through todefault: http://localhost.
format shapes the output:
| Format | Behavior |
|---|---|
string (default) | Stringify the resolved value as-is. |
bool | A boolean value is rendered as the literal true or false. Other types fall back to a plain stringification. |
int | The resolved number is stringified directly (YAML numbers are already int-like). |
Truthiness
Section titled “Truthiness”The same truthiness rule applies to both when and the string-format fallback:
| Value | Truthy? |
|---|---|
| absent path | no |
false | no |
0 (any numeric type) | no |
"" | no |
"false" | no |
"0" | no |
| anything else | yes |
Example: when: services.adminer.enabled skips the rule whenever the service is unset, explicitly false, or a string "false" / "0".
Dot-path syntax note: Export rule from: / when: fields use bare dot-paths into the merged config, not the {{ ... }} template syntax. Per-service values live under services.<name>.* for every type — e.g. from: services.adminer.ports.http, from: services.mailpit.hosts.web, from: services.main.container, when: services.adminer.enabled.
Encrypted values and the marker guard
Section titled “Encrypted values and the marker guard”The .env file is the one plaintext sink the container reads, and it is
gitignored and written 0600 (an existing looser file is tightened on every
write). A rule may therefore export an
encrypted vars.* value — the config loader decrypts it
in memory, so from: vars.telegram.token emits the plaintext exactly like any
other value.
When the value cannot be decrypted on this machine, render env fails
rather than publishing ciphertext as if it were the credential:
exports.env[TELEGRAM_TOKEN]: value at vars.telegram.token is an undecrypted secret — see 'dwe secrets status'The check covers every emitted value — the system variables (PROJECT comes
from project.name, COMPOSE_PROJECT_NAME from the compose project name)
and every export rule, including a value that
came from a rule’s default: rather than its from:. The guard lives here
rather than in preflight because none of the .env write paths run preflight:
dwe render env- the automatic regeneration before
dwe docker up/run/exec/restart/build dwe services enable/disable- the
.envrenderdwe runperforms before its own preflight — so on a keyless machine this is the first thingdwe runreports
All four fail identically and name the fix. See
secrets.md → Output guards.
Output format
Section titled “Output format”# Generated by dwe — do not edit manually
PROJECT=<project.name>UID=<host UID or 1000>GID=<host GID or 1000>COMPOSE_PROJECT_NAME=<lowercased compose project name># <comment from rule, if any><NAME>=<value>...A blank line follows the header banner, then system variables, then rules. The
system block is emitted in that fixed order; COMPOSE_PROJECT_NAME is dropped
from it when the name resolves empty.
When --out <path> is supplied:
- The path is interpreted relative to the current working directory, not the project root. Pass an absolute path if you want a deterministic location regardless of where the command is invoked from (for example, when running with
-c /path/to/workspace.ymlfrom a different directory). - Missing parent directories are created.
- The full content replaces any existing file (no merging, no comments preserved).
Worked example
Section titled “Worked example”workspace/services/main/service.yml:
type: appcontainer: app-mainrequired: truedir: ./services/mainports: http: 8080workspace/services/adminer/service.yml:
type: toolcontainer: adminerports: http: 8027workspace/defaults.yml:
services: adminer: enabled: trueruntime: urls: app: ""exports: env: - name: APP_PORT from: services.main.ports.http format: int - name: APP_URL from: runtime.urls.app default: http://localhost - name: TOOL_ADMINER from: services.adminer.enabled format: bool when: services.adminer.enabled - name: TOOL_REDIS from: services.redis_insight.enabled format: bool when: services.redis_insight.enabledworkspace.yml:
project: name: demodwe render env on macOS produces:
# Generated by dwe — do not edit manually
PROJECT=demoUID=1000GID=1000COMPOSE_PROJECT_NAME=demoAPP_PORT=8080APP_URL=http://localhostTOOL_ADMINER=trueWalk-through:
COMPOSE_PROJECT_NAME— noworkspace/docker.ymland noproject.prefix, so the compose name falls back toproject.name, lowercased. Withproject: {prefix: dwe, name: Demo}the two lines would readPROJECT=DemoandCOMPOSE_PROJECT_NAME=dwe-demo.APP_PORT—format: int, value8080, emitted directly.APP_URL—fromresolves to empty string (falsy underformat: string), sodefaultis used.TOOL_ADMINER—whenresolves truthy, valuetruerendered as literaltrue.TOOL_REDIS—whenresolves to absent (noredis_insightentry), rule skipped, no line emitted.
Common pitfalls
Section titled “Common pitfalls”format: stringswallowsfalse/0/""— if you need a literalfalseor0in the output, pickformat: boolorformat: int. Otherwise the value silently falls through todefault(or to an empty string).whenandfromare independent dot-paths —whendoes not have to point at the same key asfrom. Use it to gate one variable on another setting (e.g.from: services.second.container,when: services.second.enabled).required: truewithoutdefault— produces a hard error if the path is absent. Use it for variables your runtime cannot start without; otherwise rely ondefaultto keep the file complete.- Editing
.envby hand — the file is regenerated bydwe render env --out .envand by lifecycle hooks. Editworkspace/defaults.ymlexports orworkspace/local.ymloverrides instead. --outhas no short form. The flag is spelled--out; there is no-oalias. Usedwe render env --out .env.- A value spanning multiple lines is refused. Values are written unquoted, so compose would read the second and later lines as further
.enventries: the value arrives truncated, and a line shaped likeNAME=…defines a variable nobody declared.dwe render envfails and names the source path instead. Deliver a PEM key or a service-account blob through arender configpack file, which supports.agesources natively. See Single-line values only. - Redeclaring
PROJECT/UID/GID/COMPOSE_PROJECT_NAME— these names are reserved and validated at config load. An export rule that uses one of them produces a hard error when any command loads the project config; remove the rule and reach the same value via the system variable instead. Set the compose project name throughproject_nameinworkspace/docker.yml, which is what the emitted line follows.
Related references
Section titled “Related references”exports.envrule schema — full field reference, formats- Dot-path resolution — how
fromandwhenpaths navigate the merged config - secrets — encrypted
vars.*values, keys, and why an undecrypted one is a hard error here - Run
dwe render env --helpfor the live CLI surface