Developer Self-Service
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:
| Capability | Tool | Owner |
|---|---|---|
| Service discovery & docs | Backstage / Port | Platform |
| Namespace provisioning | Crossplane / Kyverno generate | Platform |
| Database provisioning | Crossplane AWS/GCP provider | Platform |
| Secret request | External Secrets ClusterSecretStore | Platform |
| TLS certificates | cert-manager ClusterIssuer | Platform |
| Observability defaults | ServiceMonitor Kyverno generate | Platform |
| RBAC per team | Kyverno generate + ClusterRole | Platform |
| New service scaffold | Backstage template | Platform |
| Deployment (app layer) | Argo CD Application | App team |
| Runbook publishing | TechDocs (Backstage) | App team |
Related
- Workflows Overview — golden path philosophy
- Runbooks — TechDocs and runbook standards
- 08 — Multi-Tenancy — namespace isolation model
- 08 — Secrets Automation — ESO self-service secrets