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.