os.Getenv in your handlers, and no list of environment variables to keep in step with the code.
The arrangement has a payoff beyond tidiness. The set of valid settings is data a tool can read, so a typo in a variable name, a value outside its allowed set, or a missing required field is caught when the process starts (or when you render an environment), with a message naming the field, instead of surfacing as odd behavior in production. For an agent, that is feedback it can act on in the same minute.
Declaring a setting
Config lives inproto/<project>/config/v1/config.proto, in package <project>.config.v1. Each field carries a (forge.v1.config) annotation. This is the database setting from a freshly scaffolded project, with the long description trimmed:
A scaffolded project ships dozens of settings: the port, log level and format, database pool tuning, CORS, TLS, shutdown timing, rate limits, payload caps, JWT and OIDC settings, and more. Add a field to the proto, run
forge generate, and it appears in the typed config, as a flag, and in the KCL schema.
Durations are declared as google.protobuf.Duration and written as Go durations in the environment (5s, 168h). Declare a duration as a plain string and it stays a string: the loader has no name heuristics, so behavior always follows the declared type.
Because behavior follows annotations, renaming a field is safe. The
environment field is the runtime mode because it carries role: CONFIG_FIELD_ROLE_MODE, and a field that merely happens to be named environment gets no special behavior. Exactly development (not dev, not Development) relaxes CORS to an origin-reflecting policy and turns off HSTS. Every other value, including unset, is a deployed environment and stays closed.What gets generated
pkg/config/config_gen.go is a thin shim. The config type is an alias for the generated proto message, so holding a *config.Config is holding the message:
forge/pkg/config, that reads the annotations off the message descriptor. Adding a field costs a proto edit and a forge generate, with no per-field Go code. The same declaration also projects to deploy/kcl/config_gen.k, which holds a typed AppConfig schema and an appConfigEnvMap function that turns values into a workload’s environment.
How a value is chosen
Each setting is resolved through fixed layers. Later layers override earlier ones:- the proto
default_value; - a build default, if the binary was built with one (below);
- a config file, only if you pass one;
- the environment variable;
- an explicit command-line flag.
--config <path> (.yaml, .yml or .json, keyed by the proto field names) or the binary’s own path variable, <BINARY>_CONFIG. A path that is given but missing, unreadable or invalid stops startup and says where the path came from:
grcp_port fails instead of being ignored. The variable is per binary on purpose, so a shell that sets it for one program cannot make another load a file written for a different schema.
Build defaults let one build of a binary carry its own values, such as a release stamped with its environment’s endpoints, while go install of the same source keeps the proto defaults. They are stamped with a linker flag, in URL query form keyed by env_var:
Validated before the process does anything
Loading ends with validation, so a config that reaches your code has already passed every declared check. In order, after the layers are applied:- every
requiredfield must have a value from some layer; - each
allowed_valuesset is checked; - the role invariants are enforced, for example both TLS paths or neither, and no wildcard CORS origin combined with credentials;
- any
buf.validaterules on the config message are evaluated, which is where range checks and cross-field rules live.
Each process loads only what it reaches
A project with several processes should not give each one every credential. A process (a root, usually the binary’sserver command) loads only the settings that its part of the composition graph can reach. cmd/<binary>/cmd/roots_gen.go records that reach, and the loader uses it to demand required fields only where the root reads them. In KCL, config_gen.ROOTS lists each root’s variables and credentials, and a workload bound to a root receives exactly those.
For finer control, split config per binary or per command with (forge.v1.binary_config) = {binary: "admin"} on a config message, or {binary: "app", command: ["server", "api"]} to scope it to one process of a binary. Ownership then becomes disjoint: deleting a field from one binary’s config touches no other binary, and a worker’s deployment never carries an API credential. forge lint --config-reach warns about config fields that no binary or frontend loads.
Components do not read the environment
A component takes its configuration throughDeps, never from the process environment. forgeconv-no-getenv flags os.Getenv in component packages, and the scaffold’s config.enforce_typed_access: error in forge.yaml turns the same guardrail into a gating lint for hand-written code too. That is the lint-level half of the design: one way to read config, enforceable by a tool.
The scaffold currently hands each component the whole root *config.Config. A component that has its own block takes only that block: declare a <Component>Config message, compose it as a field on AppConfig, and take Cfg *config.<Component>Config in the component’s Deps.
Supplying values per environment
Declaring a setting says what exists. Each environment says what it is set to, indeploy/kcl/<env>/config.k, a file you own. It instantiates the generated AppConfig schema and sets only the fields that environment pins:
main.k projects those values into each workload’s environment with the generated function, merging in values only that environment knows (ports on this machine, the dev database):
--strict makes it fail instead. forge env up and forge env exec refuse to start a process while one is missing. forge env render <env> shows the full rendered objects.
Secrets are references, never values
A field markedsensitive: true is never an inline value in a rendered manifest. In the generated schema it is a reference to a key in a named secret:
config_secrets and its root reaches it. The scaffolded migrate job, for example, declares config_secrets = ["DATABASE_URL"] and gets nothing else. This is deliberate: a pod whose secret reference names a missing key does not start, so broadcasting every credential to every pod turns one missing secret into a whole-namespace outage. A config_secrets entry that the workload’s root does not reach is a render error.
Before anything starts, deploy checks every secret a workload declares against the environment’s store, where forge can see the store, and fails with the full list of what is missing. A credential that is genuinely optional (a feature’s off-switch) says so with optional: true next to sensitive: true. Setting values is covered in Secrets, and forge secret set --env <env> <NAME> is the command.
Checking a deployment’s values against the schema
When the repository that holds a deployment’s values is separate from the one that defines the schema,pkg/config/roots_gen.go exposes the same data as Go: config.Roots(), config.RootNamed(name) and config.ValidateRootEnv(name, env). A unit test in the values repository can render each workload’s environment and call ValidateRootEnv. It runs the process’s boot load over that environment with no flags, reports every problem instead of the first, and adds an UnreachedVarError for each variable the root never reads, so a value set under a name nothing consumes is a test failure.
forge generate runs for you.
Related topics
Environments and deploys covers how environments are bound to runtimes and how a release is planned. Secrets covers setting and rotating the values behindsensitive fields. Adding a feature has the short version of declaring a setting. Agents can load the binaries/config-files, binaries/subtrees and deploy skills for the full reference.