LUNAROPS · OPERATIONAL UPLINK 100% UPTIME 1,247d POSTS 893 JEFF.MOON@LUNAROPS.DEV UTC --:--:--

Internal Developer Platforms: Building the Golden Path

platform-engineeringdeveloper-experienceidpkubernetescrossplanebackstagedevopsautomation

An Internal Developer Platform (IDP) is the sum of tools, workflows, and self-service capabilities that a platform team provides to product teams. It’s the difference between a developer waiting three days for a ticket to provision a staging database, and a developer running one command and having a database URL in their terminal thirty seconds later.

This post is about building that — the concrete pieces, the abstractions, the tooling choices, and the patterns that make an IDP genuinely useful rather than an elaborate system that gets routed around.


What Makes an IDP an IDP

An IDP is not:

  • A Confluence wiki with runbooks
  • A shared Kubernetes cluster with no guardrails
  • A ticketing system with “infrastructure” as a category
  • A collection of Terraform modules in a repo nobody can find

An IDP is a product with:

  • Self-service interfaces — developers get what they need without human approval for routine requests
  • Opinionated abstractions — the platform makes decisions so developers don’t have to
  • Guardrails, not gates — security and compliance are built into the path, not enforced afterwards
  • Feedback loops — the platform team measures adoption, satisfaction, and time-to-production

The best IDP feels like infrastructure that was already there. Developers don’t think about it — they just ship.


The Five Planes of an IDP

The CNCF’s platforms white paper defines five planes that a mature IDP addresses. Understanding these helps you plan what to build and in what order.

1. Developer Control Plane

How developers interact with the platform: CLIs, web portals, golden path templates, self-service forms. This is the developer-facing surface area. If this is painful or confusing, nothing else matters — developers will work around it.

2. Integration and Delivery Plane

CI/CD pipelines, artifact registries, deployment automation. How code gets from a developer’s laptop to production reliably, repeatedly, and with appropriate checks along the way.

3. Resource Plane

Infrastructure provisioning — databases, queues, caches, cloud storage buckets, DNS records. The developer asks for a resource; the platform provides it within minutes.

4. Monitoring and Observability Plane

Metrics, logs, traces, and alerts — pre-configured per service, automatically wired up. Developers shouldn’t have to instrument their services to get golden signals.

5. Security and Compliance Plane

Secrets management, policy enforcement, vulnerability scanning, audit logging — built into every deployment path, not bolted on after the fact.


Designing the Golden Path

The golden path is the set of opinionated choices the platform makes. Every decision you make in the golden path is a decision your developers don’t have to make. But make too many decisions and the path becomes a straitjacket.

A useful exercise: write the developer experience first. Before building anything, write the README for your ideal platform. What does a developer type to create a new service? What do they get? What does deploying look like? Work backwards from that experience.

Example: The Ideal New Service Experience

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
# What a developer should be able to do:
platform new service --name my-api --template go-http-service

# What they get:
# ✓ GitHub repo created with CI/CD pre-configured
# ✓ Kubernetes namespace provisioned (dev, staging, production)
# ✓ Grafana dashboard created with golden signals
# ✓ PagerDuty integration configured
# ✓ Vault path created with initial secrets
# ✓ ArgoCD app configured for GitOps delivery
# ✓ README with runbook links

# First deploy:
git push origin main   # triggers CI, deploys to staging automatically

# Promote to production:
platform deploy my-api --env production --version v1.2.3
# ✓ Deployed 3 replicas to production
# ✓ Smoke tests passed
# ✓ Grafana dashboard: https://grafana.internal/d/my-api

That’s the target. Now build backwards to make it real.


Building Blocks: The Platform Stack

No single tool does everything. A production IDP typically combines:

Concern Common Tools
Developer portal / catalog Backstage, Port, Cortex
Service templates / scaffolding Backstage Software Templates, Cookiecutter, custom CLI
Kubernetes abstraction Helm, Crossplane, custom operators, KubeVela
GitOps delivery ArgoCD, Flux
CI/CD GitHub Actions, GitLab CI, Tekton
Secret management HashiCorp Vault, External Secrets Operator
Policy enforcement Kyverno, OPA/Gatekeeper
Observability Prometheus + Grafana + Loki + Tempo
Infrastructure provisioning Terraform, Crossplane, Pulumi

You don’t need all of these on day one. Start with the two or three that address your biggest pain points.


Service Templates: Codifying the Golden Path

Service templates are the entry point to the IDP. They generate a new service skeleton pre-configured with everything the platform requires.

With Backstage Software Templates

Backstage’s Software Templates (covered in depth in the Backstage post) are the most common approach for teams already using Backstage as a portal:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
# template.yaml in your platform's template repo
apiVersion: scaffolder.backstage.io/v1beta3
kind: Template
metadata:
  name: go-http-service
  title: Go HTTP Service
  description: Production-ready Go HTTP service with CI/CD, monitoring, and GitOps
spec:
  owner: platform-team
  type: service
  parameters:
    - title: Service Details
      required: [name, owner, description]
      properties:
        name:
          type: string
          pattern: '^[a-z][a-z0-9-]{2,30}$'
          description: Lowercase, hyphen-separated service name
        owner:
          type: string
          ui:field: OwnerPicker
        description:
          type: string
        port:
          type: integer
          default: 8080
    - title: Infrastructure
      properties:
        database:
          type: boolean
          title: Include PostgreSQL database?
          default: false
        redis:
          type: boolean
          title: Include Redis cache?
          default: false
        environment:
          type: string
          enum: [dev, staging, production]
          default: staging

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

    - id: create-repo
      name: Create GitHub Repository
      action: github:repo:create
      input:
        repoUrl: github.com?owner=myorg&repo=${{ parameters.name }}
        description: ${{ parameters.description }}
        defaultBranch: main
        repoVisibility: private
        topics: ["service", "go"]

    - id: publish
      name: Publish to GitHub
      action: publish:github
      input:
        repoUrl: github.com?owner=myorg&repo=${{ parameters.name }}
        defaultBranch: main

    - id: create-argocd-app
      name: Register with ArgoCD
      action: argocd:create-resources
      input:
        appName: ${{ parameters.name }}
        argoInstance: production
        namespace: ${{ parameters.name }}
        repoUrl: https://github.com/myorg/${{ parameters.name }}
        path: deploy/

    - id: register-catalog
      name: Register in Catalog
      action: catalog:register
      input:
        repoContentsUrl: ${{ steps['publish'].output.repoContentsUrl }}
        catalogInfoPath: /catalog-info.yaml

Platform CLI (Without Backstage)

For teams not using Backstage, a simple CLI wrapping the same logic works well:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
#!/usr/bin/env python3
# platform CLI — simplified example

import click
import subprocess
from pathlib import Path
import requests

@click.group()
def cli():
    """LunarOps Platform CLI"""
    pass

@cli.group()
def new():
    """Create new platform resources"""
    pass

@new.command()
@click.option('--name', required=True, help='Service name (lowercase, hyphen-separated)')
@click.option('--template', default='go-http-service',
              type=click.Choice(['go-http-service', 'python-api', 'node-api']))
@click.option('--owner', required=True, help='Owning team (e.g. payments-team)')
@click.option('--database', is_flag=True, default=False, help='Include PostgreSQL')
def service(name, template, owner, database):
    """Scaffold a new service from a template"""
    click.echo(f"Creating service: {name}")

    # 1. Generate code from template
    click.echo("  → Generating code from template...")
    subprocess.run([
        'cookiecutter',
        f'gh:myorg/platform-templates/{template}',
        '--no-input',
        f'service_name={name}',
        f'owner={owner}',
        f'include_database={str(database).lower()}'
    ], check=True)

    # 2. Create GitHub repo
    click.echo("  → Creating GitHub repository...")
    _create_github_repo(name, owner)

    # 3. Push code
    click.echo("  → Pushing initial code...")
    subprocess.run(['git', 'init', name], check=True)
    subprocess.run(['git', '-C', name, 'add', '.'], check=True)
    subprocess.run(['git', '-C', name, 'commit', '-m', 'Initial commit from platform template'], check=True)
    subprocess.run(['git', '-C', name, 'remote', 'add', 'origin',
                   f'git@github.com:myorg/{name}.git'], check=True)
    subprocess.run(['git', '-C', name, 'push', '-u', 'origin', 'main'], check=True)

    # 4. Provision Vault path
    click.echo("  → Provisioning Vault secrets path...")
    subprocess.run([
        'vault', 'kv', 'put', f'secret/{name}/config',
        'placeholder=replace_me'
    ], check=True)

    # 5. Register ArgoCD app
    click.echo("  → Registering with ArgoCD...")
    _create_argocd_app(name)

    click.echo(f"\n✓ Service '{name}' created successfully!")
    click.echo(f"  Repository: https://github.com/myorg/{name}")
    click.echo(f"  ArgoCD: https://argocd.internal/applications/{name}")
    click.echo(f"  Next steps: cd {name} && git push")

Kubernetes Abstractions: Hiding the Complexity

Raw Kubernetes requires developers to understand Deployments, Services, Ingress, HorizontalPodAutoscalers, PodDisruptionBudgets, ServiceMonitors, NetworkPolicies, and more — for every service. Platform teams can abstract this with custom resource types.

Approach 1: A Helm Umbrella Chart

The simplest approach: a single, opinionated Helm chart that accepts high-level inputs and generates all the boilerplate.

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
# values.yaml that product teams write (simple)
name: my-api
image:
  repository: ghcr.io/myorg/my-api
  tag: v1.2.3

replicas:
  min: 2
  max: 10

resources:
  requests:
    cpu: 200m
    memory: 256Mi

ingress:
  enabled: true
  host: my-api.lunarops.io

database:
  enabled: true
  name: my-api-db

monitoring:
  enabled: true
  sloTarget: 99.5   # % availability target

The Helm chart translates this into:

  • Deployment with proper health checks, security context, affinity rules
  • Service and Ingress with TLS via cert-manager
  • HorizontalPodAutoscaler with sensible defaults
  • PodDisruptionBudget to ensure rolling updates don’t cause downtime
  • ExternalSecret to pull credentials from Vault
  • ServiceMonitor for Prometheus scraping
  • PrometheusRule for SLO-based alerting
  • NetworkPolicy restricting ingress to the ingress controller only

Approach 2: A Kubernetes Operator with Custom CRDs

For more control, write a custom operator that defines a first-class WebService CRD:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
# WebService CRD — what product teams write
apiVersion: platform.lunarops.io/v1
kind: WebService
metadata:
  name: my-api
  namespace: my-api
spec:
  image: ghcr.io/myorg/my-api:v1.2.3
  port: 8080

  scaling:
    minReplicas: 2
    maxReplicas: 10
    targetCPUPercent: 70

  ingress:
    host: my-api.lunarops.io
    auth: none   # or: internal, oauth2

  database:
    postgres:
      size: small   # platform team defines what "small" means

  secrets:
    - name: stripe-key
      vault: secret/my-api/stripe

  slo:
    availability: 99.5
    latencyP99Ms: 500
 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
// Simplified operator reconcile loop
func (r *WebServiceReconciler) Reconcile(ctx context.Context, req ctrl.Request) (ctrl.Result, error) {
    var ws platformv1.WebService
    if err := r.Get(ctx, req.NamespacedName, &ws); err != nil {
        return ctrl.Result{}, client.IgnoreNotFound(err)
    }

    // Ensure Deployment exists and matches spec
    if err := r.reconcileDeployment(ctx, &ws); err != nil {
        return ctrl.Result{}, err
    }

    // Ensure Service exists
    if err := r.reconcileService(ctx, &ws); err != nil {
        return ctrl.Result{}, err
    }

    // Ensure Ingress with TLS
    if err := r.reconcileIngress(ctx, &ws); err != nil {
        return ctrl.Result{}, err
    }

    // Ensure HPA
    if err := r.reconcileHPA(ctx, &ws); err != nil {
        return ctrl.Result{}, err
    }

    // Ensure ExternalSecrets for each vault secret
    if err := r.reconcileSecrets(ctx, &ws); err != nil {
        return ctrl.Result{}, err
    }

    // Ensure database if requested
    if ws.Spec.Database.Postgres != nil {
        if err := r.reconcileDatabase(ctx, &ws); err != nil {
            return ctrl.Result{}, err
        }
    }

    // Ensure monitoring (ServiceMonitor + PrometheusRule for SLOs)
    if err := r.reconcileMonitoring(ctx, &ws); err != nil {
        return ctrl.Result{}, err
    }

    return ctrl.Result{}, nil
}

Approach 3: Crossplane for Infrastructure Resources

Crossplane extends Kubernetes with infrastructure provisioning — cloud resources like RDS instances, S3 buckets, and GCP Cloud SQL are managed as Kubernetes custom resources. Platform teams define Compositions (how resources are assembled) and CompositeResourceDefinitions (the API surface exposed to developers).

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
# Platform team defines this Composition (once)
apiVersion: apiextensions.crossplane.io/v1
kind: Composition
metadata:
  name: postgres-aws
spec:
  compositeTypeRef:
    apiVersion: platform.lunarops.io/v1alpha1
    kind: PostgreSQLInstance
  resources:
    - name: rds-instance
      base:
        apiVersion: rds.aws.upbound.io/v1beta1
        kind: Instance
        spec:
          forProvider:
            region: us-east-1
            engine: postgres
            engineVersion: "16"
            skipFinalSnapshot: false
            backupRetentionPeriod: 7
            storageEncrypted: true
      patches:
        - fromFieldPath: spec.parameters.size
          toFieldPath: spec.forProvider.instanceClass
          transforms:
            - type: map
              map:
                small: db.t4g.micro
                medium: db.t4g.small
                large: db.t4g.medium
        - fromFieldPath: spec.parameters.name
          toFieldPath: metadata.name
 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
# Product team uses this simple claim
apiVersion: platform.lunarops.io/v1alpha1
kind: PostgreSQLInstance
metadata:
  name: my-api-db
  namespace: my-api
spec:
  parameters:
    size: small
    name: my-api
  writeConnectionSecretToRef:
    name: my-api-db-credentials
    # Kubernetes Secret created automatically with host, port, user, password

The platform team owns the Composition (the AWS-specific details, encryption settings, backup policies). The product team sees only size: small. Changing from AWS to GCP means updating the Composition — product teams don’t change anything.


GitOps Delivery: ArgoCD + Environment Promotion

A clean environment promotion model is central to any IDP. Developers push to main; the platform takes it from there.

Developer pushes → CI builds & tests → updates image tag in GitOps repo
                                              ↓
                                    ArgoCD syncs to dev (auto)
                                              ↓
                              Smoke tests pass → promote to staging (auto)
                                              ↓
                              QA sign-off → promote to production (manual gate)

The GitOps repo structure:

platform-gitops/
├── apps/
│   ├── my-api/
│   │   ├── dev/
│   │   │   └── values.yaml      # image: my-api:main-abc1234
│   │   ├── staging/
│   │   │   └── values.yaml      # image: my-api:v1.2.2
│   │   └── production/
│   │       └── values.yaml      # image: my-api:v1.2.1

CI updates the image tag in dev/values.yaml after a successful build. ArgoCD auto-syncs dev. Promotion to staging is automated after smoke tests. Production requires an explicit PR or platform promote command.

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
# ArgoCD ApplicationSet — auto-creates an Application per environment
apiVersion: argoproj.io/v1alpha1
kind: ApplicationSet
metadata:
  name: my-api
  namespace: argocd
spec:
  generators:
  - list:
      elements:
      - env: dev
        autoSync: "true"
        namespace: my-api-dev
      - env: staging
        autoSync: "true"
        namespace: my-api-staging
      - env: production
        autoSync: "false"     # manual sync for production
        namespace: my-api
  template:
    metadata:
      name: 'my-api-{{env}}'
    spec:
      project: default
      source:
        repoURL: https://github.com/myorg/platform-gitops
        path: apps/my-api/{{env}}
        helm:
          valueFiles:
          - values.yaml
      destination:
        server: https://kubernetes.default.svc
        namespace: '{{namespace}}'
      syncPolicy:
        automated:
          prune: true
          selfHeal: true
          enabled: '{{autoSync}}'

Self-Service Environment Management

Developers need environments for testing. The IDP should make this frictionless.

Namespace-per-Feature-Branch

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
# GitHub Actions — create a preview environment for every PR
name: Preview Environment

on:
  pull_request:
    types: [opened, synchronize, reopened, closed]

jobs:
  deploy-preview:
    if: github.event.action != 'closed'
    runs-on: ubuntu-latest
    steps:
      - uses: actions/checkout@v4

      - name: Deploy preview environment
        run: |
          NAMESPACE="preview-${{ github.event.number }}"

          # Create namespace
          kubectl create namespace "$NAMESPACE" --dry-run=client -o yaml \
            | kubectl apply -f -

          # Label for cleanup
          kubectl label namespace "$NAMESPACE" \
            preview=true \
            pr-number=${{ github.event.number }} \
            created-by=github-actions

          # Deploy with preview values
          helm upgrade --install my-api ./deploy \
            --namespace "$NAMESPACE" \
            --set image.tag=${{ github.sha }} \
            --set ingress.host="preview-${{ github.event.number }}.lunarops.io" \
            --set replicas.min=1 \
            --set replicas.max=1 \
            --values deploy/preview-values.yaml

      - name: Comment with preview URL
        uses: actions/github-script@v7
        with:
          script: |
            github.rest.issues.createComment({
              issue_number: context.issue.number,
              owner: context.repo.owner,
              repo: context.repo.repo,
              body: `🚀 Preview deployed: https://preview-${{ github.event.number }}.lunarops.io`
            })

  cleanup-preview:
    if: github.event.action == 'closed'
    runs-on: ubuntu-latest
    steps:
      - name: Delete preview environment
        run: |
          kubectl delete namespace "preview-${{ github.event.number }}" \
            --ignore-not-found

Self-Service Database Clones

Developers often need production-like data without using production data. Neon’s branching or a custom clone workflow:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
# platform CLI
platform db clone --source production --name my-feature-test
# ✓ Created database branch: my-feature-test
# ✓ Connection string stored in Vault: secret/my-api/db-branches/my-feature-test
# ✓ Branch auto-expires in 7 days

platform db list-branches
# NAME                 CREATED          EXPIRES          SIZE
# my-feature-test      2026-03-26       2026-04-02       4.2 GB
# alice-migration-test 2026-03-25       2026-04-01       4.2 GB

platform db delete-branch my-feature-test
# ✓ Branch deleted

Observability: Pre-Wired Golden Signals

Every service deployed via the golden path should automatically get:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
# PrometheusRule generated by the WebService operator
apiVersion: monitoring.coreos.com/v1
kind: PrometheusRule
metadata:
  name: my-api-slos
  namespace: my-api
spec:
  groups:
  - name: my-api.slos
    rules:
    # Success rate (availability SLO)
    - record: job:http_requests:success_rate5m
      expr: |
        sum(rate(http_requests_total{job="my-api",code!~"5.."}[5m]))
        / sum(rate(http_requests_total{job="my-api"}[5m]))

    # Alert when burning through error budget too fast
    - alert: MyApiSLOBreach
      expr: |
        job:http_requests:success_rate5m < 0.995
      for: 5m
      labels:
        severity: critical
        service: my-api
      annotations:
        summary: "my-api availability SLO breach"
        runbook: "https://wiki.internal/runbooks/my-api"
        dashboard: "https://grafana.internal/d/my-api"

Grafana dashboards provisioned automatically via ConfigMaps:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
apiVersion: v1
kind: ConfigMap
metadata:
  name: my-api-dashboard
  namespace: monitoring
  labels:
    grafana_dashboard: "1"   # picked up by Grafana sidecar
data:
  my-api.json: |
    {
      "title": "my-api",
      "panels": [
        { "title": "Request Rate", ... },
        { "title": "Error Rate", ... },
        { "title": "P50/P95/P99 Latency", ... },
        { "title": "Active Connections", ... }
      ]
    }

Policy as Code: Guardrails Not Gates

Kyverno policies enforce platform standards automatically — no human review required for routine deployments:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
# Require all images to come from the approved registry
apiVersion: kyverno.io/v1
kind: ClusterPolicy
metadata:
  name: require-approved-registry
spec:
  validationFailureAction: Enforce
  background: true
  rules:
  - name: check-registry
    match:
      any:
      - resources:
          kinds: [Pod]
          namespaces: ["production", "staging"]
    validate:
      message: "Images must come from ghcr.io/myorg/"
      pattern:
        spec:
          containers:
          - image: "ghcr.io/myorg/*"
---
# Require resource limits on all containers
apiVersion: kyverno.io/v1
kind: ClusterPolicy
metadata:
  name: require-resource-limits
spec:
  validationFailureAction: Enforce
  rules:
  - name: check-limits
    match:
      any:
      - resources:
          kinds: [Pod]
    validate:
      message: "Resource limits are required"
      pattern:
        spec:
          containers:
          - resources:
              limits:
                memory: "?*"
                cpu: "?*"
---
# Mutate: automatically add cost-allocation labels
apiVersion: kyverno.io/v1
kind: ClusterPolicy
metadata:
  name: add-cost-labels
spec:
  rules:
  - name: add-team-label
    match:
      any:
      - resources:
          kinds: [Namespace]
    mutate:
      patchStrategicMerge:
        metadata:
          labels:
            cost-center: "{{ request.object.metadata.annotations.\"platform.io/owner\" }}"

Measuring IDP Success

Track these metrics from day one:

Time to first deployment (TTFD): How long does it take a brand-new service to go from platform new service to running in staging? Target: under 30 minutes. Measure it quarterly.

Developer NPS: Send a monthly survey: “How easy is it to deploy a service using the platform? (1-10)”. Track trend. Below 7 is a red flag.

Golden path adoption: number of services using the standard Helm chart / total services. Low adoption means the path doesn’t fit real use cases.

Platform incidents: Treat the CI/CD system, ArgoCD, and Vault as production systems with SLOs. When the platform is down, everyone is blocked.

Change failure rate for platform changes: Measure how often platform updates cause incidents in product teams’ services. Should trend toward zero.


An IDP succeeds when developers stop thinking about it. The goal isn’t an impressive architecture — it’s a system that product teams interact with so smoothly it becomes invisible infrastructure. Build towards that invisibility one friction point at a time: find the most common complaint, remove it, measure the improvement, repeat. The platform is never finished; it grows as your organization’s needs do.

Comments