The State of Internal Developer Tooling
Internal tooling at most companies consists of incomplete Bash scripts and Confluence pages that have not been updated since 2023. Developers avoid the "official" workflows because those workflows were designed by someone who left eighteen months ago, and the support Slack channel has three members, two of which are bots.
Platform engineering is not about building another tool. It is about building a product, one where the customers happen to sit in the same office (or Zoom call) as the platform team.
Backstage: The Open-Source Framework
Spotify open-sourced Backstage in 2020, and it has grown into the de facto framework for building developer portals. Backstage out of the box is a service catalog with a well-designed UI. The real power comes from plugins, and that is where the engineering investment lies.
Setting Up a Backstage Instance
The npx @backstage/create-app setup can be skipped here, the documentation covers it adequately. What it does not cover well is how to structure an instance for a real organization.
# app-config.yaml - production-grade configuration
app:
title: "Acme Developer Portal"
baseUrl: https://portal.acme.internal
backend:
baseUrl: https://portal.acme.internal
database:
client: pg
connection:
host: ${POSTGRES_HOST}
port: ${POSTGRES_PORT}
user: ${POSTGRES_USER}
password: ${POSTGRES_PASSWORD}
catalog:
rules:
- allow: [Component, System, API, Resource, Location, Template, Domain, Group, User]
providers:
github:
acmeOrg:
organization: 'acme-corp'
catalogPath: '/catalog-info.yaml'
filters:
repository: '.*'
schedule:
frequency: { minutes: 30 }
timeout: { minutes: 3 }
locations:
- type: url
target: https://github.com/acme-corp/backstage-catalog/blob/main/org.yaml
The critical piece here is the GitHub discovery provider. Instead of manually registering every service, you let Backstage crawl your GitHub org and find catalog-info.yaml files. This is the only approach that scales past twenty services.
A Production catalog-info.yaml
Most examples show a minimal spec. Here is what a production catalog-info.yaml looks like after six months of running Backstage, once the relevant metadata requirements become clear:
apiVersion: backstage.io/v1alpha1
kind: Component
metadata:
name: payment-service
description: "Handles payment processing, refunds, and subscription billing"
annotations:
github.com/project-slug: acme-corp/payment-service
backstage.io/techdocs-ref: dir:.
pagerduty.com/service-id: P1A2B3C
sonarqube.org/project-key: acme_payment-service
backstage.io/kubernetes-id: payment-service
jenkins.io/job-full-name: acme/payment-service/main
tags:
- java
- payments
- pci
links:
- url: https://grafana.acme.internal/d/payment-svc
title: Grafana Dashboard
icon: dashboard
- url: https://runbooks.acme.internal/payment-service
title: Runbook
icon: docs
spec:
type: service
lifecycle: production
owner: team-payments
system: billing-platform
dependsOn:
- resource:default/payments-db
- component:default/notification-service
providesApis:
- payment-api
consumesApis:
- stripe-gateway-api
The annotations block is the key integration point. Each annotation connects Backstage to an external system, PagerDuty for on-call, SonarQube for code quality, Kubernetes for runtime status. Without these, a developer portal provides little value beyond a spreadsheet.
Port: The Declarative Alternative
Port takes a fundamentally different approach. Instead of plugins and code, you define your data model as blueprints, essentially schemas for the entities in your developer platform.
{
"identifier": "microservice",
"title": "Microservice",
"icon": "Microservice",
"schema": {
"properties": {
"language": {
"type": "string",
"enum": ["Go", "Java", "Python", "Node.js", "Rust"],
"title": "Language"
},
"tier": {
"type": "string",
"enum": ["critical", "standard", "experimental"],
"title": "Service Tier",
"description": "Determines SLO targets and on-call requirements"
},
"repository_url": {
"type": "string",
"format": "url",
"title": "Repository"
},
"last_deploy": {
"type": "string",
"format": "date-time",
"title": "Last Deployment"
},
"dora_lead_time_hours": {
"type": "number",
"title": "Lead Time (hours)"
},
"resource_cost_monthly": {
"type": "number",
"title": "Monthly Cost ($)"
}
},
"required": ["language", "tier", "repository_url"]
},
"relations": {
"team": {
"target": "team",
"required": true,
"title": "Owning Team"
},
"environment": {
"target": "environment",
"many": true,
"title": "Deployed Environments"
}
}
}
A genuine strength of Port is the ability to model things that Backstage makes awkward. Cost attribution, compliance status, DORA metrics, these become first-class properties instead of annotations you have to parse.
Self-Service Actions in Port
This is where Port shines. You can create self-service actions that trigger real infrastructure changes:
{
"identifier": "provision_database",
"title": "Provision PostgreSQL Database",
"trigger": "CREATE",
"invocationMethod": {
"type": "GITHUB",
"org": "acme-corp",
"repo": "platform-actions",
"workflow": "provision-db.yml"
},
"userInputs": {
"properties": {
"db_name": {
"type": "string",
"pattern": "^[a-z][a-z0-9_]{2,30}$",
"title": "Database Name"
},
"instance_size": {
"type": "string",
"enum": ["small", "medium", "large"],
"default": "small",
"title": "Instance Size"
},
"enable_read_replica": {
"type": "boolean",
"default": false,
"title": "Enable Read Replica"
}
},
"required": ["db_name", "instance_size"]
}
}
The developer clicks a button, fills in a form, and a GitHub Actions workflow provisions a real RDS instance with Terraform. No tickets, no waiting for the infra team, no Slack DMs at 4pm on a Friday.
Golden Paths: The Heart of Platform Engineering
A golden path is a pre-paved, opinionated way to do something. It's not the only way, it's the recommended way. The distinction matters.
The following Backstage Software Template implements a golden path for creating a new microservice:
apiVersion: scaffolder.backstage.io/v1beta3
kind: Template
metadata:
name: go-microservice
title: Go Microservice (Golden Path)
description: "Production-ready Go service with CI/CD, observability, and Kubernetes manifests"
tags:
- go
- recommended
- golden-path
spec:
owner: team-platform
type: service
parameters:
- title: Service Details
required:
- name
- owner
- tier
properties:
name:
title: Service Name
type: string
pattern: '^[a-z][a-z0-9-]{2,30}$'
ui:help: 'Lowercase, hyphens only. This becomes the repo name, k8s namespace, and DNS entry.'
owner:
title: Owning Team
type: string
ui:field: OwnerPicker
ui:options:
catalogFilter:
kind: Group
tier:
title: Service Tier
type: string
enum: ['critical', 'standard', 'experimental']
enumNames: ['Critical (99.9% SLO)', 'Standard (99.5% SLO)', 'Experimental (best-effort)']
- title: Infrastructure
properties:
needsDatabase:
title: PostgreSQL Database
type: boolean
default: false
needsRedis:
title: Redis Cache
type: boolean
default: false
needsQueue:
title: SQS Queue
type: boolean
default: false
steps:
- id: fetch
name: Fetch Template
action: fetch:template
input:
url: ./skeleton
values:
name: ${{ parameters.name }}
owner: ${{ parameters.owner }}
tier: ${{ parameters.tier }}
- id: publish
name: Create Repository
action: publish:github
input:
allowedHosts: ['github.com']
repoUrl: 'github.com?owner=acme-corp&repo=${{ parameters.name }}'
defaultBranch: main
protectDefaultBranch: true
requireCodeOwnerReviews: true
- id: register
name: Register in Catalog
action: catalog:register
input:
repoContentsUrl: ${{ steps['publish'].output.repoContentsUrl }}
catalogInfoPath: '/catalog-info.yaml'
In under two minutes, a developer goes from "I need a new service" to having a repository with CI/CD pipelines, Kubernetes manifests, a Dockerfile, structured logging, health checks, Prometheus metrics, and a Grafana dashboard, all pre-configured and following organizational standards.
Developer Experience Metrics Worth Tracking
Improvement requires measurement, and most platform teams measure the wrong things. "Number of Backstage page views" provides no actionable insight. The following metrics are worth tracking:
# developer_experience_metrics.py
from dataclasses import dataclass
from datetime import timedelta
@dataclass
class PlatformMetrics:
# Time from "I need X" to "X is provisioned and usable"
time_to_first_request: timedelta
# Percentage of new services using golden path templates
golden_path_adoption_rate: float
# How many times developers had to ask for help on Slack
# vs self-served through the portal
self_service_ratio: float
# DORA: time from commit to production
lead_time_for_changes: timedelta
# How often developers abandon a self-service flow midway
action_abandonment_rate: float
# Net Promoter Score from quarterly developer surveys
developer_nps: int
# Real numbers from a client engagement (anonymized)
before_idp = PlatformMetrics(
time_to_first_request=timedelta(days=5),
golden_path_adoption_rate=0.0,
self_service_ratio=0.12,
lead_time_for_changes=timedelta(days=14),
action_abandonment_rate=0.0, # no self-service existed
developer_nps=-23
)
after_idp_6_months = PlatformMetrics(
time_to_first_request=timedelta(minutes=8),
golden_path_adoption_rate=0.73,
self_service_ratio=0.81,
lead_time_for_changes=timedelta(hours=4),
action_abandonment_rate=0.15,
developer_nps=41
)
The before/after numbers from that engagement are striking. Five days to eight minutes for provisioning. But the metric that convinced leadership was the NPS swing, from -23 to +41. Developers went from actively hostile about internal tools to recommending the platform to new hires.
Where It Falls Apart
Not everything is smooth. Here are the common pitfalls:
Backstage plugin quality is wildly inconsistent. Some community plugins are excellent. Others haven't been updated in a year and break on every Backstage upgrade. Budget time for plugin maintenance or build your own.
Port's pricing scales with entities. When you start modeling every Kubernetes pod, every Lambda function, every S3 bucket as an entity, the bill grows fast. Be deliberate about what you model.
The hardest part isn't technical. It's convincing teams to register their services in the catalog and keep metadata up to date. A proven solution is a GitHub Action that fails CI if catalog-info.yaml is missing or has stale ownership info:
# .github/workflows/catalog-lint.yml
name: Catalog Info Lint
on: [pull_request]
jobs:
lint:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- name: Validate catalog-info.yaml
run: |
if [ ! -f catalog-info.yaml ]; then
echo "::error::Missing catalog-info.yaml. See https://portal.acme.internal/docs/catalog-requirements"
exit 1
fi
# Validate owner exists in our org
OWNER=$(yq '.spec.owner' catalog-info.yaml)
if ! curl -sf "https://portal.acme.internal/api/catalog/entities/by-name/group/default/${OWNER}" > /dev/null; then
echo "::error::Owner '${OWNER}' not found in Backstage catalog. Update spec.owner."
exit 1
fi
Recommendation
For teams of under 100 developers wanting results in weeks, Port is the stronger starting point. The blueprint model gets you a working IDP fast, and the self-service actions are production-ready out of the box.
For organizations over 100 developers, or those needing deep customization or with strong opinions about data ownership, Backstage is the better fit. But commitment is essential, a half-built Backstage instance is worse than no developer portal at all.
Either way, start with the service catalog. Get ownership right. Then add golden paths. Then self-service actions. Trying to do all three at once is how platform teams burn out and get dismantled.
This pattern has played out repeatedly across organizations. The teams that succeed treat the platform as a product with real users, real feedback loops, and real roadmaps. The teams that fail treat it as a project with a deadline and a handoff. There is no handoff. The platform is never done.