Environment and modes
stackctl loads .env files for every service it runs and injects them into that service's process. You don't call dotenv anywhere. Parsing is done by dotenvx, so ${VAR} references expand.
Which files load
Files are read at two scopes, the workspace root and the service's own directory:
your-monorepo/
├── .env # workspace: every service
├── .env.local
├── .env.development
└── apps/
└── api/
├── .env # service: only api, overrides the workspace's
└── .env.localAt each scope, in this order, each overriding the one before:
.env.env.local.env.<mode>.env.<mode>.local
Only files that exist are read. Commit .env and .env.<mode>, and keep .env*.local out of git for personal overrides.
Which value wins
From lowest to highest:
env.vars, variables set in the config.- The workspace's files.
- The service's files.
- The environment stackctl was started with.
LOG_LEVEL=debug sctloverrides every file for one run, as in Vite, Next.js andnode --env-file.
To let a file win over the shell for some variables (a NODE_OPTIONS that extends the inherited one, say), name them in env.override. To load files of your own between stackctl's, use env.additionalFiles.
Modes
A mode picks the .env.<mode> files, and gives the session its own state.
sctl # development
sctl --mode test # .env.test and .env.test.local- Without
--mode, the mode isSTACKCTL_MODEif set, elsetestin CI (CIset and notfalseor0), elsedevelopment. - Any name works:
test,staging,e2e. - Modes are exclusive.
--mode testdoes not read.env.development. Put shared values in.env. - One session per mode. A
testsession runs next to yourdevelopmentone, each with its own processes, infra containers and state undernode_modules/.stackctl/modes/<mode>/. Give each mode its own ports in its.env.<mode>. NODE_ENVis left alone. Set it in the mode's file if you want it:NODE_ENV=testin.env.test.
Starting a second session in the same mode takes over the first.
Variables stackctl sets
Every process stackctl starts gets these:
| Variable | Value |
|---|---|
PORT | The service's port. |
PUBLIC_ORIGIN | Where a browser reaches the service. See URLs and origins. |
STACKCTL_MODE | The active mode. |
STACKCTL_WORKSPACE_DIR | The workspace root: the directory holding stackctl.config.ts. |
STACKCTL_CWD | The directory stackctl was invoked in. |
NODE_EXTRA_CA_CERTS | With the proxy's tls: true: its certificates. |
PORT and PUBLIC_ORIGIN win over a value set workspace-wide. Only the service's own .env file overrides them.
The three STACKCTL_* variables are set before the config loads, so a .env file can reference them (UPLOADS=${STACKCTL_WORKSPACE_DIR}/.data), and config callbacks find them in envs.
Live reload
While a session runs, stackctl watches the .env files, and the places new ones would appear. When a file's content changes, the services that read it restart: a workspace file restarts every running service, a service's file only that one. Symlinked files are followed. A write that changes nothing (a touch) restarts nothing.
Values the config computed are the exception: env.vars and callbacks such as port run once, when stackctl starts. Restart stackctl to pick those up.
The same environment anywhere
sctl execandsctl runrun one command with a service's environment.resolveEnv()returns it to a script.sctl show-configlists, underenvFiles, the files a mode looks for. Check there when a variable is missing.
Details
${VAR}expands to the value that wins for that variable: the shell's, or an already-loaded file's when the shell has none orenv.overridenames it.- A file that can't be read yet holds the services, not the session. macOS, for one, blocks a read of a Dropbox or iCloud folder until you allow access. The services show as
waiting, the log names the file, and the session still answers its control API. - Nested stackctl. A process stackctl started also gets
STACKCTL_INJECTED_ENV, the names of the variables stackctl gave it, andSTACKCTL_REPLACED_ENV, the shell's values of those it changed. A stackctl started from that process (an e2e suite that callsstartStack(), say) drops the first and restores the second, so its own.envfiles load as from the shell the outer one was started in. It stays in the outer mode unless--modesays otherwise.