PlatformEngr® Backstage course

Cheat notes · Backstage

Backstage: cheat notes

Every module on one page, cut to what you need to remember: the idea, the commands, the pitfalls and a few questions to test yourself. Made for review after you have done the course, not instead of it. Back to the course.

00 Orientation: how to think about Backstage

The idea

Backstage is a framework, not a product: your platform team owns a TypeScript app built with it. Every feature hangs off the Software Catalog, so the portal is only as good as the data in it, and that data lives in Git next to the code.

Remember

  • A framework: @backstage/create-app generates a monorepo (React frontend, Node backend) that you own, upgrade and ship like any service.
  • The catalog is the product: who owns what, what depends on what. A wrong catalog makes every page wrong.
  • Git is the source: teams describe their software in catalog-info.yaml next to their code. The portal reads it; it does not own it.
  • Config before code: most behaviour is app-config.yaml (where the catalog reads, how people sign in). Code is for what config cannot do.
  • Who owns what: the platform team owns the portal repo (app, config, image, catalog/ with teams and systems, templates, custom actions). team-checkout owns what checkout is: its catalog-info.yaml and docs/.

Pitfalls

  • Spending weeks on plugins while half the components have no real owner. Rollouts stall on the catalog, not the code.
  • Fixing a wrong owner on the page. Pages render catalog data: fix the field in that service's catalog-info.yaml.

Test yourself

Answer in your head first, then open the answer.

What do you get from @backstage/create-app?

A TypeScript monorepo with a frontend and a backend that your team owns, builds and upgrades.

Where should the facts about checkout (owner, APIs, docs) live?

In the checkout repo next to the code, owned by team-checkout.

A page shows the wrong owner for a service. What is most likely wrong?

The catalog data: the owner field in that service's catalog-info.yaml.

Where module 00 leaves you: Nothing is running yet. Your details are saved in the course, every tool is checked, and the reference repo is on your laptop to copy from.

01 Create the app and find your way around

The idea

The generated app is small: one file wires the backend, one wires the frontend, one YAML file configures both, and everything else is npm packages. Learn to go from "I see this in the UI" to "this line adds it".

Remember

  • The monorepo: packages/app (React frontend), packages/backend (Node backend), plugins/ (your plugins), app-config.yaml (config for both), tied together by Yarn workspaces.
  • Backend plugins: one process. packages/backend/src/index.ts adds each plugin with backend.add(import(...)); each serves its API under /api/<pluginId>.
  • Frontend extensions: the app is built from extensions (pages, nav items, entity tabs). Installed plugin packages are discovered (app.packages: all); App.tsx adds the rest. The Visualizer lists them, e.g. page:catalog.
  • Ports: yarn start runs the frontend on 3000 and the backend on 7007.
  • Every request is authenticated: curl without a token gets 401, even on your laptop.
  • Pinned: create-app 0.9.3 generates Backstage 1.56.0, recorded in backstage.json.

Pitfalls

  • Commenting out a backend.add line that "looks unused": the portal starts fine, but the feature behind it answers 404 (here: templates list, but Choose fails, because the list comes from the catalog and running comes from the scaffolder).
  • Expecting data to survive a restart: the generated app uses in-memory SQLite and example data until module 02.

Commands

npx @backstage/[email protected] --path portalGenerate the app
yarn startStart frontend (3000) and backend (7007). Leave this terminal running
grep -n "target:" app-config.yamlWhere the catalog reads from (catalog.locations)
grep -n "scaffolder" packages/backend/src/index.tsWhich backend plugins are added
curl -s -o /dev/null -w "%{http_code}\n" localhost:7007/api/catalog/entities401 = alive, wants a token; 404 = plugin missing

Test yourself

Answer in your head first, then open the answer.

Where is a backend plugin turned on?

A backend.add(import(...)) line in packages/backend/src/index.ts.

Why does curl localhost:7007/api/catalog/entities return 401?

Every backend request needs a token: from a user, another plugin or a configured script.

The template list shows, but Choose fails with 404. Where is the problem?

The backend: the plugin serving /api/scaffolder is not loaded.

Your fastest debugging habit for a failing page?

Open the browser's network tab: /api/<pluginId>/... names the plugin, and 401, 403, 404 each point somewhere different.

Where module 01 leaves you: A portal repo on GitHub with the generated app, running on your laptop at http://localhost:3000 with example data and guest sign-in.

02 Configuration, secrets and Postgres

The idea

Everything is configuration: one YAML file in Git with placeholders, the values from the environment. Config files layer (objects merge, lists replace), secrets never reach Git or the browser, and scripts call the API with a configured token.

Remember

  • Config layers: app-config.yaml, then app-config.local.yaml if present, or the files given with --config. Later files override keys. Lists are replaced, not merged.
  • Environment variables: ${POSTGRES_PASSWORD} is read at backend start; ${POSTGRES_HOST:-localhost} has a default. A missing value is only an error when a plugin needs it.
  • Frontend visibility: each plugin's config schema marks what the browser may see. Secrets are never sent.
  • External access: scripts and CI call the API with a token in backend.auth.externalAccess; each token has a subject, so logs show who called.
  • Postgres: one database per plugin (backstage_plugin_catalog, backstage_plugin_scaffolder, ...).
  • .env stays on your laptop and is git-ignored. yarn start does not read it: load it with set -a && . ./.env && set +a in every terminal.

Pitfalls

  • A typo in a variable name on a fresh laptop: Missing required config value at 'backend.auth.externalAccess[0].options.token' and the backend shuts down. Plain config:check passes; --strict catches it. Keep a .env.example with every name.
  • A variable left over in your shell from an earlier session hides the same mistake. Test from a fresh terminal.
  • Pasting config:print --with-secrets output anywhere.

Commands

set -a && . ./.env && set +aLoad .env into this terminal
yarn backstage-cli config:print --frontendWhat the browser receives
yarn backstage-cli config:printThe backend view, secrets shown as <secret>
yarn backstage-cli config:check --strictFail on a missing required value, not only on a wrong shape
curl -H "Authorization: Bearer $PORTAL_API_TOKEN" localhost:7007/api/catalog/entitiesCall the API as a script
git check-ignore .envProve Git ignores the secrets file

Placeholders in app-config.yaml

backend:
  auth:
    externalAccess:
      - type: static
        options:
          token: ${PORTAL_API_TOKEN}
          subject: portal-cli
  database:
    client: pg
    connection:
      host: ${POSTGRES_HOST:-localhost}
      password: ${POSTGRES_PASSWORD}

Test yourself

Answer in your head first, then open the answer.

You set catalog.locations in app-config.local.yaml. What happens to the list in app-config.yaml?

It is replaced. Lists in a later file replace the whole list; objects merge.

Why is the database password safe in app-config.yaml?

The file only holds ${POSTGRES_PASSWORD}. The value comes from the environment and is never sent to the browser.

What does config:check --strict add?

It fails when a required value is missing, not only when the shape is wrong.

Where module 02 leaves you: 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 your portal repo; .env is not in Git.

03 The catalog: teams, systems and a real service

The idea

The catalog is small YAML entities connected by refs. You write one side of a relation (owner, system, providesApis, dependsOn) and the catalog stores both sides, which is what makes "who owns this, what breaks if it goes down" answerable.

Remember

  • Entity: apiVersion, kind, metadata (name, title, annotations), spec. Kinds here: Group, User, Domain, System, Component, API, Resource.
  • Entity ref: kind:namespace/name, e.g. group:default/team-checkout. Fields that imply the kind take the short form: owner: team-checkout.
  • Relations: spec.owner on a component stores ownedBy on it and ownerOf on the group. Refs are not checked: one to a missing entity is stored anyway.
  • Location: where the catalog reads YAML: a file next to the backend (paths relative to packages/backend) or a URL in Git. Each location can limit kinds with rules: - allow: [...].
  • Annotations plugins read: github.com/project-slug links the repo; backstage.io/techdocs-ref points at the docs. $text: ./openapi.yaml is resolved relative to the file's location.
  • Removing a location deletes its entities (the example guests group vanished).

Pitfalls

  • A typo in the owner (team-chekout): no error anywhere. The team page no longer lists the service and the owner link leads to a missing page. Use an owner picker in templates and a CI check that every ref resolves.
  • Making a person the owner. People leave; make the owner a team.

Commands

curl -s -H "Authorization: Bearer $PORTAL_API_TOKEN" "localhost:7007/api/catalog/entities?filter=kind=group" | jq -r '.[].metadata.name'List entities of one kind
curl -s -H "Authorization: Bearer $PORTAL_API_TOKEN" localhost:7007/api/catalog/entities/by-name/component/default/checkout | jq -r '.relations[] | .type + " " + .targetRef'The relations the catalog built
curl -s -H "Authorization: Bearer $PORTAL_API_TOKEN" -X POST -H 'Content-Type: application/json' localhost:7007/api/catalog/refresh -d '{"entityRef":"component:default/checkout"}'Refresh one entity now

A component that is connected

apiVersion: backstage.io/v1alpha1
kind: Component
metadata:
  name: checkout
  annotations:
    github.com/project-slug: OWNER/checkout
    backstage.io/techdocs-ref: dir:.
spec:
  type: service
  lifecycle: production
  owner: team-checkout          # -> ownedBy / ownerOf
  system: shop                  # -> partOf
  providesApis: [checkout-api]  # -> providesApi
  dependsOn: [resource:checkout-db]

Test yourself

Answer in your head first, then open the answer.

You write spec.owner: team-checkout. What does the catalog store?

ownedBy on the component and ownerOf on the group.

A component names an owner that does not exist. What happens?

The ref is stored, the link leads nowhere, and nothing reports it.

Why does $text: ./openapi.yaml work for the checkout repo?

It is resolved relative to the URL of catalog-info.yaml and read with the GitHub token.

Where module 03 leaves you: The catalog holds Acme's teams, you as a User, two systems, and checkout with its API and database. The example data is gone. Your checkout repo exists on GitHub.

04 Catalog at scale: let it find the repos

The idea

One URL per repo does not scale. A discovery provider scans your GitHub account on a schedule and reads every catalog-info.yaml it finds, so adding the file is the whole onboarding, and catalog rules stop team repos from inventing teams or users.

Remember

  • Entity provider vs location: the catalog reads locations in its processing loop; a provider runs on its own schedule and writes entities in (GitHub discovery, org imports).
  • A backend module, not a plugin: plugin-catalog-backend-module-github plus one backend.add line plugs into the catalog plugin. A new backend module needs a restart.
  • catalog.providers.github.<id>: organization, catalogPath, filters (branch, repository), schedule. The id names the scheduled task: github-provider:<id>:refresh.
  • Rules: catalog.rules lists what any source may add (Component, System, API, Resource, Location). Only the platform's own file locations widen it (Group, User, Domain, Template). Anything else is refused with a warning in the log.
  • Orphans: an entity its source no longer mentions. The default strategy delete removes it in a cleanup every 30 seconds.
  • Discovery reads main: a catalog-info.yaml only counts once it is merged.

Pitfalls

  • A team defining its own Group in its repo: the group never appears and nothing on the entity page says why. The log says not of an allowed kind for that location. Groups go to catalog/org.yaml by pull request.
  • Leaving create-app's own catalog-info.yaml owned by [email protected]: discovery finds it. Make it true before you merge.

Commands

yarn --cwd packages/backend add @backstage/plugin-catalog-backend-module-githubInstall the discovery module
curl -s -H "Authorization: Bearer $PORTAL_API_TOKEN" -X POST localhost:7007/api/catalog/.backstage/scheduler/v1/tasks/github-provider:teamRepos:refresh/triggerRun the scan now
... | jq -r '.metadata.annotations["backstage.io/managed-by-origin-location"]'Where an entity now comes from

Provider and rules

catalog:
  rules:
    - allow: [Component, System, API, Resource, Location]
  providers:
    github:
      teamRepos:
        organization: YOUR_GITHUB_USER
        catalogPath: /catalog-info.yaml
        filters: { branch: main, repository: '.*' }
        schedule:
          frequency: { minutes: 30 }
          timeout: { minutes: 3 }

Test yourself

Answer in your head first, then open the answer.

What is the difference between a location and an entity provider?

The catalog reads a location; a provider runs on its own schedule and writes entities in.

You remove a location. What happens to its entities by default?

They become orphans and are deleted within about 30 seconds.

Why can a discovered team repo not add a User?

catalog.rules does not allow User for general sources; only the platform's org.yaml location does.

Where module 04 leaves you: The catalog finds checkout and portal by scanning your GitHub account. Team repos may add components, APIs and resources, but not groups or users.

05 Sign in with GitHub

The idea

Sign-in turns a GitHub login into a catalog User, and the User into groups. The resolver is the lock: no matching User entity, no sign-in. The catalog is the source of who you are.

Remember

  • Auth provider: a backend module that talks to an identity provider, configured under auth.providers.<name>.<environment> (auth.environment picks which).
  • Sign-in resolver: maps the provider identity to a User entity. usernameMatchingUserEntityName looks for a User named like the GitHub username.
  • Ownership entities: the user plus the groups they are a member of, put in the token. "Owned" filters and permissions read them.
  • OAuth callback on port 7007: http://localhost:7007/api/auth/github/handler/frame. The backend finishes the flow.
  • Swap the sign-in page with a frontend module: createFrontendModule({ pluginId: 'app', extensions: [SignInPageBlueprint.make(...)] }), added to features in App.tsx.
  • A door, not a lock: with the allow-all permission policy every signed-in user can still do everything.

Pitfalls

  • Renaming a User in org.yaml to a "nicer" name: the next sign-in fails with unable to resolve user identity. Import users with an org provider and review org.yaml with CODEOWNERS.
  • Debugging OAuth first when login breaks. Most login problems are catalog problems: a missing, renamed or unimported User.
  • Forgetting to restart: the portal reads new environment variables only at start.

Commands

backend.add(import('@backstage/plugin-auth-backend-module-github-provider'));Add the GitHub provider to the backend
curl -s -H "Authorization: Bearer $PORTAL_API_TOKEN" "localhost:7007/api/catalog/entities?filter=kind=user" | jq -r '.[].metadata.name'The User names sign-in can resolve to

The provider and its resolver

auth:
  environment: development
  providers:
    github:
      development:
        clientId: ${AUTH_GITHUB_CLIENT_ID}
        clientSecret: ${AUTH_GITHUB_CLIENT_SECRET}
        signIn:
          resolvers:
            - resolver: usernameMatchingUserEntityName

Test yourself

Answer in your head first, then open the answer.

What does the sign-in resolver decide?

Which catalog User a provider identity becomes, and so whether the person may sign in at all.

Why is the OAuth callback URL on port 7007?

The auth backend plugin finishes the OAuth flow, and the backend listens on 7007.

How did you replace the sign-in page?

With a frontend module that overrides the app plugin's sign-in page extension.

Where module 05 leaves you: The portal only lets in people who have a User entity. You sign in with GitHub and the portal knows you are in platform and team-checkout.

06 Golden paths with software templates

The idea

A template makes the right way the easy way: a form (parameters), actions run on the backend (steps), and links (output). The service starts with an owner, a system, docs and a catalog entry from its first commit.

Remember

  • Parameters: JSON Schema with ui: hints, one form page per item. Each property becomes parameters.<name>. Pickers (OwnerPicker, EntityPicker, RepoUrlPicker) read the catalog.
  • Steps: each runs one action on the backend with the backend's credentials: fetch:template renders the skeleton, publish:github creates the repo, catalog:register adds it. Outputs feed later steps: steps['publish'].output.repoContentsUrl.
  • Skeleton: files with ${{ values.x }} placeholders, rendered with Nunjucks by fetch:template.
  • Templates are entities: registered through a location that allows Template, owned by the platform team.
  • Dry run: the template editor (Create, ⋮, Template Editor) runs steps without side effects and shows files, log and output. Actions without dry-run support are skipped.

Pitfalls

  • Removing a value from the fetch step that "looks unused": the template still runs, and the rendered file has system: with nothing after it. Dry-run every template in CI and assert key fields.
  • Shipping a clever first template nobody needs. Start with the service type teams create most often.

Commands

curl -s -H "Authorization: Bearer $PORTAL_API_TOKEN" "localhost:7007/api/catalog/entities?filter=kind=template" | jq -r '.[].metadata.name'Which templates the catalog has
gh repo view OWNER/cart --json name,descriptionThe repo the template created

The skeleton of a template

spec:
  parameters:
    - title: About the service
      required: [name, owner]
      properties:
        owner:
          type: string
          ui:field: OwnerPicker
          ui:options:
            catalogFilter: { kind: Group, spec.type: team }
  steps:
    - id: fetch
      action: fetch:template
      input:
        url: ./skeleton
        values:
          owner: ${{ parameters.owner }}
    - id: publish
      action: publish:github
    - id: register
      action: catalog:register
      input:
        repoContentsUrl: ${{ steps['publish'].output.repoContentsUrl }}

Test yourself

Answer in your head first, then open the answer.

Where do template steps run?

On the backend, with the backend's credentials, one action per step.

What does a dry run in the template editor do?

Runs the steps without side effects and shows the rendered files, the log and the output.

Why does the owner field use OwnerPicker with a catalogFilter?

It only offers teams that exist, so an owner typo is impossible.

Where module 06 leaves you: The portal offers one template, Node.js service. A cart repo exists on GitHub, created by the template and already in the catalog, owned by team-checkout.

07 Your own scaffolder action

The idea

Company rules need your own code, and the smallest piece of backend code is a module: it plugs into one plugin (the scaffolder) through its extension point and gets services like the catalog injected.

Remember

  • Backend module: createBackendModule({ pluginId: 'scaffolder', moduleId }), added with backend.add. yarn new --select scaffolder-backend-module generates and wires it.
  • Extension point: what a plugin offers modules. scaffolderActionsExtensionPoint.addActions(...) registers actions.
  • Service refs in deps: catalogServiceRef, loggerServiceRef, databaseServiceRef; the backend injects them.
  • The action: createTemplateAction({ id, description, schema: { input, output }, handler }). Schemas are Zod; the Installed Actions page shows them. ctx.output(...) feeds later steps.
  • On whose behalf: read the catalog with ctx.getInitiatorCredentials(), so permissions apply to the user running the template.
  • Dry run: supportsDryRun: true only for read-only actions. An action that writes checks ctx.isDryRun and skips the write.

Pitfalls

  • Deleting supportsDryRun: true: tests and types still pass, but the dry run logs Skipping because acme:team:lookup does not support dry-run and the README says Owned by ****. Test for it.
  • Throwing a generic Error for bad input: use InputError so the user sees a clear message, and fail before anything is created (put the lookup step first).

Commands

yarn new --select scaffolder-backend-module --option moduleId=acmeGenerate the module and wire it into the backend
cd plugins/scaffolder-backend-module-acme && yarn test --watchAll=falseRun its tests
yarn tscType-check the whole repo

A module that adds an action

export const scaffolderModuleAcme = createBackendModule({
  pluginId: 'scaffolder',
  moduleId: 'acme',
  register({ registerInit }) {
    registerInit({
      deps: {
        scaffolderActions: scaffolderActionsExtensionPoint,
        catalog: catalogServiceRef,
      },
      async init({ scaffolderActions, catalog }) {
        scaffolderActions.addActions(createTeamLookupAction({ catalog }));
      },
    });
  },
});

Test yourself

Answer in your head first, then open the answer.

How does your module get the catalog client?

It lists catalogServiceRef in deps and the backend injects it.

Why is the action a module of the scaffolder and not its own plugin?

It extends the scaffolder through its extension point and has no API of its own.

Why read the catalog with ctx.getInitiatorCredentials()?

The read happens as the user who runs the template, so permissions apply to them.

Where module 07 leaves you: The scaffolder has your acme:team:lookup action with three passing tests. New services say "Owned by Team Checkout" in their README and docs.

08 Docs next to the code with TechDocs

The idea

Docs live in the repo, are built by MkDocs and shown on the entity page next to the owner and the API, so they change in the same pull request as the code. One annotation links the entity to its docs.

Remember

  • backstage.io/techdocs-ref: dir:. points at the folder with mkdocs.yml, relative to catalog-info.yaml. MkDocs then reads docs_dir (default docs).
  • Builder and generator: the backend fetches the repo and runs MkDocs. runIn: docker uses the spotify/techdocs image; runIn: local needs MkDocs installed.
  • Publisher: where built sites are stored: the local disk here, a bucket (S3, GCS, Azure) in production.
  • Recommended in production: build in each repo's CI with techdocs-cli generate and publish to a bucket; the portal only reads.
  • Templates give docs from day one: the skeleton includes mkdocs.yml and docs/.

Pitfalls

  • "Fixing" the annotation to dir:./docs because the Markdown is there: The path '/input/docs' isn't an existing directory. It must point at the mkdocs.yml folder.
  • Making docs builds the portal's problem: a broken build should fail the pull request in the repo's CI.

Commands

curl -s -H "Authorization: Bearer $PORTAL_API_TOKEN" -N localhost:7007/api/techdocs/sync/default/component/checkoutRebuild one entity's docs now and stream the log
docker images spotify/techdocsThe image that built them

Test yourself

Answer in your head first, then open the answer.

What does backstage.io/techdocs-ref: dir:. point at?

The folder with mkdocs.yml, relative to catalog-info.yaml.

Why does the portal need Docker for docs on your laptop?

With runIn: docker the generator runs MkDocs in the spotify/techdocs image.

What is the recommended production setup?

Build in each repo's CI, publish to a bucket; the portal only reads.

Where module 08 leaves you: Both services have a TechDocs tab with their docs. You know where the build runs and what the annotation does.

09 Ship it: an image, production config and an upgrade

The idea

Production is one Node process that serves the built frontend and the API, configured by environment variables. Whatever the portal reads from disk (catalog files, templates) or runs (MkDocs) must be in the image. Upgrades are part of shipping: small and monthly.

Remember

  • Host build: yarn build:backend builds frontend and backend on your machine; the Dockerfile only copies the result in. yarn build-image tags it backstage.
  • One port: in production the backend serves the frontend, so app.baseUrl and backend.baseUrl are the same URL (7007).
  • Config layering: the container starts with --config app-config.yaml --config app-config.production.yaml. Objects merge; lists such as catalog.locations are replaced, so the production file repeats every location (paths relative to /app).
  • What goes in the image: COPY the catalog and templates folders, and install MkDocs (mkdocs-techdocs-core) for runIn: local; there is no Docker in the container.
  • A release is a set of package versions: yarn backstage-cli versions:bump --release X moves every @backstage/* package together. Check the upgrade helper for changes to generated files.

Pitfalls

  • Trimming a COPY line that "only copies YAML": sign-in fails with unable to resolve user identity because the catalog has no Users. The log says file /app/catalog/org.yaml does not exist.
  • Testing an image against an old database: entities survive there and hide what is missing. Smoke-test against an empty database.
  • Giving Docker 2 GB: the build runs out of memory. Give it at least 4 GB.

Commands

yarn install --immutable && yarn tsc && yarn build:backendBuild on the host
yarn build-imageBuild the backstage image
docker run --rm -p 7007:7007 -e POSTGRES_HOST=... -e PORTAL_API_TOKEN ... backstageRun it; variables without = pass through from your shell
yarn backstage-cli versions:bump --release 1.56.3Upgrade every Backstage package to one release
jq -r .version backstage.jsonWhich release the app is on

Test yourself

Answer in your head first, then open the answer.

You list catalog.locations in app-config.production.yaml. What happens to the ones in app-config.yaml?

They are replaced. The production list is the only list.

Why did the broken image work fine against the old database?

Entities stay in the database; only a fresh database showed the gap.

How should you upgrade Backstage?

Run versions:bump regularly, check the upgrade helper, then build and test.

Where module 09 leaves you: A backstage image that runs the whole portal on one port, with your catalog, template and docs, on Backstage 1.56.3.

10 Capstone: onboard team-payments

The idea

Onboarding a team is what a portal is for: the platform adds a Group and a System by pull request, the team creates its service from the template, and the image proves it all works from Git alone.

Remember

  • Platform: in catalog/org.yaml add the team-payments Group (parent acme), add it to acme's children, add yourself via memberOf; in catalog/systems.yaml add the payments System (owner team-payments, domain commerce).
  • Team: Create → Node.js service, owner Team Payments, system payments. The template and discovery do the rest; the team needs nothing from the platform.
  • Prove it: rebuild the image and run it against a fresh database; payments-api must show ownedBy group:default/team-payments and partOf system:default/payments.
  • Check acceptance criteria yourself before opening the solution.

Pitfalls

  • Adding the Group but not the parent's children or your memberOf: relations look half-built and you cannot pick the team as yours.
  • Typing people into YAML for every team: the next step is an org provider that imports teams and users.

Commands

curl -s -H "Authorization: Bearer $PORTAL_API_TOKEN" localhost:7007/api/catalog/entities/by-name/component/default/payments-api | jq -r '.relations[] | .type + " " + .targetRef'The new service's relations
docker rm -f portal-prod-db && docker network rm portal-netTear down

Test yourself

Answer in your head first, then open the answer.

What did onboarding team-payments need from the platform team?

A Group, a System and a membership in org.yaml and systems.yaml, through a pull request.

What did team-payments need from the platform team to create a service?

Nothing: the template and discovery do the rest.

What would you automate next with a third team?

Importing teams and users with an org provider (GitHub or your identity provider).

Where module 10 leaves you: team-payments exists, owns payments-api and the payments system, and you are a member. The image runs it all.