Skip to content

Environment and config

Keep your bot token and settings in .env files, switch them per environment with ENV=staging, and read them as typed values with envapt.

Your bot token can't live in your code, because anyone who reads the code could run your bot. A database URL that differs between your machine and the server can't either. Those go in .env files beside seedcord.config.ts, and your bot reads them through envapt (opens in a new tab). envapt is a peer dependency of seedcord, so a bot from pnpm create seedcord already has it.

Reading a value

You may have read these through process.env before. That falls short in a seedcord bot for two reasons. seedcord loads your .env files through envapt, which doesn't copy them into process.env, so process.env.MONGO_URI comes back undefined for a value that sits in .env. And every read comes back as string | undefined, so a port or a list of ids needs parsing and a fallback at each call.

Envapter reads the shell and your .env files together and hands back the type you ask for:

src/bot.tshover for typestap for types, arrow keys walk the tokens
import { class SeedcordSeedcord } from '@seedcord/http';
import { 
const Converters: {
    readonly array: typeof buildArrayConverter;
    readonly String: "string";
    readonly Number: "number";
    readonly Boolean: "boolean";
    readonly BigInt: "bigint";
    readonly Integer: "integer";
    readonly Float: "float";
    readonly Json: "json";
    readonly Url: "url";
    readonly RegExp: "regexp";
    readonly Date: "date";
    readonly Time: "time";
    readonly Port: "port";
    readonly Email: "email";
}
Converters
, class EnvapterEnvapter } from 'envapt';
export default new new Seedcord(config: HttpServerConfig): SeedcordSeedcord({ HttpServerConfig.bot: HttpBotConfig<Partial<RESTOptions>>bot: { BotConfig.interactions: InteractionsConfiginteractions: { path: stringpath: './handlers' }, BotConfig.commands: CommandsConfigcommands: { path: stringpath: './commands', guilds?: string[]guilds: class EnvapterEnvapter.ReaderMethods.getAs<ArrayOf<"string">, never[]>(key: EnvKeyInput, converter: ArrayOf<"string">, fallback?: never[] | undefined): string[] (+4 overloads)getAs( 'TESTING_GUILD_IDS',
const Converters: {
    readonly array: typeof buildArrayConverter;
    readonly String: "string";
    readonly Number: "number";
    readonly Boolean: "boolean";
    readonly BigInt: "bigint";
    readonly Integer: "integer";
    readonly Float: "float";
    readonly Json: "json";
    readonly Url: "url";
    readonly RegExp: "regexp";
    readonly Date: "date";
    readonly Time: "time";
    readonly Port: "port";
    readonly Email: "email";
}
Converters
.
array: (opts?: {
    delimiter?: string;
}) => ArrayOf<"string"> (+2 overloads)
array
(),
[] ) } }, Config.subscribers: SubscribersConfigsubscribers: { path: nullpath: null }, HttpServerConfig.port?: number | falseport: class EnvapterEnvapter.ReaderMethods.getAs<"port", 3000>(key: EnvKeyInput, converter: "port", fallback?: 3000 | undefined): number (+4 overloads)getAs('PORT',
const Converters: {
    readonly array: typeof buildArrayConverter;
    readonly String: "string";
    readonly Number: "number";
    readonly Boolean: "boolean";
    readonly BigInt: "bigint";
    readonly Integer: "integer";
    readonly Float: "float";
    readonly Json: "json";
    readonly Url: "url";
    readonly RegExp: "regexp";
    readonly Date: "date";
    readonly Time: "time";
    readonly Port: "port";
    readonly Email: "email";
}
Converters
.type Port: "port"Port, 3000)
});

getAs takes the variable, a converter, and the value to use when the variable is unset. TESTING_GUILD_IDS=111,222 becomes ['111', '222'], an unset one becomes [], and PORT=8080 becomes the number 8080.

Gateway and http differ

This sample is an http bot on its own server. port exists only on http's config for that runtime, so TypeScript rejects it on an edge bot. A gateway bot doesn't take port, and its bot also needs clientOptions and events.

Three readers cover most bots:

readerreturnswhen the variable is unset
Envapter.get('KEY')string | undefinedundefined
Envapter.getAs('KEY', converter, fallback)the converter's typethe fallback, or undefined without one
Envapter.getRequired('KEY', converter)the converter's typethrows MissingEnvValue

The converters include Converters.String, Number, Integer, Boolean, Url, Port, Json, and array(). A Boolean reads true, yes, on, or 1 as true. envapt's docs (opens in a new tab) list the rest, plus validation through any Standard Schema library, like zod, valibot, or arktype.

Required values

Use getRequired for a value your bot can't start without, like a database URL:

const mongoUri = Envapter.getRequired(
    'MONGO_URI',
    Converters.String
);

A missing MONGO_URI throws while bot.ts loads, before anything connects.

seedcord codegen, seedcord commands, and seedcord build load bot.ts too. If the variable isn't set where they run, like in CI, they stop with CoreImportFailed and the message gives the missing key. Set it there as well, or read it with getAs and a fallback if the build doesn't need the real value.

You don't read DISCORD_BOT_TOKEN the way you read MONGO_URI. seedcord reads it through envapt itself, so you never pass the token to Seedcord.

The .env files

Four files load, and a later one wins over an earlier one for the same key:

  1. .env
  2. .env.local
  3. .env.<mode>, like .env.staging
  4. .env.<mode>.local

A variable set in your shell wins over all four. Put an override that only your machine needs in .env.local, or in .env.<mode>.local to beat a value from .env.<mode>. A bot from pnpm create seedcord keeps every .env file out of git except .env.example.

Picking the mode

The mode comes from your shell. seedcord reads ENVIRONMENT, ENV, NODE_ENV, and MODE in that order, and the first one that has a value sets the mode:

export ENV=staging
pnpm run build

That build runs in staging and loads .env.staging. The value has to be development, staging, production, or test. Upper case works too, so ENV=STAGING also loads .env.staging. Anything else, like NODE_ENV=prod, stops the CLI with ConfigInvalidEnv.

With none of them set, seedcord build runs in production, and every other command runs in development.

A .env file can't pick the mode, because the CLI reads the mode before it loads any .env file. Your .env usually holds development values, so a mode set there would carry development to production.

Inside your bot, Envapter.environment holds the mode, and Envapter.isProduction, isStaging, isDevelopment, and isTest check it.

In a built bot

On Node, the build's mode doesn't carry over to the process that runs your bot. Set the runtime environment when you start it:

ENV=production node dist/index.mjs

Use ENV=staging to load staging's runtime files from the same build. envapt's environment guide (opens in a new tab) covers detection, defaults, and mode variables inside .env files.

seedcord reads runtime .env files beside seedcord.config.ts, even when you start the bot from another folder. If that config file is absent from its recorded location, or you're running a standalone Bun binary, it reads them from the folder you start it in.

Build-time values

By default, Vite puts every variable whose name starts with VITE_ on import.meta.env, read from the same .env files:

const greeting = import.meta.env.VITE_GREETING;

The same Vite values are available through envapt:

const greeting = Envapter.get('VITE_GREETING');

These values are fixed when you build. seedcord build writes them into the code in dist, so changing the runtime .env keeps the built values. Use them for something that belongs to a build, like a version label. Keys Vite doesn't expose keep coming from the runtime environment.

To use another prefix, set Vite's envPrefix (opens in a new tab) through a plugin in seedcord.config.ts. This example keeps VITE_ and adds APP_:

seedcord.config.ts
import { defineConfig } from 'seedcord';

export default defineConfig({
    root: './src',
    instance: './bot.ts',
    vitePlugins: () => [
        {
            name: 'env-prefix',
            config: () => ({ envPrefix: ['VITE_', 'APP_'] })
        }
    ]
});

Use envPrefix: 'APP_' to replace the default prefix. seedcord merges the variables Vite exposes into envapt in dev and builds, so APP_GREETING works through both APIs.

On Node, Envapter.envFileOptions = { override: true } explicitly lets runtime .env files replace merged values. envapt's file options (opens in a new tab) explain that choice.

If you add a source of your own, use merge(Envapter.source, yourSource) to keep seedcord's Vite and runtime sources. Calling Envapter.useSource(yourSource) replaces them. envapt's guide to combining sources (opens in a new tab) shows both forms and their precedence rules.

Warning

Never put a secret in a variable exposed through import.meta.env, including one with a custom prefix. Anyone who gets a copy of dist, like from a published package, can read it.

TypeScript reads the type of import.meta.env from vite/client, so add it to your tsconfig's types, as in "types": ["node", "vite/client"]. A bot from pnpm create seedcord already lists it.

On Cloudflare Workers

A worker can't read files. Its variables arrive as the worker's env, and seedcord passes that env to envapt before your bot loads. Every Envapter read on this page works the same on an edge bot.

Worker variables can declare the environment through ENVIRONMENT, ENV, NODE_ENV, or MODE, in that order. Without one, envapt uses Vite's mode from the dev session or build.

Only the bindings you declare in cloudflare.config.ts reach the worker. In dev, a declared secret takes its value from .dev.vars, or from your .env files when there's no .dev.vars. Cloudflare Workers covers declaring them.