Developer Self-Service
Summary
- Developer self-service is a written, enforced split between what an app team may change on its own and what requires a platform ticket.
- Insight: The line is not drawn by risk appetite. It is drawn by blast radius - if a mistake stays inside one namespace, the team owns it.
- Key Concepts: the self-service contract, namespace-scoped RBAC, shared-resource primitives (Gateway API, quotas), paved-path templates, escalation paths.
- When to Use: As soon as a platform team exists and more than two app teams share a cluster. Before that, the ticket queue is short enough not to matter.
- Limitations: Self-service only works if the paved path is genuinely easier than the ticket. If it is not, teams route around it.
Recipe
Write the contract down before you encode it in RBAC.
- List every change an app team wants to make in a week: image tags, replicas, env vars, config, routes, secrets, resources, node placement.
- Sort each into self-service (blast radius = one namespace) or platform (blast radius = cluster or other tenants).
- Encode the self-service half as a namespace-scoped
RoleplusRoleBinding. - Encode the platform half as a request path: a PR to the platform repo, or a ticket with a template.
- Publish the table in the team's README so the answer to "can I just do this?" takes ten seconds.
Working Example
Here is a self-service contract for a typical app team, expressed as RBAC.
apiVersion: rbac.authorization.k8s.io/v1
kind: Role
metadata:
namespace: team-payments
name: app-self-service
rules:
# Own your workloads.
- apiGroups: ["apps"]
resources: ["deployments", "statefulsets", "replicasets"]
verbs: ["get", "list", "watch", "create", "update", "patch"]
# Own your config and your own Secrets.
- apiGroups: [""]
resources: ["configmaps", "secrets", "services"]
verbs: ["get", "list", "watch", "create", "update", "patch", "delete"]
# Debug your own Pods.
- apiGroups: [""]
resources: ["pods", "pods/log", "pods/exec"]
verbs: ["get", "list", "watch", "delete"]
# Route your own traffic through a Gateway you do not own.
- apiGroups: ["gateway.networking.k8s.io"]
resources: ["httproutes"]
verbs: ["get", "list", "watch", "create", "update", "patch", "delete"]
# Scale yourself.
- apiGroups: ["autoscaling"]
resources: ["horizontalpodautoscalers"]
verbs: ["get", "list", "watch", "create", "update", "patch"]apiVersion: rbac.authorization.k8s.io/v1
kind: RoleBinding
metadata:
namespace: team-payments
name: app-self-service
subjects:
- kind: Group
name: eng-payments
apiGroup: rbac.authorization.k8s.io
roleRef:
kind: Role
name: app-self-service
apiGroup: rbac.authorization.k8s.ioNote the deliberate absences: no resourcequotas, no nodes, no gateways, no namespaces, no CRD management, and nothing cluster-scoped.
Verify the contract from the developer's seat rather than reading the YAML back to yourself.
kubectl auth can-i create deployments --namespace team-payments --as-group eng-payments
kubectl auth can-i update resourcequotas --namespace team-payments --as-group eng-payments
kubectl auth can-i delete nodes --as-group eng-paymentsThe first should print yes; the other two should print no.
Deep Dive
The contract table
RBAC is the enforcement. The table is the documentation, and the table is what people actually read.
| Change | Self-service | Platform ticket |
|---|---|---|
| Image tag / new release | Yes | - |
| Replica count, HPA min/max | Yes | - |
| Env vars, ConfigMaps | Yes | - |
| Own Secrets (via External Secrets) | Yes | - |
| Requests and limits (within quota) | Yes | - |
HTTPRoute on an existing Gateway | Yes | - |
| New hostname / TLS certificate | - | Yes |
| Namespace quota increase | - | Yes |
| New namespace | - | Yes |
| Node pool, taints, tolerations | - | Yes |
| Installing a CRD or operator | - | Yes |
| PriorityClass above default | - | Yes |
| Cluster-scoped RBAC | - | Yes |
The right-hand column is short on purpose. If it grows, your platform is becoming a bottleneck.
Why quotas must be platform-owned
A team that can edit its own ResourceQuota does not have a quota - it has a suggestion.
Quotas are the mechanism that converts "you crashed the cluster" into "you hit your budget," so the object must sit outside the tenant's write scope.
The same logic applies to PriorityClass: a team that can grant itself high priority can preempt everyone else's Pods.
Shared resources as constrained primitives
The interesting cases are the shared ones, and the pattern is always the same: split the object into a piece the platform owns and a piece the tenant owns.
Gateway API does this natively. The platform owns the Gateway (listeners, TLS, IP); the team owns the HTTPRoute that attaches to it.
apiVersion: gateway.networking.k8s.io/v1
kind: HTTPRoute
metadata:
name: payments
namespace: team-payments
spec:
parentRefs:
- name: shared-gateway
namespace: infra-gateways
hostnames:
- "payments.example.com"
rules:
- matches:
- path:
type: PathPrefix
value: /api/payments
backendRefs:
- name: payments-api
port: 8080The team routes its own traffic and cannot reconfigure the front door for anyone else.
Whether a route in one namespace may attach to a Gateway in another is decided by the listener's allowedRoutes.namespaces - exactly the platform-owned approval point.
A ReferenceGrant is a different control: it authorizes cross-namespace references, such as a route whose backendRefs target a Service in another namespace, or a Gateway whose certificateRefs read a Secret in another namespace.
The paved path is the real product
RBAC that permits a change does not make the change easy.
Ship a golden-path template - a Helm chart or Kustomize base - that already carries probes, resource requests, a non-root securityContext, and a PodDisruptionBudget.
Then self-service means editing five values, not authoring 120 lines of YAML from memory.
Gotchas
Granting secrets read across the namespace leaks the ServiceAccount tokens of every workload in it. Scope Secret access by name with resourceNames where the risk is real, or use External Secrets so the source of truth is the vault.
pods/exec is close to a root shell in the container. It is reasonable in dev and staging, questionable in production; prefer ephemeral debug containers and logs, and audit exec events.
patch on a Deployment lets a team route around admission-time policy only if you have no policy. Keep Pod Security Standards on the namespace so RBAC breadth does not become privilege breadth.
Teams cannot see why they were denied. A bare Error from server (Forbidden) is a ticket generator; make policy engines emit the rule name and a link to the contract table.
The platform column silently grows. Review it quarterly and ask which entries could become self-service primitives instead.
Alternatives
Full cluster-admin for app teams. Fast at three engineers, ungovernable at thirty. Use only in throwaway dev clusters.
Ticket-everything. Maximum control, and the reason teams stand up shadow clusters on a corporate card. Reserve it for genuinely cluster-scoped changes.
Cluster-per-team. Real isolation and no shared-resource seam, at the cost of N control planes and N upgrade cycles. Good for strict compliance boundaries, expensive as a default.
GitOps-only self-service. Nobody holds write RBAC in production; teams open PRs and Argo CD reconciles. This is the strongest posture for prod and pairs well with direct RBAC in lower environments.
Internal developer portal. A UI or CLI that opens the right PR for you. Best experience, most build cost, and it needs the contract to exist first anyway.
FAQs
Should self-service differ between staging and production? Yes. Direct write in staging and PR-driven change in production is the common and defensible split.
Who owns requests and limits? The team, bounded by a platform-owned quota and a LimitRange that supplies defaults when they forget.
How do teams get Secrets without cluster access? External Secrets Operator or a CSI secrets driver pulls from your vault, so the team references a secret name and never handles the material.
Does a namespace admin Role work instead of an explicit list? It is convenient but grants RBAC-editing rights inside the namespace, which lets a team widen its own permissions. Prefer an explicit rule list.
What if a team needs a cluster-scoped resource often? That is a signal to build a namespaced abstraction for it, not to grant a ClusterRole.
How do I stop the contract from rotting? Tie it to the same review cadence as the golden-path template, and treat every recurring ticket type as a self-service candidate.
Related
- Making App Devs Cluster-Safe
- Onboarding Basics
- Pairing on Manifest PRs
- Skills Matrix
- Team & Onboarding Best Practices
Stack versions: This page was written for Kubernetes 1.36.2, Docker Engine 29.6.1 (BuildKit default), containerd (CRI runtime on nodes), Helm 3, Compose v2, Argo CD (latest - verify at build), and Gateway API (GA - verify controller support at build).