The Exam Blueprint · D3 · Self-Service & Developer Portals · 25%

Self-Service & Developer Portals

This is where the whole course has been heading. Everything so far — GitOps, pipelines, CRDs and operators — was laying track for this moment: a developer clicks one button, or writes a dozen lines of YAML, and gets a running service — repo, pipeline, namespace, a real cloud database, dashboards, on-call — in minutes, safely, without a single ticket. Two engines make it real, Crossplane (cloud infrastructure as Kubernetes APIs) and Backstage (the developer portal); templates, GitOps, and policy compose them into one paved golden path.

☺ Explain it like I’m 10

You know a vending machine? You press B4 and a snack drops out — you don’t walk into the factory or ask a worker to make you one. A self-service platform is a vending machine for engineers. Behind the glass sits a lot of machinery — a robot that builds cloud databases, a conveyor belt that ships code, guards that check nothing dangerous comes out — but from the front, Dot the Duck just presses a button and out comes a whole working service. Our job is to stock the machine.

🦋🐿️Your hosts for this topic: Mira the Butterfly & Nutty the Squirrel — Mira hides the scary YAML behind one beautiful button, Nutty makes sure it’s a button developers actually asked for — and front and centre is 🦆 Dot the Duck, the customer this whole machine is built to serve.

The self-service goal — one button, not one ticket

☺ Like you’re 10: The old way was “fill in a form and wait days for a grown-up.” The new way is “press the button and get it yourself, right now — safely.”

Picture the old world. Dot needs a database, so she files a ticket. It sits in a queue. Two days later a busy engineer hand-provisions an RDS instance in the console — guessing at the size, forgetting backups — then pastes the connection string into a chat. Nobody remembers what got created or why; every database is a different snowflake. That’s Ticket Swamp: the platform team is the queue, and Dot waits.

Self-service flips it. The platform team stops doing the provisioning and paves a path instead. Dot declares “I want a small Postgres,” and the platform provisions it the same correct way every time — encrypted, backed up, private subnet, credentials delivered right where her app runs. And the unit isn’t just a database: a well-stocked golden path hands over a whole paved project in one motion:

Dot needs…Ticket Swamp (toil)Golden Path (self-service)
A new serviceCopy-paste an old repo, hand-wire CI, hope you didn’t miss anythingClick “New Service” → repo + pipeline + namespace scaffolded and registered
A databaseFile a ticket → wait days → a hand-clicked snowflake instanceWrite a tiny Database claim → a right-sized, backed-up instance in minutes
An environmentEmail ops, wait for a namespace and quotasTemplated namespace with quotas, RBAC, and network policy already attached
Dashboards & on-callRemember to set them up later (nobody does)Wired in automatically — golden signals and alerts from day one
Who does the workThe platform team, one ticket at a timeThe developer, on a path the platform team paved once
🦆 Dot’s-eye view

“I don’t want to become a cloud expert to ship a feature. I want to say ‘small Postgres, please’ and get one that’s already secure, already backed up, already plugged into my app — without learning what a subnet group is. Do that and I’ll never route around you. Make me file a ticket and I’ll find a shortcut you won’t like.”

◆ Key idea

Self-service is the moment the platform team stops being a bottleneck and becomes a force multiplier: pave the path once, and developers walk it thousands of times. The platform’s real product is its API and its portal — the button Dot presses — everything else is machinery behind the glass.

Crossplane — cloud infrastructure as Kubernetes APIs

☺ Like you’re 10: Crossplane teaches Kubernetes a new word — “database” — and a tireless robot builds the real cloud database to match, then rebuilds it if anyone breaks it.

Recall from Platform APIs & Operators: a CustomResourceDefinition plus a controller teaches the cluster a new kind of object, and a control loop keeps reality matching your declaration. Crossplane (a CNCF project) aims that at the cloud — your Kubernetes cluster becomes a universal control plane where an RDS database, an S3 bucket, or a GCP network is just another object, declared in YAML and continuously reconciled. Delete the RDS instance in the console and Crossplane spots the drift and recreates it — GitOps self-heal for infrastructure. Cloud resources get the same Git-driven, versioned, self-healing model as your apps — one control plane, one audit trail.

But raw cloud resources are still low-level: an RDS instance has dozens of fields Dot shouldn’t have to think about. Crossplane’s real power is composition — the platform team wraps that complexity into a simple, opinionated API developers actually want.

The five pieces

☺ Like you’re 10: Five Lego parts: a plug-in for each cloud, tiny bricks for each resource, a shape for your new button, a recipe behind the button, and the button itself.

PieceWhat it isWho touches it
ProviderA plug-in that installs controllers + CRDs for an external API (AWS, GCP, Azure, even Kubernetes itself). Configured with a ProviderConfig holding credentials.Platform team
Managed Resource (MR)A high-fidelity CRD for one external resource — e.g. an RDS Instance, a Subnet, a SecurityGroup. One MR ≈ one cloud thing.Platform team (indirectly)
Composite Resource Definition (XRD)Defines a new composite API — its schema and its friendly Claim name. This is the shape of the button.Platform team
CompositionThe recipe: “when someone asks for this composite, create these Managed Resources,” mapping simple inputs to real cloud fields.Platform team
ClaimA small, namespaced resource a developer creates to request one instance of the composite — e.g. kind: Database. The button itself.🦆 Developer

The mental model: the platform team authors the XRD (the API shape) and Composition (the recipe) once; the developer writes only the Claim. The composite (the “XR”) is cluster-scoped and holds all the machinery; the Claim is the tiny namespaced handle Dot actually holds.

🦋 Platform team authors once: XRD (the API shape) + Composition (the recipe) Claim: Database namespaced · dev writes XDatabase (XR) cluster-scoped composite Managed Resources • RDS Instance • Subnet group • Conn Secret Provider ☁️ Cloud Composition expands one → many 🦆 Dot writes only the Claim 🤖 Recon reconciles the real cloud to match the Claim — forever.

What the platform team authors

☺ Like you’re 10: One file describes the button’s shape; the other is the recipe that runs when you press it.

The XRD declares the new API and, because it sets claimNames, also creates the friendly namespaced Database kind. Its OpenAPI schema is where you decide what developers are allowed to ask for — here, a size that must be one of three values. The Composition is the recipe: it maps that simple size onto a real RDS instance class, adds networking, and writes connection details to a Secret.

# 1) XRD — defines the composite API + the developer-facing Claim
apiVersion: apiextensions.crossplane.io/v1
kind: CompositeResourceDefinition
metadata:
  name: xdatabases.platform.acme.io
spec:
  group: platform.acme.io
  names:      { kind: XDatabase, plural: xdatabases }   # composite (cluster-scoped)
  claimNames: { kind: Database,  plural: databases }     # what Dot writes (namespaced)
  versions:
    - name: v1alpha1
      served: true
      referenceable: true
      schema:
        openAPIV3Schema:
          type: object
          properties:
            spec:
              type: object
              properties:
                parameters:
                  type: object
                  properties:
                    size:                                 # the ONLY choice devs get
                      type: string
                      enum: [small, medium, large]
                    engineVersion: { type: string }
                  required: [size]
              required: [parameters]
---
# 2) Composition — the recipe: map size → a real RDS instance + networking + secret
apiVersion: apiextensions.crossplane.io/v1
kind: Composition
metadata:
  name: rds-postgres
spec:
  compositeTypeRef: { apiVersion: platform.acme.io/v1alpha1, kind: XDatabase }
  writeConnectionSecretsToNamespace: crossplane-system
  resources:
    - name: instance
      base:
        apiVersion: rds.aws.upbound.io/v1beta1
        kind: Instance
        spec:
          forProvider:
            region: us-east-1
            engine: postgres
            username: masteruser
            autoGeneratePassword: true      # provider generates the master password…
            passwordSecretRef:              # …and stores it in this Secret
              namespace: crossplane-system
              name: db-master
              key: password
            allocatedStorage: 20
            storageEncrypted: true          # secure-by-default, baked into the recipe
            publiclyAccessible: false
            backupRetentionPeriod: 7
            dbSubnetGroupNameSelector:      # private networking, wired for the dev
              matchControllerRef: true
      patches:
        - fromFieldPath: spec.parameters.size          # small/medium/large → a class
          toFieldPath: spec.forProvider.instanceClass
          transforms:
            - type: map
              map: { small: db.t3.micro, medium: db.t3.medium, large: db.r5.large }
        - fromFieldPath: spec.parameters.engineVersion
          toFieldPath: spec.forProvider.engineVersion
        - fromFieldPath: metadata.name                 # one master Secret per database
          toFieldPath: spec.forProvider.passwordSecretRef.name
          transforms:
            - type: string
              string: { type: Format, fmt: "%s-master" }
      connectionDetails:
        - name: host
          fromConnectionSecretKey: endpoint
        - name: password
          fromConnectionSecretKey: password
    - name: subnet-group
      base:
        apiVersion: rds.aws.upbound.io/v1beta1
        kind: SubnetGroup
        spec:
          forProvider: { region: us-east-1, subnetIdSelector: { matchLabels: { tier: private } } }

Notice what just happened: storageEncrypted, private subnets, and 7-day backups are baked into the recipe. Dot cannot create an unencrypted, publicly-exposed database on this path — the guardrail is the golden path.

One note on versions, because Crossplane has moved: the inline spec.resources list above is the classic patch-and-transform style, and newer Crossplane releases prefer a function pipeline instead — spec.mode: Pipeline with a spec.pipeline of composition functions (one of which is the patch-and-transform function). Crossplane 2.x also lets composite resources be namespaced directly, which makes a separate Claim optional. Learn the division of labour rather than the exact syntax: the platform team owns the API shape and the recipe, the developer writes one small request.

What the developer writes

☺ Like you’re 10: After all that setup, Dot’s whole part is a few lines — and a real cloud database appears.

Dot writes a Database Claim in her namespace, pointing writeConnectionSecretToRef at a Secret name; Crossplane provisions everything behind the XR and drops the host, username, and password into that Secret — right where her app’s pods mount it.

# The ENTIRE thing a developer writes — a dozen lines for a real, secure cloud database
apiVersion: platform.acme.io/v1alpha1
kind: Database
metadata:
  name: orders-db
  namespace: checkout
spec:
  parameters:
    size: small
    engineVersion: "15"
  writeConnectionSecretToRef:
    name: orders-db-conn        # app mounts host/user/password from this Secret
⚠ Watch out

Crossplane holds the keys to your cloud, so treat it that way. Give each ProviderConfig least-privilege credentials, not god-mode. Note that kubectl delete on a Claim deletes the real RDS instance (via finalizers) — set a sensible deletionPolicy and guard prod claims. And don’t over-abstract: a Composition that models every knob becomes its own platform to maintain. Start with the three sizes developers actually ask for, and rehearse on a throwaway cluster before pointing it at a real cloud account.

Backstage — the developer portal (the storefront)

☺ Like you’re 10: Crossplane is the vending machine’s guts; Backstage is the glass front with the buttons and pictures so you know what to press.

Crossplane gives you the API; Backstage gives you the front door. A CNCF project first built at Spotify, it’s an open framework for an Internal Developer Portal: one web UI where developers discover what exists, spin up new things, and read the docs — the storefront on the golden path.

◆ IDP vs IDP — don’t confuse them

Both get abbreviated “IDP,” and the exam wants you to tell them apart. The Internal Developer Platform is the whole paved road — clusters, GitOps, Crossplane, policy, pipelines. The Internal Developer Portal (Backstage) is just the UI on top. Portal ≠ platform: a portal with no paved road behind it is a menu in a restaurant with no kitchen — lots of buttons, nothing gets cooked.

The Software Catalog

☺ Like you’re 10: A living map of everything the company has built, and who to ask about each thing.

Backstage’s Software Catalog is a registry of every component, API, resource, and team. Each is described by a catalog-info.yaml that lives next to the code, so the map updates itself as services change. Annotations wire an entity to its docs, CI, Argo CD app, and dashboards — so one page becomes the front door to everything about a service.

# catalog-info.yaml — lives in the service repo; registers it in the portal
apiVersion: backstage.io/v1alpha1
kind: Component
metadata:
  name: checkout
  description: Handles cart checkout for the storefront
  annotations:
    backstage.io/techdocs-ref: dir:.          # docs render in the portal
    argocd/app-name: checkout                 # link its GitOps app
    grafana/tag-selector: checkout            # pull in dashboards tagged "checkout"
spec:
  type: service
  lifecycle: production
  owner: payments-team                          # no more "who owns this?!"
  system: commerce

Software Templates — the “New Service” button

☺ Like you’re 10: A fill-in-the-blanks form that, on submit, builds a whole new project for you.

This is the button Dot presses. A Backstage Software Template (run by the scaffolder) has two halves: parameters (the form the developer fills in) and steps (the actions that run on submit — scaffold, publish, register, open a PR). One template turns “start a new service the right way” from a day of copy-pasting into a 30-second form.

apiVersion: scaffolder.backstage.io/v1beta3
kind: Template
metadata:
  name: golden-path-service
  title: New Service (Golden Path)
  description: Repo + pipeline + namespace + database, wired to the golden path
spec:
  owner: platform-team
  type: service
  parameters:                                   # ── the form Dot sees ──
    - title: Service details
      required: [name, owner]
      properties:
        name:  { title: Name, type: string, pattern: "^[a-z0-9-]+$" }
        size:  { title: Database size, type: string, enum: [small, medium, large] }
        owner:
          title: Owner team
          type: string
          ui:field: OwnerPicker                # picks a Group from the catalog
  steps:                                         # ── what runs on submit ──
    - id: fetch
      name: Scaffold from skeleton
      action: fetch:template
      input:
        url: ./skeleton                          # Dockerfile, CI, k8s manifests, a Database claim
        values: { name: "${{ parameters.name }}", size: "${{ parameters.size }}" }
    - id: publish
      name: Create the Git repo
      action: publish:github
      input:
        repoUrl: "github.com?owner=acme&repo=${{ parameters.name }}"
    - id: register
      name: Register in the catalog
      action: catalog:register
      input:
        repoContentsUrl: "${{ steps.publish.output.repoContentsUrl }}"
        catalogInfoPath: /catalog-info.yaml
  output:
    links:
      - title: Open in catalog
        entityRef: "${{ steps.register.output.entityRef }}"

The skeleton that template fetches can contain a Deployment, a CI config, and the very Database claim from earlier — so “new service” and “new database” arrive together, pre-wired.

TechDocs & plugins

☺ Like you’re 10: The instruction booklet and all the gauges live on the same page as the button.

TechDocs renders docs-as-code (Markdown in the repo) right inside the portal, so documentation stays next to what it documents. Plugins pull the rest of the platform into one pane of glass — CI status, Argo CD health, cost from OpenCost, security scans, on-call from PagerDuty — so a developer never has to remember which of eight tools holds the answer. That single pane makes the portal feel like a product, not a directory.

🦆 Dot’s-eye view

“I open one website, click ‘New Service,’ type a name, pick ‘small database,’ and hit create. A minute later there’s a repo with my name on it, a green pipeline, a namespace, a database whose password is already in my app, and a dashboard link — docs on the same page. I never touched a cloud console, a ticket, or a wiki. This is what ‘paved road’ means.”

Describing the workload — Score

☺ Like you’re 10: One simple recipe card for your app that works whether you’re cooking at home or in the big restaurant kitchen.

One more piece. Developers still have to describe their workload somewhere — its containers and dependencies. Score (score.dev, a CNCF Sandbox project) is a platform-agnostic workload spec: the developer writes one score.yaml, and a Score implementation translates it into whatever the target platform speaks — Kubernetes manifests, Helm, or Docker Compose on a laptop. It separates what the app needs (the developer’s concern) from how this platform provides it (the platform’s concern), so the same spec runs locally and in prod.

# score.yaml — one workload spec, portable across platforms
apiVersion: score.dev/v1b1
metadata:
  name: checkout
containers:
  checkout:
    image: acme/checkout:1.4.3
    variables:
      DB_HOST: ${resources.db.host}      # resolved by the platform, not hardcoded
resources:
  db:
    type: postgres                        # "I need a postgres" — platform decides how

You won’t be asked to memorise Score’s schema, but recognise the shape: a developer-owned description of intent that a platform fulfils. It’s the same theme as a Crossplane Claim — declare what you need, let the platform decide how.

Automation frameworks — how one click provisions everything

☺ Like you’re 10: No single machine does the whole job — the button, the conveyor belt, the database-robot, and the safety guards are all wired into one smooth line.

The domain’s second competency turns on one insight: self-service isn’t one tool — it’s a composition of tools. The automation framework wires them into one hands-off flow. Follow one click:

  1. Dot fills in the Backstage template and hits create.
  2. The scaffolder generates the service and opens a pull request to the config repo — it doesn’t touch the cluster directly.
  3. A teammate approves; on merge, Argo CD / Flux reconciles the new manifests.
  4. Among those manifests are a Crossplane Database claim, a Deployment, a ResourceQuota, a NetworkPolicy, and a ServiceMonitor.
  5. Crossplane provisions the cloud database and writes the connection Secret; Prometheus starts scraping via the ServiceMonitor; policy attaches at admission.

Every step is declarative, versioned, reviewable, and reversible — roll back by reverting the PR. No human opens a cloud console. That’s why the pieces only shine together: templates without GitOps are just a code generator; Crossplane without a portal is still expert-only; GitOps without policy is fast but unsafe. Compose all four and “click → everything provisioned” becomes a diff you can read, not a slogan.

The end-to-end golden path (the capstone)

☺ Like you’re 10: Here’s the whole machine, front to back, with every character from the course doing their one job.

The payoff for the whole course: trace Dot’s single click to a running, observed, guard-railed service — and notice how every earlier lesson shows up exactly once.

🦆 Dot clicks New Service Backstage scaffolder → PR Config repo desired state (Git) 🤖 Argo CD GitOps sync Crossplane provisions infra Deployment + ns app running ☁️ Cloud DB + conn Secret Attached automatically to every golden-path service: 🐢 Policy, quotas & RBAC — checked on the PR and enforced at admission 🐘 Dashboards, alerts & on-call — wired in without anyone remembering to

Every character earns their keep: Nutty scoped the path to what Dot needs, Mira built the button and the Claim, Benny laid the GitOps rails, Recon reconciles app and cloud, Ellie’s watchtower lights up automatically, and Timmy’s guardrails ride along on the same PR. One diagram, the whole course.

🦆 Dot’s-eye view

“It’s 11:15; I decided to build a new service at 11:00. It’s already in the catalog, its pipeline is green, its database is live, its latency on a dashboard. I haven’t talked to a single person or filed a single ticket. I’m going to lunch — and shipping the feature this afternoon.” That is the destination this whole course was walking toward.

Guardrails — self-service is not a free-for-all

☺ Like you’re 10: Self-service isn’t “do whatever you want.” It’s “here are three safe buttons” — the dangerous levers aren’t on the machine at all.

The tempting-but-wrong version of self-service is “give everyone cluster-admin and let them create anything.” That’s not self-service; it’s abdication, and it ends in a 2am incident. Real self-service is a narrow, paved, opinionated path: Dot can create a Database claim in sizes small/medium/large — not an arbitrary RDS instance in any region at any size. The guardrails are the same primitives from the rest of the course:

GuardrailWhat it enforces on the self-service path
RBACDevelopers may create Claims in their own namespace — not raw Managed Resources or cluster-scoped objects. (Security & Policy)
ResourceQuota / LimitRangeCaps how much CPU, memory, and storage a tenant namespace can consume — no runaway self-service bill. (Reference architecture)
Policy engines (Kyverno / OPA Gatekeeper)Admission control validates every Claim and PR: allowed sizes only, required owner labels, blocked regions, signed images. (Security & Policy)
The Composition itselfBakes in secure defaults — encryption, private subnets, backups — so a developer can’t forget them.
Cost right-sizing (Sol)The size enum maps to budgeted, right-sized instance classes, not a blank cheque.

Done well, the golden path is popular because it’s both the easiest and the safest way to get things done — you make the right thing the easy thing, so nobody wants the shortcut. The guardrails aren’t a tax on self-service; they’re what lets you hand developers the keys at all.

⚠ Watch out

“Self-service” with no policy engine, no quotas, and cluster-admin for all is Gizmo’s trap: it feels fast for a week, then someone spins up a giant unencrypted database in the wrong region and the team spends a month cleaning up. If a path isn’t safe by default, it isn’t a golden path — it’s a cliff with a welcome mat.

🦋 Mira’s workshop · 20 min

Do it without a cloud bill. On a throwaway kind cluster, install Crossplane and the provider-kubernetes (or nop) provider. Author a tiny XRD for a kind: Database plus a Composition that “provisions” a ConfigMap and a Namespace as stand-ins for real infra. Apply a tiny Database claim and watch the composite fan out into those resources; delete one by hand and watch Crossplane put it back — self-healing infra. For bonus points, run Backstage locally and write a Software Template whose skeleton drops that exact claim into a repo. You’ll have built the whole self-service loop, end to end, for free.

🎬 At the Platform Guild
🦊

Foxy: So “self-service” just means we hand Dot a big Crossplane manifest and say “good luck,” right?

🐿️

Nutty: Nope — I asked the developers, and “learn 60 lines of RDS YAML” is exactly what they don’t want. They want one choice: how big?

🦋

Mira: So we write the XRD and Composition once, and hide all 60 lines behind a dozen-line Database claim — then behind one button in Backstage. Dot picks “small,” done.

👺

Gizmo: Buttons are slow to build! Just give everyone cluster-admin and let ’em terraform apply whatever. Freedom! 🤑

🐢

Timmy: That’s a free-for-all, not self-service, Gizmo. The path only stays open because the dangerous levers aren’t on it — encryption’s baked in, quotas cap the blast radius, Kyverno rejects the rest.

🦆

Dot: Honestly I don’t care what’s behind the glass. I pressed “small database,” it showed up plugged into my app, and I made lunch. Ship more buttons.

That’s the whole arc of the course: from filing tickets in the swamp to pressing one safe button on a paved road. What’s left is keeping it healthy and honest — the watchtower that tells you when the path breaks, and the guardrails that keep it from becoming a cliff.

🐢 Timmy’s checkpoint

1. Name Crossplane’s five pieces and say which one a developer writes. 2. What’s the difference between a Crossplane composite (XR) and a Claim? 3. In one sentence, distinguish an internal developer platform from an internal developer portal. 4. In a Backstage Software Template, what do parameters vs steps do? 5. Give two guardrails that keep self-service from becoming a dangerous free-for-all.

Check your answers
  1. Provider, Managed Resource, Composite Resource Definition (XRD), Composition, and Claim — the developer writes only the Claim.
  2. The composite (XR) is the cluster-scoped object that holds all the machinery and Managed Resources; the Claim is the small, namespaced handle a developer creates to request one, in their own namespace.
  3. The platform is the whole paved road (clusters, GitOps, Crossplane, policy, pipelines); the portal (Backstage) is just the UI/storefront sitting on top of it.
  4. parameters define the form the developer fills in; steps are the actions that run on submit (fetch a skeleton, publish a repo, register it in the catalog, open a PR).
  5. Any two of: RBAC (Claims only, in your namespace), ResourceQuota/LimitRange, a policy engine like Kyverno or OPA Gatekeeper at admission, or secure defaults baked into the Composition.