Environment variables
defineEnv describes the variables the server needs.
defineEnv describes the variables the server needs. createHandler({ env }) validates them once at startup, from
process.env unless you pass envSource, so a missing secret stops the server before it serves a request. Values
reach code as event.env and as handler.env?.all; handler.env?.public holds only the DUSK_PUBLIC_* ones.
type |
Accepts | Options |
|---|---|---|
string |
any text | minLength, maxLength, pattern |
number |
a finite number | integer, min, max |
boolean |
true, false, 1, 0, yes, no, on, off in any case |
none |
url |
a URL; the value is the normalized href |
protocols (default ["http:", "https:"]) |
enum |
one of values |
values |
Every field also takes default (used when the variable is missing or empty), required: false (the value is
undefined when missing) and secret: true. A field with none of these is required. The names must be identifiers,
and a variable whose name starts with DUSK_PUBLIC_ cannot be secret: defineEnv throws, because that prefix means
"any visitor may read this".
import { defineEnv } from "@shadow-js/dusk/server";
export const env = defineEnv({
SESSION_SECRET: { type: "string", minLength: 32, secret: true },
DATABASE_URL: { type: "url", protocols: ["postgres:"], secret: true },
LOG_LEVEL: { type: "enum", values: ["debug", "info", "warn"], default: "info" },
RATE_LIMIT: { type: "number", integer: true, min: 1, default: 60 },
MAINTENANCE: { type: "boolean", default: false },
DUSK_PUBLIC_API_URL: { type: "url" },
});
Env files while you develop#
Server code reads its settings from process.env. In production the platform sets them, and serve() does not read a
file: it uses process.env as it finds it (start it with node --env-file=.env.production dist/serve.mjs if you keep a
file next to the server). While you develop, dusk() fills process.env from the env files of the mode, and the real
environment always wins over every file. The files, from the lowest precedence to the highest, are:
.env.publicand.env.[mode].public: the values the browser may read. Commit them (see below)..env, then.env.[mode],.env.local, and.env.[mode].local: server values, each overriding the earlier ones..env.public.local: a local override of a public value. The scaffold ignores it with the other.env*.localfiles.
This is Dusk's order, and it differs from Vite's: Vite puts .env.local below .env.[mode], Dusk puts it above, so a
local file always beats a mode file. ${OTHER} references are expanded. The same files apply when dusk build prerenders
pages, because that runs your server code in the build process. A variable that is already set wins over every file, so a
value given on the command line or by the platform is never replaced, and NODE_ENV is never taken from a file.
The files feed the server, and the browser gets only the public names, inlined at build time, so a secret in .env cannot
reach a bundle. Keep .env.local and any file with secrets out of version control. loadServerEnv, readEnvFiles and
applyServerEnv from @shadow-js/dusk/vite are the functions the plugin uses.
import { mkdtempSync, rmSync, writeFileSync } from "node:fs";
import { tmpdir } from "node:os";
import { join } from "node:path";
import { applyServerEnv, loadServerEnv } from "@shadow-js/dusk/vite";
const dir = mkdtempSync(join(tmpdir(), "shadow-env-doc-"));
writeFileSync(join(dir, ".env"), "DOC_A=base\nDOC_B=base\nDOC_C=base\nDATABASE_URL=postgres://localhost/app\nDUSK_PUBLIC_API=ok\n");
writeFileSync(join(dir, ".env.development.local"), "DOC_B=mode-local\n");
const target: Record<string, string | undefined> = { DOC_C: "from the shell" };
const applied = applyServerEnv(loadServerEnv("development", dir), target);
rmSync(dir, { recursive: true, force: true });
console.log([target["DOC_A"], target["DOC_B"], target["DOC_C"]].join("|"));
console.log(applied.filter((name) => name === "DATABASE_URL" || name === "DUSK_PUBLIC_API").join(","));
console.log(`DUSK_PUBLIC_API=${target["DUSK_PUBLIC_API"]}`);
base|mode-local|from the shell
DATABASE_URL,DUSK_PUBLIC_API
DUSK_PUBLIC_API=okPublic values in .env.public
A name is public when its prefix is DUSK_PUBLIC_ (in any file, or in the environment), when env.public.prefixes in
dusk.config.ts lists its prefix, or when it is declared in .env.public, .env.[mode].public or .env.public.local,
whatever its name. The browser gets the value of each public name where client code reads it, as process.env.NAME or
import.meta.env.NAME, and in the whole import.meta.env object.
Public values are checked when the build starts:
- A name declared in
.env.publicand also in a server file (.env,.env.local...) fails the build with[ENV_PUBLIC_CONFLICT]: its visibility would depend on which file wins. Keep it in one file. - A public value that looks like a credential fails the build with
[ENV_PUBLIC_VALUE]: a live or test secret key (sk_live_,sk_test_), a private key block, a password inside a URL, or a JWT. The message names the variable and its file, never the value. If the value is public on purpose, list the name inenv.public.allow. - A name that reads like a secret (
DUSK_PUBLIC_API_SECRET) only warns with[ENV_PUBLIC_NAME], because a publishable key has such a name too. The value is what the build checks.
import { defineConfig } from "@shadow-js/dusk/vite";
export default defineConfig({
env: { public: { allow: ["DUSK_PUBLIC_PREVIEW_LINK"] } },
});
The build also writes src/env.public.d.ts, which declares each public name for import.meta.env and process.env. It
holds names only, never values, and dusk dev and dusk build rewrite it, so do not edit it. With the file in place,
import.meta.env.SITE_URL is a string in your editor, and a misspelt name is a type error.
import { mkdtempSync, rmSync, writeFileSync } from "node:fs";
import { tmpdir } from "node:os";
import { join } from "node:path";
import { checkEnvFiles, publicEnvNames, readEnvFiles } from "@shadow-js/dusk/vite";
const dir = mkdtempSync(join(tmpdir(), "shadow-public-env-doc-"));
writeFileSync(join(dir, ".env.public"), "SITE_URL=https://site.example\n");
writeFileSync(join(dir, ".env"), "DATABASE_URL=postgres://localhost/app\nDUSK_PUBLIC_FLAG=on\n");
const files = readEnvFiles("development", dir);
rmSync(dir, { recursive: true, force: true });
checkEnvFiles(files, ["DUSK_PUBLIC_"]);
console.log(files.publicNames.join(","));
console.log(publicEnvNames(Object.keys(files.values), files.publicNames, ["DUSK_PUBLIC_"]).join(","));
SITE_URL
DUSK_PUBLIC_FLAG,SITE_URLWhat the browser can read#
The browser gets only what Vite inlines at build time from variables with an allowed prefix, as
import.meta.env.DUSK_PUBLIC_API_URL. The names are the ones with the prefix DUSK_PUBLIC_ (plus any envPrefix you set in the Vite config) and the ones declared in .env.public. Reading any
other name from client code, such as import.meta.env.DATABASE_URL, fails the build with [ENV_NOT_PUBLIC] and a code
frame (at the line you wrote, not in the transformed text) instead of shipping undefined; MODE, BASE_URL, PROD, DEV
and SSR are exempt. The check looks at what the browser gets. The handler of a server function, and code only it
used, are replaced by a stub before the check, so import.meta.env.STRIPE_SECRET inside a handler is fine even in a
module that client code imports; the same read at the top level of that module, in a component or in a loader (which also
runs in the browser) still fails. A *.server.* file is never checked: it cannot be imported by client code at all. An envPrefix of *,
of an empty string or shorter than three characters throws [ENV_PREFIX_UNSAFE], because it would expose server
variables. The check is the exported findForbiddenEnvReads. Because the value is fixed when the client bundle is
built, change a DUSK_PUBLIC_ variable by rebuilding, not by restarting. This is the start of the error a real dusk build
printed for a client module that read import.meta.env.DATABASE_URL:
RolldownError: [ENV_NOT_PUBLIC] import.meta.env.DATABASE_URL is not available in client code; only variables that start with DUSK_PUBLIC_ or are declared in .env.public reach the browser.
1 | export const secret = import.meta.env.DATABASE_URL;
| ^
import { findForbiddenEnvReads, publicPrefixes } from "@shadow-js/dusk/vite";
const code = [
"export const api = import.meta.env.DUSK_PUBLIC_API_URL;",
"export const db = import.meta.env.DATABASE_URL;",
].join("\n");
const found = findForbiddenEnvReads(code, "src/config.ts", publicPrefixes(undefined));
for (const read of found) console.log(`${read.name} ${read.line}:${read.column}`);
console.log(found.some((read) => read.name === "DUSK_PUBLIC_API_URL") ? "leak" : "DUSK_PUBLIC_API_URL ok");
console.log(JSON.stringify(publicPrefixes("APP_")));
try {
publicPrefixes("*");
} catch (error) {
console.log(error instanceof Error ? error.message : String(error));
}
DATABASE_URL 2:18
DUSK_PUBLIC_API_URL ok
[