Production-grade guide to internal developer platform design covering architecture patterns, implementation strategies, testing approaches, and operational best practices for enterprise engineering teams.
An internal developer platform (IDP) is a coherent set of services, tools, and workflows that enable engineering teams to deliver software faster, more reliably, and with less friction. It matters when teams grow beyond a single monolith, when multiple services and teams need to coordinate, and when the cost of onboarding, deploying, and operating software exceeds the cost of writing code. A well-designed IDP reduces cognitive load, standardizes practices, and makes developer workflows predictable—so teams can focus on building features, not plumbing.
Onboarding begins with a git clone and ends with a running service in production. The first step is a golden path defined in a platform.yaml at the repo root.
platform:
name: "customer-service"
type: "microservice"
runtime: "nodejs"
default-branch: "main"
ci: "github-actions"
deployment:
environment: "staging"
strategy: "canary"
rollout: "5% per hour"
This file triggers a set of actions:
.github/workflows/ci.yml.docker-compose.yml is generated with services for the app, database, and message queue.kustomization.yaml is created in config/staging to define the staging environment.The onboarding experience fails silently if platform.yaml is missing or malformed. The most common error is a misaligned deployment.strategy value: canary is accepted, but canary-deployment is not, even though the platform's code expects canary. A deployment.strategy of "blue-green" is valid, but the platform assumes blue-green implies a promote step in the workflow, which is not configured unless platform.yaml includes deployment.promote: true.
The onboarding process is complete when a developer runs:
git clone https://github.com/org/customer-service.git
cd customer-service
./scripts/onboard.sh
The onboard.sh script does the following:
kubectl and helm via apt or brew.kustomization.yaml to a local kind cluster.npm run dev and opens the service in a browser.If onboard.sh fails, it logs the first command that returns a non-zero exit code. The most common failure is a kubectl apply -f config/staging that fails due to a missing namespace: staging in the kustomization.yaml. The error is:
error: error when applying patch:
unable to find resource: "namespace.staging" in namespace "staging"
This indicates that the platform expects a namespace field in kustomization.yaml, but the generated file only has name: staging.
Every service in the platform runs the same CI pipeline: build, test, lint, package, and publish.
The pipeline is triggered by a ci.yaml in the project root. This file is the source of truth for the entire build process.
ci:
image: "node:18"
steps:
- name: "lint"
command: "npm run lint"
timeout: "10m"
- name: "test"
command: "npm run test -- --coverage"
env:
TEST_ENV: "ci"
DB_URL: "postgresql://localhost:5432/test"
- name: "build"
command: "npm run build"
outputs:
- "dist/"
- "build/"
- name: "publish"
command: "npm run publish"
depends-on: ["test", "build"]
artifacts:
- "dist/"
- "coverage/"
The pipeline is executed by a ci-runner daemon that polls for ci.yaml files and runs jobs via docker run.
A common failure is a missing ci.yaml file. When absent, the platform falls back to a default pipeline defined in a ci-default.yaml at the platform root. The fallback is silent—no error message—but the default pipeline does not include test with coverage, so test coverage reports are missing in the CI dashboard.
Another sharp edge: the artifacts section in ci.yaml is not a list of glob patterns but a list of file paths relative to the project root. If a developer writes:
artifacts:
- "dist/**/*"
the platform expects dist/ to be a directory, but if dist is a file, the artifact is not uploaded. The error is logged as:
error: artifact path "dist/**/*" does not exist
The platform uses tar to package artifacts, and the resulting .tar.gz file is stored in a central artifact storage service, accessible via a REST API:
GET /artifacts?pipeline=customer-service&job=build-123
Response:
{
"id": "build-123",
"pipeline": "customer-service",
"status": "success",
"artifacts": [
{
"name": "dist.tar.gz",
"url": "https://artifacts.example.com/build-123/dist.tar.gz",
"size": 4201234
},
{
"name": "coverage.tar.gz",
"url": "https://artifacts.example.com/build-123/coverage.tar.gz",
"size": 834512
}
]
}
Deployment is initiated by pushing to a release branch or by merging into main with a changelog entry.
The platform defines a deployment.yaml file in the config/ directory.
deployment:
environments:
staging:
strategy: "canary"
rollout:
step: "5%"
interval: "1h"
readiness:
path: "/health"
timeout: "30s"
interval: "10s"
hooks:
pre-deploy:
- "kubectl apply -f config/staging"
- "echo 'Waiting for database migration...'"
- "psql -c 'SELECT pg_sleep(30)'"
post-deploy:
- "curl -X POST https://monitoring.example.com/health-check"
- "echo 'Deployment successful'"
The deployment process runs in a deployer service that watches for changes to deployment.yaml and executes the steps.
A common failure occurs when pre-deploy hooks fail. The platform logs the output of each command, but if a command returns a non-zero exit code, the deployment fails immediately. The error is:
error: pre-deploy hook failed:
command: "psql -c 'SELECT pg_sleep(30)'"
exit code: 1
output:
ERROR: relation "migrations" does not exist
CONTEXT: SQL statement "SELECT pg_sleep(30)"
This indicates that the database migration was not applied before the deployment. The platform expects migrations to be applied via a migrate command, but it is not included in the pre-deploy hooks.
The platform supports canary and blue-green strategies. For canary, the deployment process deploys a new version alongside the current one and gradually shifts traffic.
The traffic shift is managed by a traffic-shifter service that updates a traffic.yaml file.
traffic:
service: "customer-service"
strategy: "canary"
version:
current: "v1.2.0"
next: "v1.3.0"
rollout:
step: "10%"
interval: "30m"
status:
progress: "50%"
last-update: "2024-05-14T12:34:56Z"
The traffic-shifter service watches traffic.yaml and updates the service’s routing configuration. If the traffic.yaml is missing, the platform defaults to strategy: "blue-green" and step: "100%".
Environments are defined in config/<environment>/ directories. Each environment has a values.yaml and a kustomization.yaml.
database:
replicas: 2
storage: "10Gi"
image: "postgres:14"
config:
max_connections: 200
shared_buffers: "2GB"
app:
replicas: 4
image: "customer-service:v1.3.0"
env:
NODE_ENV: "staging"
LOG_LEVEL: "info"
The kustomization.yaml defines overlays and patches.
# config/staging/kustomization.yaml
resources:
- base.yaml
- service.yaml
- ingress.yaml
patches:
- path: patches/enable-tracing.yaml
target:
kind: Deployment
name: customer-service
- path: patches/enable-metrics.yaml
target:
kind: Service
name: customer-service
The platform applies kustomization.yaml using kustomize build config/staging and deploys the result with kubectl apply -f -.
A silent failure occurs when kustomization.yaml is missing a resources section. The platform assumes base.yaml is always present, but if it is not, the deployment fails with:
error: unable to find resource: "base.yaml"
Another common issue: patches are applied in order, and the platform does not merge patches with the same target and kind. If two patches define app.replicas, the second patch overwrites the first, not merges it.
The platform allows environment configuration to be versioned in Git. A config/staging directory can be a Git submodule or a standalone repository. When the config/staging directory is updated, the platform triggers a sync-config job that applies the new configuration to the cluster.
The sync-config job fails if the values.yaml file is invalid YAML. The error is logged as:
error: failed to parse values.yaml:
line 10, column 15: expected '}' but found ']' in map
This often happens when a developer writes:
app:
replicas: 4
env:
- NODE_ENV: staging
- LOG_LEVEL: info
The platform expects a map ({}), not a list ([]), so env becomes a list of key-value pairs, not a map.
The platform manages feature flags via a features.yaml file in the project root.
features:
user-profile-updates:
enabled: true
stages:
- name: "beta"
rollout: "25%"
conditions:
- "user.id % 10 == 0"
- "user.region == 'us-west'"
rollout:
- "10% per day"
- name: "prod"
rollout: "100%"
conditions:
- "user.active == true"
rollout:
- "10% per hour"
metadata:
owner: "dev-team"
description: "Allow users to update profile pictures and preferences"
The platform evaluates feature flags at runtime using a feature-flag-client library that fetches the current state from a REST API.
GET /features/user-profile-updates?user.id=123&user.region=us-west&user.active=true
Response:
{
"feature": "user-profile-updates",
"enabled": true,
"stage": "prod",
"rollout": "100%",
"evaluation": {
"conditions": [
{ "condition": "user.id % 10 == 0", "result": true },
{ "condition": "user.region == 'us-west'", "result": true },
{ "condition": "user.active == true", "result": true }
],
"reason": "All conditions met"
}
}
A common failure is a missing user.id query parameter. If user.id is not provided, the evaluation returns:
{
"feature": "user-profile-updates",
"enabled": true,
"stage": "beta",
"rollout": "25%",
"evaluation": {
"conditions": [
{ "condition": "user.id % 10 == 0", "result": false },
{ "condition": "user.region == 'us-west'", "result": true },
{ "condition": "user.active == true", "result": true }
],
"reason": "Missing user.id"
}
}
The platform also supports feature flag rollout schedules. A rollout entry like "10% per hour" is parsed by a rollout-parser that converts it into a rollout-schedule.yaml file.
rollout-schedule:
feature: "user-profile-updates"
start: "2024-05-14T00:00:00Z"
steps:
- at: "2024-05-14T01:00:00Z"
percentage: 10
- at: "2024-05-14T02:00:00Z"
percentage: 20
If the rollout string is malformed—e.g., "10% per day" instead of "10% per hour"—the platform logs:
warning: unknown rollout interval: "per day", expected: "per hour", "per minute", "per week"
The platform assumes that every component can fail silently. A service can be running but not responding to health checks. The platform uses kustomize for configuration, git for versioning, docker for containerization, and kubectl for orchestration—tools that are widely used but not always configured identically.
The most common failure is a missing config/ directory. When absent, the platform falls back to a config/base/ directory but does not apply kustomization.yaml from config/base. Instead, it applies kustomization.yaml from config/base/kustomization.yaml, but the resources section is not read.
Another sharp edge: the platform.yaml file is read at the start of the developer's workflow, but changes to it are not picked up until the next onboard.sh run. A developer who updates platform.yaml after running onboard.sh sees no effect until they re-run the script.
The platform uses systemd to manage daemons: ci-runner, deployer, traffic-shifter, and feature-flag-client. These services are configured via systemd unit files in /etc/systemd/system.
A common failure is a deployer.service that is not enabled:
systemctl status deployer
Output:
● deployer.service - Internal Deployer
Loaded: loaded (/etc/systemd/system/deployer.service; disabled)
Active: inactive (dead)
This indicates that the service is not started on boot. The platform expects deployer to be enabled and running, so deployments are missed.
The platform also logs all events to a central events.log file in /var/log/platform/events.log. Each event is a JSON object with timestamp, type, source, and payload.
Example:
{
"timestamp": "2024-05-14T12:34:56Z",
"type": "deployment.started",
"source": "deployer",
"payload": {
"service": "customer-service",
"version": "v1.3.0",
"environment": "staging"
}
}
A missing events.log file is a silent failure: the platform logs to /var/log/platform/events.log, but if the directory does not exist, the logging fails silently. The error is not logged until the next journalctl query.
The platform design is complete when every developer can run onboard.sh, build, test, and deploy their service without reading documentation—just by following the files in the project root.
This page was rewritten on 18 October 2026. It replaced a templated version that was not specific to its own title. How these pages are written and checked. If anything here is wrong, tell us at [email protected] and we will correct it.
We use cookies for analytics (Google Analytics) and advertising (Google AdSense) to improve your experience and support free content. Privacy Policy