[]previous and next page

Environment variables

defineEnv describes the variables the server needs.

For agentsUpdated

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".

src/env.tsts
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:

  1. .env.public and .env.[mode].public: the values the browser may read. Commit them (see below).
  2. .env, then .env.[mode], .env.local, and .env.[mode].local: server values, each overriding the earlier ones.
  3. .env.public.local: a local override of a public value. The scaffold ignores it with the other .env*.local files.

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.

env-files.tsts
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"]}`);
Expected output
base|mode-local|from the shell
DATABASE_URL,DUSK_PUBLIC_API
DUSK_PUBLIC_API=ok

Public 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.public and 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 in env.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.
dusk.config.tsts
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.

env-public-files.tsts
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(","));
Expected output
SITE_URL
DUSK_PUBLIC_FLAG,SITE_URL

What 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:

text
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;
     |                       ^
public-env.tsts
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));
}
Expected output
DATABASE_URL 2:18
DUSK_PUBLIC_API_URL ok
[