Overview

Developer self-service removes the platform team from the critical path for routine tasks — provisioning namespaces, creating databases, rotating secrets, requesting TLS certificates. When developers can do these things themselves safely, platform teams can focus on infrastructure rather than ticket triage.

Without self-service:
  Developer → ticket → platform team → (queue) → deploy → done
  Latency: hours to days

With self-service:
  Developer → portal / CLI → automated workflow → done
  Latency: seconds to minutes, guardrails enforced automatically

The key is that self-service does not mean "no guardrails" — it means guardrails are enforced by systems, not humans.

Backstage — Internal Developer Portal

Backstage is an open-source IDP (Internal Developer Portal) that provides a software catalog, scaffolder templates, and plugin ecosystem. Teams use it to onboard new services, discover APIs, and access runbooks.

Core Concepts

Software Catalog
  └── Component  — a service, website, library, or pipeline
        ├── System    — group of related components
        ├── API       — OpenAPI / gRPC / Async schemas
        ├── Resource  — database, S3 bucket, queue
        └── Domain    — high-level grouping

Scaffolder
  └── Template  — wizard that creates repos, PRs, Argo CD apps, JIRA tickets

TechDocs
  └── Markdown docs served from the same repo as the service

catalog-info.yaml — Service Registration

# Commit this file to every service repo
apiVersion: backstage.io/v1alpha1
kind: Component
metadata:
  name: payments-api
  title: Payments API
  description: Handles payment processing, refunds, and reconciliation
  annotations:
    # Links Backstage to Kubernetes resources
    backstage.io/kubernetes-id: payments-api
    backstage.io/kubernetes-namespace: production

    # Links to external systems
    github.com/project-slug: acme/payments-api
    pagerduty.com/service-id: P1234AB
    grafana/dashboard-selector: "payments-api"
    argocd/app-name: payments-api

    # TechDocs
    backstage.io/techdocs-ref: dir:.
  tags:
  - payments
  - critical
  - go
  links:
  - url: https://grafana.internal/d/payments
    title: Grafana Dashboard
    icon: dashboard
  - url: https://runbooks.internal/payments-api
    title: Runbook
    icon: book
spec:
  type: service
  lifecycle: production
  owner: team-payments
  system: payment-platform
  dependsOn:
  - resource:default/payments-postgres
  - resource:default/stripe-api
  providesApis:
  - payments-api-v1

Scaffolder Template — New Service

# templates/new-service/template.yaml
apiVersion: scaffolder.backstage.io/v1beta3
kind: Template
metadata:
  name: kubernetes-service
  title: New Kubernetes Service
  description: Creates a production-ready service with Helm chart, CI pipeline, Argo CD app, and PagerDuty service
spec:
  owner: platform-team
  type: service

  parameters:
  - title: Service Details
    required: [name, owner, description]
    properties:
      name:
        type: string
        title: Service Name
        pattern: '^[a-z][a-z0-9-]*$'
      owner:
        type: string
        title: Owning Team
        ui:field: OwnerPicker
        ui:options:
          allowedKinds: [Group]
      description:
        type: string
        title: Short description
      language:
        type: string
        title: Language
        enum: [go, node, python, java]
        default: go

  steps:
  - id: fetch-template
    name: Fetch base template
    action: fetch:template
    input:
      url: ./skeleton
      values:
        name: ${{ parameters.name }}
        owner: ${{ parameters.owner }}
        language: ${{ parameters.language }}

  - id: publish
    name: Create GitHub repo
    action: publish:github
    input:
      repoUrl: github.com?repo=${{ parameters.name }}&owner=acme
      description: ${{ parameters.description }}
      defaultBranch: main
      repoVisibility: private

  - id: create-argocd-app
    name: Create Argo CD Application
    action: argocd:create-application
    input:
      appName: ${{ parameters.name }}
      argoInstance: production
      namespace: ${{ parameters.name }}
      repoUrl: https://github.com/acme/${{ parameters.name }}
      path: k8s/overlays/staging

  - id: register
    name: Register in Backstage catalog
    action: catalog:register
    input:
      repoContentsUrl: ${{ steps.publish.output.repoContentsUrl }}
      catalogInfoPath: catalog-info.yaml

  output:
    links:
    - title: Repository
      url: ${{ steps['publish'].output.remoteUrl }}
    - title: Backstage component
      url: ${{ steps['register'].output.entityRef }}

Namespace Self-Service with Crossplane

Crossplane lets developers provision infrastructure (namespaces, databases, buckets) by creating Kubernetes resources, without directly touching cloud APIs or writing Terraform.

Namespace Claim

# CompositeResourceDefinition — platform team defines the API
apiVersion: apiextensions.crossplane.io/v1
kind: CompositeResourceDefinition
metadata:
  name: xnamespaces.platform.acme.com
spec:
  group: platform.acme.com
  names:
    kind: XNamespace
    plural: xnamespaces
  claimNames:
    kind: Namespace
    plural: namespaces
  versions:
  - name: v1alpha1
    served: true
    referenceable: true
    schema:
      openAPIV3Schema:
        type: object
        properties:
          spec:
            type: object
            properties:
              teamName:
                type: string
              environment:
                type: string
                enum: [dev, staging, production]
              resourceQuota:
                type: object
                properties:
                  cpuLimit:
                    type: string
                    default: "4"
                  memoryLimit:
                    type: string
                    default: "8Gi"
# Developer creates this claim — platform Composition handles the rest
apiVersion: platform.acme.com/v1alpha1
kind: Namespace
metadata:
  name: payments-dev
  namespace: crossplane-claims
spec:
  teamName: payments
  environment: dev
  resourceQuota:
    cpuLimit: "8"
    memoryLimit: "16Gi"

The Composition creates: the K8s Namespace, ResourceQuota, LimitRange, default NetworkPolicy, RoleBinding for the team, and a ServiceAccount — all from a single developer claim.

Database Self-Service with Crossplane AWS Provider

# Developer claims a managed RDS PostgreSQL instance
apiVersion: database.acme.com/v1alpha1
kind: PostgreSQLInstance
metadata:
  name: payments-db
  namespace: production
spec:
  parameters:
    storageGB: 100
    instanceClass: db.t3.medium
    version: "16"
    multiAZ: true
  writeConnectionSecretToRef:
    name: payments-db-credentials    # Crossplane writes DB credentials here
# Composition (defined by platform team — developers never see this)
apiVersion: apiextensions.crossplane.io/v1
kind: Composition
metadata:
  name: postgresql-aws
spec:
  compositeTypeRef:
    apiVersion: database.acme.com/v1alpha1
    kind: XPostgreSQLInstance
  resources:
  - name: rds-instance
    base:
      apiVersion: rds.aws.crossplane.io/v1beta1
      kind: RDSInstance
      spec:
        forProvider:
          region: us-east-1
          dbInstanceClass: db.t3.medium
          engine: postgres
          multiAZ: true
          skipFinalSnapshotBeforeDeletion: false
          deletionProtection: true
    patches:
    - fromFieldPath: spec.parameters.storageGB
      toFieldPath: spec.forProvider.allocatedStorage
    - fromFieldPath: spec.parameters.instanceClass
      toFieldPath: spec.forProvider.dbInstanceClass

Port — Lightweight Self-Service Portal

Port is a SaaS alternative to Backstage with lower operational overhead.

# Port blueprint — defines entity schema
{
  "identifier": "service",
  "title": "Service",
  "schema": {
    "properties": {
      "language": { "type": "string", "enum": ["go", "python", "node"] },
      "team": { "type": "string" },
      "on_call": { "type": "string" },
      "pagerduty_service_id": { "type": "string" }
    }
  },
  "relations": {
    "namespace": { "target": "namespace", "required": true }
  }
}
# Port GitHub Action — sync K8s resources to Port catalog
- name: Ingest K8s data to Port
  uses: port-labs/port-github-action@v1
  with:
    clientId: ${{ secrets.PORT_CLIENT_ID }}
    clientSecret: ${{ secrets.PORT_CLIENT_SECRET }}
    blueprint: service
    operation: UPSERT
    identifier: payments-api
    properties: |
      {
        "language": "go",
        "team": "payments",
        "pagerduty_service_id": "P1234AB"
      }

Self-Service Access Control Patterns

TeamRoleBinding — RBAC per Team Namespace

# Platform team applies this via Crossplane Composition or Kyverno generate
apiVersion: rbac.authorization.k8s.io/v1
kind: RoleBinding
metadata:
  name: team-payments-developer
  namespace: payments-production
subjects:
- kind: Group
  name: team-payments               # maps to GitHub team or SSO group
  apiGroup: rbac.authorization.k8s.io
roleRef:
  kind: ClusterRole
  name: developer                   # custom ClusterRole (read pods/logs/exec, no delete)
  apiGroup: rbac.authorization.k8s.io
# ClusterRole: developer — safe subset for app developers
apiVersion: rbac.authorization.k8s.io/v1
kind: ClusterRole
metadata:
  name: developer
rules:
- apiGroups: [""]
  resources: [pods, services, configmaps, endpoints, events]
  verbs: [get, list, watch]
- apiGroups: [""]
  resources: [pods/log, pods/exec, pods/portforward]
  verbs: [get, create]
- apiGroups: [apps]
  resources: [deployments, replicasets, statefulsets]
  verbs: [get, list, watch]
- apiGroups: [argoproj.io]
  resources: [rollouts]
  verbs: [get, list, watch, update, patch]   # allow promote/abort rollouts

Kyverno: Auto-Generate RBAC on Namespace Create

apiVersion: kyverno.io/v1
kind: ClusterPolicy
metadata:
  name: generate-team-rolebinding
spec:
  generateExistingOnPolicyUpdate: true
  rules:
  - name: add-team-rolebinding
    match:
      any:
      - resources:
          kinds: [Namespace]
          selector:
            matchLabels:
              team: "?*"              # any namespace with a "team" label
    generate:
      apiVersion: rbac.authorization.k8s.io/v1
      kind: RoleBinding
      name: team-developer
      namespace: "{{request.object.metadata.name}}"
      synchronize: true
      data:
        subjects:
        - kind: Group
          name: "{{request.object.metadata.labels.team}}"
          apiGroup: rbac.authorization.k8s.io
        roleRef:
          kind: ClusterRole
          name: developer
          apiGroup: rbac.authorization.k8s.io

Service Catalog Checklist

A healthy self-service platform has at minimum:

CapabilityToolOwner
Service discovery & docsBackstage / PortPlatform
Namespace provisioningCrossplane / Kyverno generatePlatform
Database provisioningCrossplane AWS/GCP providerPlatform
Secret requestExternal Secrets ClusterSecretStorePlatform
TLS certificatescert-manager ClusterIssuerPlatform
Observability defaultsServiceMonitor Kyverno generatePlatform
RBAC per teamKyverno generate + ClusterRolePlatform
New service scaffoldBackstage templatePlatform
Deployment (app layer)Argo CD ApplicationApp team
Runbook publishingTechDocs (Backstage)App team