Same machine, three environments side by side. Flip between dev, staging, and prod with one command — no docker-network gymnastics, no port collisions, no duplicate manifests.
Level: 4 · Reading time: 15 min
tpl.env template alongside your .env (created by asd env init).Already know this rung? Skip to Level 5 — authentication where you add login to one of your services.
You want to run the same project as both dev and prod on the
same host, with different ports, different subdomains, different
database URLs. The naive answer is "copy .env to .env.prod and
hope nothing collides". asd has a first-class solution: modes.
You declare available modes in asd.yaml, write per-mode values in
tpl.env with a _mode_<name>_ prefix, then asd env apply renders
the right .env.<mode> file. asd mode set <name> flips the active
mode. Services pick up the new values on next asd up / asd net apply.
1. Declare the modes in asd.yaml:
# asd.yaml (additions only)
project:
name: hello-project
env_modes:
available: [dev, prod]
default: dev
2. Write per-mode values in tpl.env:
# tpl.env
# Shared (no mode prefix) — same value in every mode
APP_NAME=hello
# Per-mode — _mode_<name>_KEY
_mode_dev_PORT=3000
_mode_dev_LOG_LEVEL=debug
_mode_dev_DB_URL=postgres://localhost:5432/hello_dev
_mode_prod_PORT=8000
_mode_prod_LOG_LEVEL=info
_mode_prod_DB_URL=postgres://localhost:5432/hello_prod
3. Render .env.dev (or initialise on first run):
$ asd env apply
✓ Rendered .env.dev (3 mode keys + 1 shared)
4. Update the service manifest to use the right port:
# packages/hello/net.manifest.yaml — endpoint now reads PORT
endpoint:
url: "http://127.0.0.1:${{ env.PORT }}"
caddy:
hostRoute:
host: "hello-${{ env.MODE }}-${{ macro.tunnelClientId() }}.${{ macro.tunnelEndpoint() }}"
${{ env.MODE }} resolves to the active mode (dev or prod),
giving each environment its own subdomain.
5. Switch + apply:
$ asd mode set prod
✓ Active mode: prod
$ asd env apply
✓ Rendered .env.prod (3 mode keys + 1 shared)
$ asd net apply
✓ Apply complete: 1 seeded, 1 routes (hello-prod-xyz1.…)
To go back: asd mode set dev && asd env apply && asd net apply.
asd mode show always tells you what's active:
$ asd mode show
active: prod
source: project (.asd/state/active_mode)
The URL changes with the mode:
$ asd urls
hello
tunnel: https://hello-prod-xyz1.eu2.tn.example.com
direct: http://127.0.0.1:8000
Each mode keeps its own .env.<mode> file in the project, so dev
secrets never bleed into prod. The shared keys (no _mode_ prefix)
appear in every rendered .env.<mode> unchanged.
To run both modes simultaneously, you need two project clones (one
per active mode) — asd mode is per-project, not per-shell.
Three layered ideas:
tpl.env is the truth, .env.<mode> is the artefact. You
never edit .env.dev or .env.prod directly — they're rendered
from tpl.env. To change a value, edit tpl.env and re-run
asd env apply. The renderer takes the keys whose prefix matches
the active mode (_mode_dev_PORT → PORT in .env.dev) and
passes shared keys through unchanged.
Mode + manifest = isolation. Because the manifest reads PORT
and MODE through ${{ env.X }}, the same manifest produces
different routes per mode. No if mode == 'prod' branches —
the templating layer handles it.
asd mode set is the only mutation. Changing the active mode
writes to .asd/state/active_mode; everything downstream
(env apply, net apply, up) reads it. This makes
"what mode am I in?" a single source of truth — no shell env vars
to forget to export, no per-terminal drift.
The full picture: tpl.env → (mode-aware render) → .env.<mode> →
(manifests + Caddy macros expand) → routes + tunnels per mode.
.env into tpl.env?" → asd env sync does a three-way merge that preserves your edits.reference/cli/env-mode for the full env/mode command list.