Backstage manifests in Kubernetes

Challenge

For a microservice system I was tasked with building a mechanism to transfer data between environments from diverse storage backends.

As not all services are rolled out in every environment, I had to find a way to understand an individual environment from within.
My idea: a standardized data sheet per service in Kubernetes, deployed together with the service.

Idea

Having worked with Backstage before I decided to use its catalog to provide the needed standardized information and get a developer platform as a positive side effect.

Quoting from the Backstage Software Catalog page

The Backstage Software Catalog is a centralized system that keeps track of ownership and metadata for all the software in your ecosystem (services, websites, libraries, data pipelines, etc). The catalog is built around the concept of metadata YAML files stored together with the code, which are then harvested and visualized in Backstage.

These metadata YAML are very close to Kubernetes manifests and could easily be extended with the needed information.

So simply kubectl apply -f catalog-info.yaml?

Yeah well, no, you need CRDs for Kubernetes to understand, check and accept the data; but the metadata YAML were never intended to be put into k8s and thus no CRD exist.

This is why I wrote1 the small tool alteos-gmbh/backstage-crd-gen

  1. Fetch Schemas: Downloads JSON schemas from the Backstage GitHub repository2 for the specified version
  2. Parse Schemas: Parses JSON schemas and resolves $ref references
  3. Generate Go Types: Creates Go structs with kubebuilder markers for each entity type
  4. Generate CRDs: Runs controller-gen to produce OpenAPI v3 CRD YAML files

It’s always in the details

Did I say the YAML are very close to k8s manifests? Somehow, some time back in the mists of Backstage’s creation, it was decided to add information to top-level metadata3, which is NOT supported by Kubernetes.

Also, a kind:API requires a spec.definition as string and Backstage’s processors support substitutions like $text in the descriptor format4.
This breaks when applying the raw catalog-info.yaml into Kubernetes.

There is help for pre-processing the manifests by using kustomize, but beware, it has its own sharp edges and pitfalls.

See the excerpt below and fully in alteos-gmbh/backstage-crd-gen/example. Happy patching! (this works for us. But as usual, YMMV!)

apiVersion: kustomize.config.k8s.io/v1beta1
kind: Kustomization
resources:
  - catalog-info.yaml

# The below is needed to strip Backstage.io's catalog down to kubernetes manifests
# Note: for the `remove` operation, the target MUST exist (https://datatracker.ietf.org/doc/html/rfc6902#section-4.2)
patches:
  # metadata.tags
  - patch: |-
      - op: add
        path: /metadata/tags
        value: foo
      - op: remove
        path: /metadata/tags
    target:
      group: &group backstage.io
      version: &version v1alpha1
  # metadata.links
  - patch: |-
      - op: add
        path: /metadata/links
        value: foo
      - op: remove
        path: /metadata/links
    target:
      group: *group
      version: *version
  # metadata.description
  - patch: |-
      - op: add
        path: /metadata/description
        value: foo
      - op: remove
        path: /metadata/description
    target:
      group: *group
      version: *version
  # metadata.title
  - patch: |-
      - op: add
        path: /metadata/title
        value: foo
      - op: remove
        path: /metadata/title
    target:
      group: *group
      version: *version
  # --- kind:API ------------------------------------------------------
  # APIs may have `spec.definition` references as $text, $json, $yaml, but CRD defines `spec.definition` as string
  # Possible strategies:
  #  - none needed bc/ you define the API inline

  #  - have a specific `move` (if all use same reference)
  - patch: |-
      - op: move
        from: /spec/definition/$text
        path: /spec/definition
    target:
      group: *group
      version: *version
      kind: API

  #  - as `spec.definition` is required for `kind:API`, replace with a comment
  - patch: |-
      - op: replace
        path: /spec/definition
        value: placeholder to satisfy kubernetes CRD
    target:
      group: *group
      version: *version
      kind: API

Screenshots

$> cd backstage-crd-gen

$> make
go mod download
go mod tidy
go fmt ./...
golangci-lint run ./...
0 issues.
go test ./... -race
?       github.com/alteos-gmbh/backstage-crd-gen        [no test files]
?       github.com/alteos-gmbh/backstage-crd-gen/cmd    [no test files]
?       github.com/alteos-gmbh/backstage-crd-gen/internal/versions      [no test files]
ok      github.com/alteos-gmbh/backstage-crd-gen/pkg/fetch      (cached)
ok      github.com/alteos-gmbh/backstage-crd-gen/pkg/generate   (cached)
ok      github.com/alteos-gmbh/backstage-crd-gen/pkg/schema     (cached)
go build -ldflags "-X github.com/alteos-gmbh/backstage-crd-gen/internal/versions.Version=399c110 -X github.com/alteos-gmbh/backstage-crd-gen/internal/versions.BuildTime=2026-01-17T12:58:25Z" -o bin/backstage-crd-gen .

$> ./bin/backstage-crd-gen generate
Using controller-gen version: 0.20.0
Resolved latest version to: v1.46.3
Fetching schemas for Backstage v1.46.3...
Fetched 11 schemas
Generating Go types...
Generating CRDs...
CRDs written to: crds-gen/v1.46.3

$> kubectl apply -f crds-gen/v1.46.3
customresourcedefinition.apiextensions.k8s.io/apis.backstage.io created
customresourcedefinition.apiextensions.k8s.io/components.backstage.io created
customresourcedefinition.apiextensions.k8s.io/domains.backstage.io created
customresourcedefinition.apiextensions.k8s.io/groups.backstage.io created
customresourcedefinition.apiextensions.k8s.io/locations.backstage.io created
customresourcedefinition.apiextensions.k8s.io/resources.backstage.io created
customresourcedefinition.apiextensions.k8s.io/systems.backstage.io created
customresourcedefinition.apiextensions.k8s.io/users.backstage.io created

$> kubectl kustomize ./example | kubectl apply -f -
api.backstage.io/example-grpc-api created
component.backstage.io/example-website created
resource.backstage.io/example-db created
system.backstage.io/examples created

$> kubectl get apis.backstage.io
NAME               TYPE   OWNER    LIFECYCLE
example-grpc-api   grpc   guests   experimental

$> kubectl describe resources.backstage.io example-db
Name:         example-db
Namespace:    default
Labels:       alteos.com/env-sync-relevant=true
Annotations:  <none>
API Version:  backstage.io/v1alpha1
Kind:         Resource
Metadata:
  Creation Timestamp:  2026-01-17T12:59:27Z
  Generation:          1
  Resource Version:    534
  UID:                 3db521fb-ad75-47ea-908f-1c36cc4d204b
Spec:
  Alteos:
    Consumption:  producer
    Pii:
      Default:
        Method:  scrub
      Items:
        Field:  bar
        Table:  foo
        Field:  adsfsdf
        Table:  sdafadsf
    Secret:
      Host:      ALTEOS_HOST
      Name:      ALTEOS_NAME
      Password:  ALTEOS_PASSWORD
      Port:      ALTEOS_PORT
      Username:  ALTEOS_USER
    Type:        rds
  Owner:         guests
  Type:          database
Events:          <none>

Footnotes

  1. very much assisted by Claude Code 

  2. Backstage Entity schemas 

  3. Backstage Common metadata 

  4. Backstage Substitutions in descriptors