MainSDK
Search⌘K
How-to

Change config without a deploy

Store runtime settings and flags, read them with config.get, watch changes, and roll a feature out in steps with no deploy.

Building for 0.2

Status Status is where the work is: Planned (in a milestone, not started), Building (tickets in progress), Preview (usable, may change), then the stability it ships at.

Stability The promise an interface ships with.

  • Stable Follows semantic versioning; breaking changes need a new major, 12 months of overlap and a codemod.
  • Beta Usable in production; may change before it becomes stable.
  • Experimental May change or go away; needs an explicit opt-in in main.yaml.
Stability levels
Updated 2026-10-28

Change a limit, a banner or a rollout while the app runs. You will type your settings, read them with config.get, react to changes with config.watch, and roll a feature out to a growing share of users with a flag. It takes about 20 minutes.

You need

  • A MainSDK app with a main.yaml (see Thirty-minute path).
  • The settings you want to change, written down with their types.
  • For production, a store that several instances share: the redis driver. The files driver reads JSON from your repository and suits development.

1. Type the settings

Describe each setting in a JSON schema. A value that doesn't match it is refused, and the last good value stays in place.

config.schema.json
{
  "type": "object",
  "properties": {
    "cart": {
      "type": "object",
      "properties": { "maxItems": { "type": "integer" } }
    }
  }
}
main.yaml
config:
  driver: redis
  schema: ./config.schema.json
  redis: { url: secret:REDIS_URL }

Use driver: files in main.env.dev.yaml to keep development on the repository.

2. Read values with config.get

app/api/cart/route.ts
import { config } from "mainsdk";

export function canAdd(count: number) {
  return count < (config.get("cart.maxItems") as number);   // synchronous, from memory
}

config.get() reads memory, so call it inside the request. Don't read main.yaml or the store at run time; the value you need is already there.

3. React to a change with config.watch

server/worker.ts
import { config, log } from "mainsdk";

let batchSize = config.get("worker.batchSize");

const stop = config.watch("worker.batchSize", (value, change) => {
  batchSize = value;
  log.info("batch size changed", { from: change.previous, to: value });
});

process.on("SIGTERM", () => stop());

The watcher runs once for each change, after it is applied, outside any request. A watcher that throws is logged and does not stop the others.

4. Roll a feature out in steps

A flag is a definition in the same store, at flags.<name>. Start small and raise rollout as you gain confidence:

flags.<name> in the store
{ "enabled": true, "rollout": 5 }
app/api/checkout/route.ts
import { flags } from "mainsdk";

export function checkoutFor(userId: string) {
  return flags.enabled("new-checkout", { user: userId }) ? "new" : "classic";   // no await
}

Move rollout to 25, then 100. Each step applies without a restart. A user who was on stays on, because each user has a fixed place in the rollout. To give a different group of users the early access, change the salt.

To turn it off at once, set enabled to false. That wins over every rule.

5. Know what a push can change

A push from the store can only tighten main.yaml. A spend limit can go down, network.mode can go up to enforce, and a host can be removed. It can't add a host, raise a limit or switch a rule off. A push that loosens a rule is refused with MAIN-CONFIG-0021, and a main. path that only changes in a reviewed deploy, such as main.payments.driver, with MAIN-CONFIG-0022. The table of pushable paths is in Runtime settings.

The rule keeps a compromised store from loosening the app. The reason is in the decision: a loosening is a deploy, so a person reviews it.

Keep secrets out of the store. A literal key under a secret field, such as apiKey, fails with MAIN-CONFIG-0004. Put it in Secrets and refer to it with secret:NAME.

6. Check it works

In a test, use the memory driver. Its handle sets a value, the watcher fires, and config.get() returns the new value at once. Then check the app:

bash
npx mainsdk check --env prod

A pass exits 0. To prove the app survives an outage, make the store unreachable in a test: config.get() keeps the last good value and MAIN-CONFIG-0023 is logged once.

If it fails

CodeCauseFix
MAIN-CONFIG-0004A literal secret sits in a config value, or config.redis.url is a literalUse a secret: reference and keep the secret in secrets
MAIN-CONFIG-0020A value fails the schema, or a file isn't valid JSONCorrect the value; the last good one stays
MAIN-CONFIG-0021A pushed main. value is looser than main.yamlPush a tighter value, or change main.yaml in a reviewed deploy
MAIN-CONFIG-0022A pushed main. path only changes in a reviewed deployChange it in main.yaml and deploy
MAIN-CONFIG-0023The store can't be read; the app serves its last good valuesRestore the store; no restart is needed
MAIN-FLAGS-0001A flag was checked with no definition; the fallback was servedDefine flags.<name>, or check the name
MAIN-FLAGS-0002A flag definition is invalid and was refusedFix enabled, rollout (0 to 100, two decimals at most) or the rule keys
MAIN-FLAGS-0003A rule's value has another type from the fallbackServe the same type as the fallback

For agents

text
Goal: change a runtime setting or roll out a feature without a deploy.
Read: docs/specs/config-secrets.md, docs/specs/flags.md, this page, main.yaml, config.schema.json.
Touch only: config.schema.json, the code that reads the setting or the flag, and tests.
Do: read values with config.get and flags.enabled, and never await them; never re-read main.yaml at run time;
keep secrets in secrets; ask the owner before raising a limit or widening network.allow in main.yaml.
Verify: npx mainsdk check --json --agent
Never write a push that loosens main.yaml; the store can only tighten it.

What comes later

  • A console to edit values, staged rollout that advances by itself, and rollback on error spikes: Cloud (ADR-0007).
  • The openfeature flag driver, so definitions can stay in LaunchDarkly, Flagsmith or PostHog (planned).

Next

Was this page helpful?

↑↓ moveEnter openEsc close

Not decided yet