Look at the .env.example of any self-hosted app you've installed. Mine used to look like that too: S3 keys, SMTP credentials, payment provider secrets, an ecommerce API token, all of it required, all of it read once at boot.
That design has a specific victim, and it isn't you. It's the person who installs your software but doesn't have a shell on the box it runs on. Every configuration change becomes: edit a file over SSH, restart the container, hope nothing else was depending on the old value. For software that ships an admin UI, that's an incoherent story. Half the settings are in a web form and the other half require a terminal.
I moved essentially all of it into the database. Environment variables are now a fallback, not the source of truth.
The shape
export async function getConfig<T = unknown>(key: string, envFallback?: T): Promise<T | undefined> {
const mk = memoKey(key);
const cached = _configMemo.get(mk);
if (cached && Date.now() < cached.expiresAt) return (cached.value as T) ?? envFallback;
const { getEffectiveConfig } = makePlatform();
const value = await getEffectiveConfig.get(key);
_configMemo.set(mk, { value, expiresAt: Date.now() + CONFIG_MEMO_TTL_MS });
return (value as T) ?? envFallback;
}
Read the stored value; fall back to the environment variable if it's unset. Keys are declared once in a registry with their env fallback beside them:
DEFAULT_CDN_URL: k<string>('cdn.default_url', 'DEFAULT_CDN_URL'),
GOOGLE_MAPS_API_KEY: k<string>('google_maps.api_key', 'GOOGLE_MAPS_API_KEY'),
PERMISSIONS_ADMIN_USER_IDS: k<string>('permissions.admin_user_ids', 'PERMISSIONS_ADMIN_USER_IDS'),
The registry is the part I'd copy into any project. One file that lists every configurable thing, its storage key, its env fallback and its type. It doubles as the documentation, and unlike documentation it can't drift, because the accessors are generated from it.
Four things this got right, and one it didn't
Nothing is required to boot. Every integration is inert until configured. No LLM key means the AI features hide themselves; no S3 config means uploads throw a specific, catchable error and the admin UI shows a banner. The app starts with an empty environment, which is what makes a one-command install possible at all. That property is downstream of this decision, not of the Dockerfile.
Changing a credential doesn't need a restart. For a single-box deployment, that's the difference between a settings form and an SSH session.
A 5-second memo, and it's deliberately that short. Every typed accessor calls getConfig, so a single request can hit it dozens of times, each doing two serial reads. The memo collapses that to one. The TTL is far shorter than any request, so it's effectively per-request, and cross-process staleness is bounded at five seconds. Writes invalidate the exact key in-process, so same-process reads are read-your-writes. I'd rather bound staleness with a small number than build an invalidation protocol across processes.
Nested updates are read-modify-write in application code:
// Read-modify-write of a nested path inside a JSON config document. Done in JS (not
// jsonb_set) because the underlying store does not create missing intermediate containers.
export async function setConfigPath(key: string, path: string[], value: unknown): Promise<void> {
const doc = (await getConfig<Record<string, unknown>>(key)) ?? {};
let cursor = doc;
for (let i = 0; i < path.length - 1; i++) {
if (cursor[path[i]] === null || typeof cursor[path[i]] !== 'object') cursor[path[i]] = {};
cursor = cursor[path[i]] as Record<string, unknown>;
}
cursor[path[path.length - 1]] = value;
await setConfig(key, doc);
}
Here's the one it got wrong, or at least the sharp edge I'd warn you about. getConfig returns the memoized object by reference. setConfigPath mutates that object in place before writing it back. So any decision you make based on a config object read before a path-mutating call may be reading post-mutation state. It has bitten me. The rule that keeps it safe is: snapshot what you need before calling anything that mutates a path. A defensive clone in getConfig would remove the whole class, at the cost of an allocation per call, and if I were writing it today I'd pay that.
Deleting a key writes null rather than deleting the row:
// The platform config store is write-through with no row delete; clearing a key to null
// reads back as "unset" (getConfig falls through to envFallback), which is the behavior
// every caller of deleteConfig relies on.
Worth stating because it's the kind of implementation detail that becomes a bug when someone later "optimizes" it into a real delete and changes what unset means.
The one rule I'd write in capitals
// Once published, KV keys MUST NOT be renamed - installations may have written
// rows against them. Add new keys, never repurpose old ones.
Config keys are a public interface the moment you ship a version. Rename email.from_address to email.sender and every existing installation silently reverts to defaults on upgrade. Nothing errors. Emails go out from the wrong address until someone notices.
Environment variables have the same property, but you get away with more, because a missing env var usually fails loudly at boot. A missing database row falls back to a default and keeps running. Moving config to a database makes renames strictly more dangerous, and it's the one thing I'd tell someone before they copy this design.
What genuinely belongs in the environment
Not everything moved, and the split isn't arbitrary. Three things stayed:
-
DATABASE_URL, because you can't read the config table without it. - The session secret and the credentials encryption key, because they're needed to decrypt what's in the config table.
- The port and the admin hostname, needed before request handling exists.
Everything else is data. The test I use: if the app can start without it, it isn't an environment variable. That leaves you with three or four, which is a .env.example someone will actually read.
Top comments (0)