Module 02 · The portal and its catalog · 45 min · You are: Platform team
Configuration, secrets and Postgres
In this module
- You learn: How
app-configfiles layer, how${PORTAL_API_TOKEN}-style variables keep secrets out of Git, what the browser can and cannot see, and how scripts call the API. - You do: Start Postgres, write a local
.env, change four parts ofapp-config.yaml, then check what the frontend sees and call the API with a token. - You end with: The portal is called Acme Portal, stores its data in Postgres in Docker, and accepts a static API token from
.env. The config change is merged in yourportalrepo;.envis not in Git.
The generated app keeps everything in memory and accepts only browser sessions. A real portal keeps its data, has secrets that must never reach Git or the browser, and is called by scripts. All of it is configuration: one YAML file in Git with placeholders, and the values from the environment.
Open this module in the interactive course to track progress.
Concepts
- Config layers
- Files are read in order:
app-config.yaml, thenapp-config.local.yamlif present, or the files given with--config. Later files override keys. Lists are replaced, not merged. - Environment variables
${POSTGRES_PASSWORD}is read from the environment when the backend starts.${POSTGRES_HOST:-localhost}falls back to a default. A missing value with no default is an error only when a plugin needs it.- Frontend visibility
- Each plugin declares a config schema. Only keys marked for the frontend are sent to the browser; secrets never are.
- External access
- Scripts and CI call the API with a token configured in
backend.auth.externalAccess. Each token has a subject, so logs show who called.
Files to know
Open them in your editor or on GitHub; the bullets say what to look at.
Steps
1. Start Postgres
Postgres in Docker, pinned. Backstage creates one database per plugin in it.
docker run -d --name portal-db -p 5432:5432 -e POSTGRES_PASSWORD=portal-local postgres:17.11
If Docker says port 5432 is already allocated, something else uses it: run the command with -p 5433:5432 instead and add POSTGRES_PORT=5433 to .env in the next step.
2. Write the secrets file
Secrets go in .env on your laptop, never in Git. The generated .gitignore already lists .env.
Make a random API token. Copy the output:
openssl rand -hex 24
Create ~/work/portal/.env
Paste your token in place of paste-the-openssl-output-here.
# Local secrets for the portal. Never commit this file.
POSTGRES_PASSWORD=portal-local
PORTAL_API_TOKEN=paste-the-openssl-output-here
# Read at load time: your gh login gives the portal access to GitHub.
GITHUB_TOKEN=$(gh auth token)
Check that Git ignores it. Expect .env printed back:
cd ~/work/portal && git check-ignore .env
3. Change the config
Four changes in app-config.yaml. Placeholders stay placeholders: the values come from .env.
Change in ~/work/portal/app-config.yaml
- app:
- title: Scaffolded Backstage App
+ app:
+ title: Acme Portal
Change in ~/work/portal/app-config.yaml
- organization:
- name: My Company
+ organization:
+ name: Acme
Change in ~/work/portal/app-config.yaml
Under backend:, replace the commented-out auth block.
- # auth:
- # keys:
- # - secret: ${BACKEND_SECRET}
+ auth:
+ # Scripts and CI call the API with this token (module 02). The value is
+ # never in Git: it comes from the environment (.env on your laptop).
+ externalAccess:
+ - type: static
+ options:
+ token: ${PORTAL_API_TOKEN}
+ subject: portal-cli
Change in ~/work/portal/app-config.yaml
- # This is for local development only, it is not recommended to use this in production
- # The production database configuration is stored in app-config.production.yaml
- database:
- client: better-sqlite3
- connection: ':memory:'
+ # Postgres on your laptop (module 02), so the catalog survives a restart.
+ # ${NAME:-default} falls back to the default when the variable is not set.
+ database:
+ client: pg
+ connection:
+ host: ${POSTGRES_HOST:-localhost}
+ port: ${POSTGRES_PORT:-5432}
+ user: ${POSTGRES_USER:-postgres}
+ password: ${POSTGRES_PASSWORD}
4. Restart with the environment loaded
yarn start does not read .env by itself. You load it into the terminal, and the portal inherits it.
In the portal terminal, stop it with Ctrl+C. Then load .env (set -a exports every variable it sets):
cd ~/work/portal && set -a && . ./.env && set +a
Start it again:
yarn start
Reload the browser and enter as Guest. The tab and the sidebar now say Acme Portal.
5. See what the browser gets
Run these in your second terminal. Load .env there too: every terminal that talks to the portal needs it.
cd ~/work/portal && set -a && . ./.env && set +a
The config the frontend receives. Find title: Acme Portal. Then look for database, externalAccess or token: none of them is there.
yarn backstage-cli config:print --frontend
The backend view. Values the schema marks as secret are masked. Expect three: the API token, the database password and the GitHub token, each as <secret>. (--with-secrets shows them; never paste that output anywhere.)
yarn backstage-cli config:print | grep -B1 "<secret>"
6. Call the API with the token
The same request as in module 01, now with a token.
Without a token, still 401:
curl -s -o /dev/null -w "%{http_code}\n" localhost:7007/api/catalog/entities
With the token. Expect the example entity names, such as example-website:
curl -s -H "Authorization: Bearer $PORTAL_API_TOKEN" localhost:7007/api/catalog/entities | jq -r '.[].metadata.name'
7. Check the data is in Postgres
Each backend plugin gets its own database. Expect names like backstage_plugin_catalog and backstage_plugin_scaffolder.
docker exec portal-db psql -U postgres -Atc "select datname from pg_database" | grep backstage
8. Ship it
From now on, changes to the portal go through a pull request. .env stays on your laptop.
cd ~/work/portal && git status --short
Expect only app-config.yaml as modified. If .env shows up, stop: it must not be committed.
Propose the change as a pull request. First put it on its own branch and push it:
git switch -c config-postgres-token && git add -A && git commit -qm "Acme Portal: Postgres and an API token"
git push -qu origin HEAD
Open the pull request. It prints a link:
gh pr create --fill
Open that link in your browser. Files changed shows exactly your diff. In a team, a reviewer approves here; on your own repo you merge it yourself.
Merge it on GitHub (Squash and merge, then Delete branch), or from the terminal:
gh pr merge --squash --delete-branch
Either way, bring your copy up to date:
git switch -q main && git pull -q
Simplified for the course
Secrets live in a
.envfile on your laptop. Production injects them from a secret store (Vault, a cloud secret manager, Kubernetes Secrets) as environment variables, so the same config file works everywhere.One static token with full access. In production, give each script its own token with a subject, and limit it with
accessRestrictions.
Break it on purpose: A new laptop
A colleague sets up the portal on a new laptop. They write .env from memory and make a small typo in a variable name. Their terminal is brand new, so nothing from an old session hides the mistake.
You practise: reading a startup error, connecting a config path to the variable behind it, and catching it before start.
1. Break it
Change in ~/work/portal/.env
Only the name changes; keep your token value.
- PORTAL_API_TOKEN=
+ PORTAL_API_TOKN=
Stop the portal (Ctrl+C) and close that terminal: its exported variables would hide the typo. Open a new terminal and start the portal:
cd ~/work/portal && set -a && . ./.env && set +a
yarn start
2. What you see
Expected: the portal starts. You get: the backend logs Listening on :7007, then Plugin 'app' threw an error during startup with Missing required config value at 'backend.auth.externalAccess[0].options.token', and shuts down. The browser shows the app, but every page fails to load data.
3. Fix it yourself
Find the variable behind that config path, fix it, and get the portal running. Then find a command that catches this mistake before starting the portal.
Hint 1
The error names a config path. Which line in app-config.yaml is at backend.auth.externalAccess[0].options.token, and which variable does it read?
Hint 2
Compare that variable name with .env, letter by letter. For the check before start, look at yarn backstage-cli config:check --help.
The fix
Change in ~/work/portal/.env
- PORTAL_API_TOKN=
+ PORTAL_API_TOKEN=
The check that catches it. Plain config:check passes with the variable missing. --strict fails with Configuration does not match schema and, among the lines, must have required property 'token'. Load the fixed file and run it; expect only Loaded config from app-config.yaml:
cd ~/work/portal && set -a && . ./.env && set +a
yarn backstage-cli config:check --strict
Start the portal again:
yarn start
After you fixed it: the five questions
- What failed?
- A required config value was empty because its environment variable had a different name, so the backend stopped at startup.
- How was it detected?
- The startup log named the config path, not the variable. You had to follow the path to the placeholder.
- Who responds?
- Whoever runs the portal: you on your laptop, the platform team in production.
- How is it corrected?
- Give the variable its exact name and restart.
- How is it prevented?
- Run
yarn backstage-cli config:check --strictwith the real environment before every start or deploy (in CI, with dummy values). Keep a.env.examplein Git with every name and no values.
Quiz
- You set
catalog.locationsinapp-config.local.yaml. What happens to the locations inapp-config.yaml?- They are replaced: lists in a later config file replace the whole list
- They are merged with yours, and duplicates are removed automatically
- They win, because
app-config.yamlis always read last
- Why is the database password safe in
app-config.yaml?- The file is encrypted by Git before it is pushed to GitHub
- The file only holds
${POSTGRES_PASSWORD}; the value comes from the environment and is never sent to the browser - Backstage hashes every value under
backend:when it reads the file
- What does
config:check --strictadd?- It also runs every plugin to see that it starts
- It formats the YAML and fails on wrong indentation
- It fails when a required value is missing, not only when the shape is wrong
Answers
- They are replaced: lists in a later config file replace the whole list Objects merge key by key; lists are replaced.
- The file only holds
${POSTGRES_PASSWORD}; the value comes from the environment and is never sent to the browser The placeholder is in Git; the value is not. The schema keeps it out of the frontend. - It fails when a required value is missing, not only when the shape is wrong Without
--strict, a missing environment variable passes the check.
Field note
I lost an afternoon once to a variable that was set in my shell from an earlier session and missing everywhere else. Since then I start the portal from a fresh terminal before I say "it works on my machine".