· · 11 min read Advanced

Building an Internal Developer Platform: Where to Start

Build an Internal Developer Platform that developers actually use. Start small, solve real problems, and grow organically. Practical guide with examples.

A three-button food truck with a queue of goblins next to an empty gleaming platform palace

TL;DR An Internal Developer Platform is a thin layer between what a developer wants and what the cluster demands. Mine is a Helm base chart, ArgoCD and a handful of Kyverno policies, about 500 lines of my own code. Start with those three, and add observability and a portal only when the tickets tell you to.

Should you build an Internal Developer Platform? Usually, yes. It’s the how that wrecks teams.

I’ve watched a platform that cost a small fortune go live and then sit unused, because nobody wanted it. I’ve also seen a couple of Helm charts and one Kyverno policy change how a whole team ships software. Budget didn’t separate those two. Neither did the choice of tools. One team started from a problem developers had. The other built the platform it imagined developers should want.

So instead of a reference architecture to copy, I’ll build this up from the smallest thing worth deploying. Stop reading at any point and you should still have something you can ship.

What an IDP is

An Internal Developer Platform is a self-service layer that keeps the infrastructure plumbing away from the people building applications. Developers describe what they need. The platform works out how to make it real.

With Platform

Developer

Platform API

Kubernetes

CI/CD

Security

Monitoring

Without Platform

Developer

Kubernetes YAML

CI Pipeline

Security Config

Monitoring Setup

The platform sits between what a developer wants and what the cluster requires. Everything in this post is about keeping that layer thin, and easy to get out of on the day it’s in the way.

Why so many platforms die

The failure modes first, then the build steps. Most of them are predictable and you can design around them.

The classic one is building in isolation. The platform team shuts itself in a room, ships what it thinks people need, and gets annoyed when nobody adopts it. The developers weren’t being difficult. They got handed a tool for problems they didn’t have.

Close behind is doing too much too soon. A service catalog, a custom UI, self-service for every conceivable resource, all planned up front. Six months later there’s an impressive demo and nothing in production.

Then there’s the wrong altitude. Too low and the platform is Kubernetes with extra steps, so why bother. Too high and there’s no escape hatch on the day someone hits a case the abstraction never anticipated. Someone always does.

And the one that has sunk more platform efforts than any technical mistake. The platform only works for greenfield, existing services can’t move onto it, and now you run two systems forever.

Start with problems, not a solution

This is the part people skip. Before you write a single template, go find out where the pain is.

Where do developers lose time? Waiting on infrastructure requests, copy-pasting the same boilerplate config, debugging deployments that fail for reasons nobody documented, hunting down production access. What do they keep asking for? Your ticketing system knows. Your Slack channels know. And, revolutionary idea, you can ask them.

Then look at where incidents come from. Misconfigured services, missing network policies, resource limits nobody set. Automate those away first, because every one of them is a page you stop getting.

The answers say what to build. Build anything before you have them and you’re guessing.

The simplest useful platform

Here’s the minimum I’d bother deploying. Three pieces, all from tools you already run.

Golden paths

Opinionated templates that bake in the right defaults so nobody has to remember them.

# service-template/
├── deployment.yaml      # Standard deployment pattern
├── service.yaml         # Service with sensible defaults
├── networkpolicy.yaml   # Security by default
├── servicemonitor.yaml  # Automatic monitoring
└── values.yaml          # Customization points

A developer gets a working, monitored service with a network policy out of the box, and only touches the values that make their service different. The safe configuration is also the one with the least typing, and that’s why people use it.

Self-service deployment

Push code, get a deployment. No ticket, no waiting on someone in another timezone.

With GitOps:

# Application definition in Git
apiVersion: argoproj.io/v1alpha1
kind: Application
metadata:
  name: my-service
  namespace: argocd
spec:
  source:
    repoURL: https://gitlab.internal/my-service
    path: deploy
    targetRevision: main
  destination:
    server: https://kubernetes.default.svc
    namespace: my-service
  syncPolicy:
    automated:
      prune: true
      selfHeal: true

Merge to main, ArgoCD picks it up, the service ships. You don’t need a portal for this to count as self-service. Git already is one.

Guardrails

The flip side of self-service is that people deploy things that hurt them. Kyverno policies catch the obvious mistakes before they land:

# Require resource limits
apiVersion: kyverno.io/v1
kind: ClusterPolicy
metadata:
  name: require-limits
spec:
  validationFailureAction: Enforce
  rules:
    - name: require-limits
      match:
        resources:
          kinds:
            - Pod
      validate:
        message: "CPU and memory limits are required"
        pattern:
          spec:
            containers:
              - resources:
                  limits:
                    cpu: "?*"
                    memory: "?*"

A pod without limits never reaches the cluster. Nobody has to remember the rule, the admission controller remembers it for them.

That’s a real platform. Templates, GitOps, guardrails. Ship only this and developers deploy safely without learning the Kubernetes internals, and the most common tickets are gone. You could stop here for a year and be fine.

The building blocks

Once the basics stick, you flesh out the pieces. None of this is exotic. It’s mostly configuration of tools you already have.

Service templates with Helm

A base chart teams extend instead of forking:

# base-service/Chart.yaml
apiVersion: v2
name: base-service
version: 1.0.0
description: Standard service template

# base-service/templates/deployment.yaml
apiVersion: apps/v1
kind: Deployment
metadata:
  name: {{ .Values.name }}
spec:
  replicas: {{ .Values.replicas | default 2 }}
  selector:
    matchLabels:
      app: {{ .Values.name }}
  template:
    metadata:
      labels:
        app: {{ .Values.name }}
      annotations:
        prometheus.io/scrape: "true"
    spec:
      containers:
        - name: {{ .Values.name }}
          image: {{ .Values.image }}
          ports:
            - containerPort: {{ .Values.port | default 8080 }}
          resources:
            requests:
              cpu: {{ .Values.resources.requests.cpu | default "100m" }}
              memory: {{ .Values.resources.requests.memory | default "128Mi" }}
            limits:
              cpu: {{ .Values.resources.limits.cpu | default "500m" }}
              memory: {{ .Values.resources.limits.memory | default "512Mi" }}
          readinessProbe:
            httpGet:
              path: {{ .Values.healthPath | default "/health" }}
              port: {{ .Values.port | default 8080 }}

A team consumes it with a small values file:

# my-service/values.yaml
name: my-service
image: registry.internal/my-service:v1.2.3
replicas: 3
port: 8080
resources:
  requests:
    cpu: 200m
    memory: 256Mi
  limits:
    cpu: 1
    memory: 1Gi

Fix a bug in the base chart and every service picks up the fix on its next deploy. That’s what the base chart is for.

The namespace as a boundary

Give each team a namespace that arrives with its guardrails in place:

apiVersion: v1
kind: Namespace
metadata:
  name: team-payments
  labels:
    team: payments
    environment: production
---
apiVersion: v1
kind: ResourceQuota
metadata:
  name: team-quota
  namespace: team-payments
spec:
  hard:
    requests.cpu: "10"
    requests.memory: 20Gi
    limits.cpu: "20"
    limits.memory: 40Gi
    pods: "50"
---
apiVersion: networking.k8s.io/v1
kind: NetworkPolicy
metadata:
  name: default-deny
  namespace: team-payments
spec:
  podSelector: {}
  policyTypes:
    - Ingress
    - Egress

Inside the namespace the team decides. The quota and the default-deny policy belong to the platform. A runaway workload in payments can’t eat the cluster, and traffic is closed until someone deliberately opens it.

Self-service through pull requests

Teams change their own config the same way they change code:

infrastructure/
├── teams/
│   ├── payments/
│   │   ├── namespace.yaml
│   │   ├── applications/
│   │   │   ├── api.yaml
│   │   │   └── worker.yaml
│   │   └── secrets/
│   │       └── external-secret.yaml
│   ├── orders/
│   └── ...

A pull request triggers review, the merge triggers an ArgoCD sync, and the change is live. Every infrastructure change has an author, a reviewer and a diff. That’s an audit trail, and nobody had to build one.

Growing the platform

You don’t bolt all of this on at once. Each phase is a place you can stop and live for a while.

Phase one is what I just walked through. Helm charts for the common patterns, Kyverno for safety, GitOps for deployment, and enough docs to find your way around. People deploy safely without a deep Kubernetes background.

Phase two is observability nobody wires up by hand:

A service deployed through the platform shows up in dashboards with no extra config. When it breaks, the data is already there.

Phase three is when a UI starts to earn its keep, whether that’s Backstage or something you build yourself:

Developer Portal

Service Catalog

Create from Template

Documentation

Service Status

Git Repository

ArgoCD

Kubernetes

The portal doesn’t replace any of the earlier layers. It generates Git commits that flow through the same ArgoCD you already trust. Same machinery, friendlier front door.

Phase four is the deeper self-service most teams get to eventually. Secret management with Vault, database provisioning, environment cloning, cost visibility. By now the platform handles the long tail of requests that used to land in your queue.

What not to build

The refusals matter as much as the builds. Four I’d make every time.

Don’t invent your own YAML DSL. Someone always wants to, and the result is a configuration language only your team understands, with worse tooling than Helm, Kustomize or cdk8s give you for free. Say no.

If developers still raise tickets to get things done, the automation isn’t finished. Each kind of ticket is a workflow you haven’t built yet.

Don’t make one size fit all. Golden paths are defaults. The team that knows what it’s doing should be able to drop down to raw manifests without asking permission.

And keep the platform boring on purpose. A platform that ships new features every week usually hasn’t found its real problems yet. I’d rather run one nobody talks about.

Knowing if it’s working

You’ll want to know whether any of this paid off. A few signals I watch.

Time to production first. How long from “new service idea” to “running in prod”? Weeks means the platform isn’t doing its job. Days is fine.

Ticket volume should trend down as the platform absorbs the routine work:

Before: 50 infrastructure tickets/week
After: 10 infrastructure tickets/week (edge cases only)

Ask developers a blunt question now and then. Would you recommend this platform to a colleague? That answer beats any dashboard. And check whether services deployed through the platform have fewer incidents than the hand-rolled ones. If the guardrails work, they should.

The team behind it

A platform is a product with users, so it needs people who own it. A rough rule is one platform engineer for every ten to fifteen application developers. Too few and the platform stops evolving. Too many and they start inventing features nobody asked for, which is how the six-month demo happens.

The split of responsibility matters. The platform team maintains the templates and tools, writes the policies, handles platform-level incidents, and helps teams migrate onto the thing. Application teams own their services end to end. They deploy their own code, define their own resource needs, and take the first page when their service breaks.

Start centralised so the platform has a clear owner. As it matures, embed platform engineers part-time in product teams. Sitting next to the people who live with the platform is how you find out what hurts.

What this looks like in practice

My homelab platform runs on the pieces above.

The golden path is a Helm dependency:

# Standard service template
dependencies:
  - base-service  # Helm dependency
values:
  name: my-app
  image: registry/my-app:latest

Self-service is GitOps. Push to main and ArgoCD deploys, open a PR for a namespace change and it auto-merges after review. Guardrails are Kyverno enforcing limits, labels and security context, plus a default-deny NetworkPolicy. Observability comes with the template, a ServiceMonitor created automatically and a Grafana dashboard generated alongside it.

Custom code, all in, is somewhere around 500 lines of Helm templates and Kyverno policies. Everything else is configuration of tools that already existed. That ratio matters to me more than any feature. The less bespoke code the platform carries, the less of it I have to understand when it breaks, and I want to be able to read my own platform top to bottom.

The mistakes I see most

A few patterns show up again and again.

Starting with the portal is the most common. The UI is the fun part, so people build it first, before the workflows underneath are proven. Get GitOps and the CLI working, then put a face on it.

Ignoring existing services is the expensive one. A platform that only handles greenfield leaves you running two worlds in parallel forever. Migration is a feature, and it needs to be in the first version.

Over-engineering security early strangles adoption. Block every edge case on day one and people route around the platform entirely. Start permissive and tighten based on what incidents teach you.

Hiding all the complexity sounds nice until a senior developer needs to see what’s underneath and can’t. Some people want to see the machinery, and that instinct is healthy. Give them escape hatches and real documentation.

Why bother

Developer time is the most expensive thing on the team, and every hour spent fighting infrastructure is an hour not spent on the product. With a platform that works, a junior can deploy safely on day one, and the senior who used to answer their tickets gets those hours back.

Mine started as one base chart and one Kyverno policy. 500 lines later, it’s still mostly that.