Tidying up the Backstage configuration foundation: understanding the app-config hierarchy, validating schemas with @backstage/config-lint, using nested config, environment variable substitution, and dynamic config, plus managing environment-based secrets and Vault integration without ever putting credentials in code.

In episode 8, you enabled auth providers and filled in clientId and clientSecret in app-config.yaml. Episode 9 covers the layer governing all of that: configuration. As Backstage grows, the config file becomes the place where mistakes creep in most often — mistyped values, secrets leaking into the repository, or an environment using settings different from what was intended. This time we tidy it up: how app-config is validated, structured in layers, substituted from the environment, and how secrets are protected through environment variables and Vault.
Backstage reads configuration from more than one file. The app-config.yaml file is the same base for all environments, and additional files are merged on top of it — values appearing later override earlier ones. This split keeps one base configuration with per-environment adjustments.
| Level | Example file | Purpose | In Git? |
|---|---|---|---|
| Base | app-config.yaml | Values that are the same for all environments | Yes |
| Local | app-config.local.yaml | Overrides for development on a developer's machine | No (gitignored) |
| Production | app-config.production.yaml | Overrides specific to the production environment | Yes |
| Secrets | .env file or secret manager | Credentials and sensitive values | Never |
This pattern explains why app-config.local.yaml never enters git: that's where developers keep their local values. Production uses app-config.production.yaml, merged on top of the base file when the application runs.
Backstage provides the @backstage/config-lint package to validate configuration. This validation checks the config structure against a defined schema, so typos, unknown fields, or wrong data types are caught before the application runs. The rules are simple: the config schema is defined in the app-config.schema.json file, and every field used in app-config.yaml must match it.
yarn backstage-cli config:lintconfig:lint reads app-config.yaml along with the override files, compares them against app-config.schema.json, and reports mismatches. You can add it to your CI pipeline so any pull request that breaks the configuration is rejected early.
{
"backend": {
"baseUrl": {
"type": "string",
"description": "URL dasar dari instance Backstage"
}
},
"custom": {
"featureFlag": {
"type": "boolean",
"default": false
}
}
}The schema above states that backend.baseUrl must be a string and custom.featureFlag a boolean. Fields outside the schema will be flagged as unknown — a signal that something is misspelled or not yet declared.
Backstage configuration is arranged in nested layers. Blocks like backend, auth, catalog, and techdocs each contain sub-blocks, and plugins access values through dot paths like backend.baseUrl. Keeping the config nested makes the file easier to read and avoids name collisions between plugins.
Config values can be pulled from environment variables using substitution syntax inside the config file. The syntax only applies within string values, and when the config loads, the variable's value replaces it. This is the main way to supply different values per environment without editing files.
backend:
baseUrl: ${BACKSTAGE_BASE_URL}
listen:
port: 7007
auth:
providers:
github:
production:
clientId: ${AUTH_GITHUB_CLIENT_ID}
clientSecret: ${AUTH_GITHUB_CLIENT_SECRET}When run, the BACKSTAGE_BASE_URL placeholder is replaced with the value of the BACKSTAGE_BASE_URL environment variable. If the variable isn't available, the process warns — this mechanism makes the config safe to commit because it never contains the secret values themselves.
Tip
Separate credentials from structural configuration. app-config.yaml holds public values like backend.baseUrl and provider addresses; app-config.local.yaml or environment variables hold the credentials. With this pattern, one file can be publicly reviewed while secrets live somewhere that never touches git.
Backstage also supports dynamic config — an experimental mechanism for changing parts of the configuration while the application is running, without rebuilding. Some plugins can read dynamic configuration loaded from a special file and updated through an API, so small adjustments don't have to wait for a deploy cycle.
The usage pattern: values that change often (for example feature flags or long lists) live in dynamic config, while stable values stay in app-config.yaml. Use dynamic config with care — putting secrets there actually reduces the value of the environment control you've already built.
The most basic — and most important — way to store Backstage secrets is the environment variable. clientId, clientSecret, Vault tokens, and cloud provider access keys are all supplied through the environment, not written in config files. These values are usually defined on the deployment platform, in a gitignored local .env file, or in a Kubernetes secret manager.
export AUTH_GITHUB_CLIENT_ID=Ov23li-example
export AUTH_GITHUB_CLIENT_SECRET=secretexample
export VAULT_TOKEN=hv-token-example
yarn devOnce the environment variables are available, app-config.yaml just references them through environment substitution like AUTH_GITHUB_CLIENT_ID. Credentials are never written in a file that enters git, and each environment uses its own values.
When the number of secrets grows, environment variables alone start getting hard to manage. Vault offers a centralized home: secrets are stored in Vault, and Backstage pulls them through the Vault backend plugin. The backend plugin loads secrets from specific paths and makes them available to other plugins like the scaffolder or catalog, based on the configured address and token.
vault:
baseUrl: ${VAULT_BASE_URL}
token: ${VAULT_TOKEN}
kvVersion: 2Secrets still flow through environment variables (the Vault token), but the secret contents themselves are stored and managed in Vault — including rotation, audit logs, and access control. This is a big step from writing credentials in files toward centralized, auditable storage.
Important
One non-negotiable rule: never hardcode credentials in code or in config files that enter git. If a secret was ever committed, treat it as leaked — rotate its value. Use environment variables for basic values and Vault at scale; that way, rotating a secret only means changing its value in one place.
In this episode 9, you tidied up the configuration foundation: the app-config.yaml hierarchy with local and production override files, schema validation with @backstage/config-lint, nested config with dot paths, environment variable substitution, dynamic config for values that change at runtime, and environment-based secrets management plus Vault integration without ever putting credentials in code.
The key takeaways:
config:lint catches typos and wrong types in CI, not in production.app-config.yaml stays safe to commit.In the next episode, episode 10, we return to the Scaffolder at a higher level: Advanced Scaffolder & Custom Actions — branching workflows with conditional logic, output as links and entities, permissions for templates, and custom actions that provision cloud resources from internal tooling.