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.
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
|
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.
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