Production engineering guide for internal developer portal covering patterns, implementation strategies, and operational best practices.
An internal developer portal is a unified, self-service platform that centralizes access to developer tools, services, documentation, and workflows. It matters when teams grow beyond a few developers, when services are distributed across teams and environments, and when onboarding new engineers takes more than a single Slack message. It becomes essential when developers must discover, provision, monitor, and operate services with minimal coordination—when the platform owns the developer experience.
Developers need to find, understand, and integrate services into their applications. The portal enables this through a searchable, curated catalog of services, each with metadata, usage examples, and integration guides.
A service is registered via a service.yaml file in the project repository, checked into ./platform/service.yaml:
name: user-auth-service
title: User Authentication Service
description: Handles user registration, login, and session management.
owner: [email protected]
maintainers:
- [email protected]
- [email protected]
documentation:
url: https://docs.example.com/user-auth
quickstart: |
curl -X POST https://auth.example.com/api/v1/register \
-H "Content-Type: application/json" \
-d '{"email": "[email protected]", "password": "secret"}'
api-reference: https://api.example.com/user-auth/openapi.yaml
tags:
- auth
- identity
- microservice
When git push deploys a new version, the portal automatically indexes the service. A developer navigating to /services sees a list with filters by tag, owner, and status. Clicking a service reveals:
curl command.If the service is deployed, the portal displays the status badge: green for healthy, yellow for degraded, red for failed. The badge links to a GET /api/v1/status endpoint that returns:
{
"service": "user-auth-service",
"status": "healthy",
"uptime": "99.8%",
"last-deployed": "2023-10-05T12:34:56Z",
"dependencies": [
{
"name": "database",
"status": "healthy",
"latency": 12,
"error-rate": 0.003
},
{
"name": "identity-provider",
"status": "degraded",
"latency": 89,
"error-rate": 0.08
}
]
}
The portal also enables service templates. When a team creates a new service, they select a template from /templates. Each template is a directory containing template.yaml, README.md, and a set of scaffolded files. The portal exposes a create service button that opens a form with:
github-actions.yml.The form submits a POST /api/v1/services request:
{
"template": "microservice-go",
"name": "billing-service",
"owner": "[email protected]",
"create-repo": true,
"github-org": "acme"
}
The portal then clones a repository, applies the template, and creates a PR with the initial go.mod, main.go, and Dockerfile.
Once a service exists, developers need to create and manage environments: dev, staging, production, and ephemeral ones for feature branches.
The portal provides a create environment form. It accepts:
feature-credit-card).staging).When submitted, the portal sends a POST /api/v1/environments request:
{
"service": "billing-service",
"name": "feature-credit-card",
"base": "staging",
"config": {
"database": {
"url": "postgresql://db-billing-cc:5432/billing",
"pool-size": 20
},
"features": {
"enable-credit-card": true,
"max-amount": 100000
}
}
}
The portal then triggers a configuration system that applies the environment via a config diff. It computes the difference between the base environment and the new one:
+ database.url = postgresql://db-billing-cc:5432/billing
+ database.pool-size = 20
+ features.enable-credit-card = true
+ features.max-amount = 100000
Each config key is stored as a row in a config_values table:
| service_id | environment | key | value | updated_at |
|---|---|---|---|---|
| billing-service | staging | database.url | postgresql://db-staging:5432/billing | 2023-09-20T10:00:00Z |
| billing-service | feature-credit-card | database.url | postgresql://db-billing-cc:5432/billing | 2023-10-05T12:34:56Z |
The portal uses a config loader service that, at runtime, queries this table and merges values in order: base → environment → override. A service starts with a config.yaml that defines defaults:
database:
url: postgresql://db.example.com/billing
pool-size: 10
features:
enable-credit-card: false
max-amount: 50000
When the service boots, it loads config via GET /api/v1/config?service=billing-service&env=feature-credit-card. The response is a merged configuration object:
{
"database": {
"url": "postgresql://db-billing-cc:5432/billing",
"pool-size": 20
},
"features": {
"enable-credit-card": true,
"max-amount": 100000
}
}
A common failure mode: environment-specific config is applied, but the config.yaml file is not updated in the repository. The portal detects this via a config-diff job that compares the config.yaml file in Git with the config_values table. When out of sync, it marks the environment as stale, and the developer sees a yellow warning badge.
The portal surfaces deployment workflows and observability data in a unified view. When a service is deployed, the portal records the deployment event, captures the deployer, and links to the CI/CD logs.
A deployment is triggered by a POST /api/v1/deployments request:
{
"service": "user-auth-service",
"version": "v1.2.3",
"commit": "a1b2c3d4e5f6",
"author": "[email protected]",
"message": "Add password reset endpoint",
"ci-job-id": "github-actions-12345",
"environment": "staging"
}
The portal displays a deployment history table. Each row includes:
The diff is computed by comparing the config.yaml and package.json files between the current and previous deployments. It uses git diff and jq to generate a unified diff:
--- package.json
+++ package.json
@@ -1,6 +1,6 @@
{
"name": "user-auth-service",
- "version": "1.2.2",
+ "version": "1.2.3",
"dependencies": {
"express": "^4.18.0",
"pg": "^8.11.0"
},
"scripts": {
"start": "node server.js"
}
}
When a deployment fails, the portal fetches logs from the CI/CD system via GET /api/v1/jobs/github-actions-12345/logs. It parses the output and displays the error log section with a red border and a failure reason summary:
Error: Failed to build Docker image
at /home/runner/work/user-auth-service/main.go:23
exit status 1
stdout:
Step 1/8 : FROM golang:1.21-alpine
---> c7a2b8b4c0d8
Step 2/8 : COPY . /app
---> Using cache
---> 1b3f5c7d9e0a
Step 3/8 : WORKDIR /app
---> Using cache
---> 2c4d6e8f0a1b
Step 4/8 : RUN go mod tidy
---> Running in 3e4f5g6h7i8j
go: downloading github.com/gorilla/mux v1.8.0
go: downloading github.com/go-sql-driver/mysql v1.8.0
go: finding github.com/go-sql-driver/mysql v1.8.0
go: finding github.com/gorilla/mux v1.8.0
go: errors while loading modules:
github.com/go-sql-driver/mysql v1.8.0: go.mod:1:1: module github.com/go-sql-driver/mysql
github.com/go-sql-driver/mysql v1.8.0: go.mod:2:2: go version 1.21 required by go.mod, but not available
exit status 1
stderr:
error: failed to build image
The portal also pulls metrics from a Prometheus instance. A service dashboard shows:
rate(http_request_duration_seconds_sum[5m]).sum(rate(http_requests_total{status=~"5.."}[5m])) by (service).When a trace is selected, the portal displays the full span tree with durations, service boundaries, and error flags.
The portal supports onboarding through a first-time developer workflow. When a new engineer signs into the portal, they see a onboarding checklist with tasks:
Each task is associated with a check script executed via curl or a bash script. The portal runs the script in a container and updates the task status.
For example, the task "Install CLI tools" runs:
#!/bin/bash
set -e
echo "Installing CLI tools..."
curl -LO https://dl.k8s.io/release/v1.28.0/bin/linux/amd64/kubectl
sudo install -o root -g root -m 0755 kubectl /usr/local/bin/kubectl
# Install jq
sudo apt-get update && sudo apt-get install -y jq
# Verify
kubectl version --client
jq --version
echo "✅ CLI tools installed"
The portal captures the output and marks the task as complete if the script exits with 0. If it fails, it displays the error output:
Error: failed to install kubectl
curl: (22) The requested URL returned error: 404 Not Found
https://dl.k8s.io/release/v1.28.0/bin/linux/amd64/kubectl
exit status 22
The portal also tracks developer activity via a user_events table:
| user_id | event_type | details | timestamp |
|---|---|---|---|
| [email protected] | onboarding.task.completed | task="install-cli-tools" | 2023-10-05T13:01:22Z |
| [email protected] | deployment.created | service="user-auth-service", version="v1.2.3" | 2023-10-05T13:15:33Z |
This data enables personalized dashboards. The portal displays a developer profile with:
The portal itself is a service, and its health is monitored. It exposes a /health endpoint returning:
{
"status": "healthy",
"checks": [
{
"name": "database",
"status": "healthy",
"latency": 23,
"error": null
},
{
"name": "config-loader",
"status": "degraded",
"latency": 189,
"error": "Error: failed to connect to config database: connection refused"
},
{
"name": "ci-integration",
"status": "healthy",
"latency": 11,
"error": null
}
],
"uptime": "99.7%"
}
A configuration error silently breaks the portal: if the config_loader service fails to connect to the database, the portal continues serving, but environment-specific config is not applied. Developers see the service working, but their configuration is stale.
The portal also supports custom widgets. A team can define a widget by creating a widget.yaml in their service’s ./platform/ directory:
name: deployment-dashboard
title: Deployment Dashboard
description: Real-time view of all service deployments.
type: react
source: https://github.com/acme/deployment-widget
version: v0.1.2
config:
refresh-interval: 30
show-errors: true
The portal renders the widget using a GET /api/v1/widgets/deployment-dashboard endpoint that returns a React component serialized as a JSON object.
The sharp edges: the portal assumes config.yaml is in the service’s root, but some services use config.json. When a service uses config.json, the portal shows a warning in the service’s details panel. It does not automatically detect the file, nor does it parse the JSON into the config model. Similarly, environment names are case-sensitive. A deployer creates Staging, but the portal expects staging, and the environment is not found. The portal logs the mismatch but does not resolve it.
A subtle failure: when a service is updated, the portal updates the service.yaml, but the service.yaml is not committed to Git. The portal detects this via a git status check and marks the service as untracked. The developer sees a red badge on the service card and can click to sync with Git, which runs git add . && git commit -m "Sync service metadata" && git push.
Finally, the portal exposes a developer API for custom integrations. A team can write a script that creates a service and deploys it in one call:
curl -X POST https://portal.example.com/api/v1/developer -H "Authorization: Bearer $TOKEN" -d @request.json
The request body is a JSON object that supports nested structures, optional fields, and complex configuration:
{
"service": {
"name": "order-processing",
"template": "microservice-node",
"owner": "[email protected]"
},
"deployments": [
{
"version": "v1.0.0",
"environment": "production",
"config": {
"queue": "kafka://kafka:9092/orders",
"workers": 8
}
}
],
"notes": "Initial rollout with Kafka integration"
}
The portal returns a 201 Created response with a Location header pointing to the new service, and the script can follow up with a GET /api/v1/services/order-processing to verify the setup.
This is the internal developer portal: a living, self-updating, developer-first interface to the entire platform.
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