Building an Internal Developer Portal with Backstage on Azure: A Practical Guide
A practical guide to deploying Spotify Backstage on Azure, covering AKS and Container Apps hosting, catalog setup, software templates, TechDocs, Azure DevOps integration, Entra ID authentication, and custom plugins.
The average enterprise developer spends 60 minutes per day searching for information: which team owns a service, where the API docs live, how to create a new microservice that meets compliance requirements. Multiply that across 200 engineers and you lose 200 hours of engineering productivity every single day.
Backstage, the open-source developer portal created by Spotify, solves this by providing a single pane of glass for your entire software ecosystem. But deploying it on Azure in an enterprise context requires more than running npx @backstage/create-app. This guide covers the architecture decisions, deployment patterns, and integration points that matter in production.
Backstage Architecture Overview
Before deploying anything, you need to understand what Backstage actually is under the hood.
Backstage consists of three layers:
Core — The app shell, plugin system, and built-in features (catalog, scaffolder, TechDocs). This is what you get out of the box.
Plugins — Community and custom plugins that extend functionality. Everything from Kubernetes dashboards to cost management views. Plugins have frontend components (React) and optional backend components (Express).
App — Your specific Backstage instance with configuration, theming, and the set of plugins you have chosen to install.
The backend runs as a Node.js application with a PostgreSQL database. The frontend is a React single-page application served either by the backend or through a CDN. In production, you deploy them together as a single container or split them for independent scaling.
Choosing Your Azure Hosting: AKS vs Container Apps
This is the first decision that shapes everything downstream.
Azure Kubernetes Service (AKS)
Use AKS when:
- You already operate AKS clusters and have a platform team comfortable with Kubernetes
- You need fine-grained control over networking (private clusters, custom DNS, service mesh)
- Your Backstage plugins require persistent volumes or complex sidecar patterns
- You anticipate heavy plugin customization with multiple backend services
# backstage-deployment.yaml
apiVersion: apps/v1
kind: Deployment
metadata:
name: backstage
namespace: backstage
spec:
replicas: 2
selector:
matchLabels:
app: backstage
template:
metadata:
labels:
app: backstage
spec:
serviceAccountName: backstage-sa
containers:
- name: backstage
image: myacr.azurecr.io/backstage:1.24.0
ports:
- containerPort: 7007
envFrom:
- secretRef:
name: backstage-secrets
resources:
requests:
memory: "512Mi"
cpu: "250m"
limits:
memory: "1Gi"
cpu: "1000m"
readinessProbe:
httpGet:
path: /healthcheck
port: 7007
initialDelaySeconds: 30
periodSeconds: 10
livenessProbe:
httpGet:
path: /healthcheck
port: 7007
initialDelaySeconds: 60
periodSeconds: 30
---
apiVersion: v1
kind: Service
metadata:
name: backstage
namespace: backstage
spec:
selector:
app: backstage
ports:
- port: 80
targetPort: 7007
type: ClusterIPPair this with an NGINX Ingress Controller or Azure Application Gateway Ingress Controller for TLS termination and external access.
Azure Container Apps
Use Container Apps when:
- You want minimal operational overhead and your team does not manage Kubernetes
- Your Backstage instance serves fewer than 500 developers
- You need quick setup with built-in TLS, scaling, and revision management
resource backstageApp 'Microsoft.App/containerApps@2023-05-01' = {
name: 'backstage'
location: location
properties: {
managedEnvironmentId: containerAppEnv.id
configuration: {
ingress: {
external: true
targetPort: 7007
transport: 'http'
}
secrets: [
{
name: 'postgres-connection'
value: postgresConnectionString
}
{
name: 'azure-client-secret'
value: entraIdClientSecret
}
]
}
template: {
containers: [
{
name: 'backstage'
image: '${acrName}.azurecr.io/backstage:1.24.0'
resources: {
cpu: json('1.0')
memory: '2Gi'
}
env: [
{ name: 'POSTGRES_HOST', value: postgresHost }
{ name: 'POSTGRES_PORT', value: '5432' }
{ name: 'POSTGRES_USER', value: postgresUser }
{
name: 'POSTGRES_PASSWORD'
secretRef: 'postgres-connection'
}
]
}
]
scale: {
minReplicas: 1
maxReplicas: 3
rules: [
{
name: 'http-scaling'
http: { metadata: { concurrentRequests: '50' } }
}
]
}
}
}
}For both options, use Azure Database for PostgreSQL Flexible Server as the backend database. Enable private endpoint access and disable public network access.
Setting Up the Software Catalog
The catalog is the heart of Backstage. Without a well-maintained catalog, you have an expensive empty shell.
Catalog Structure
Backstage entities follow a standard YAML format. Every repository should contain a catalog-info.yaml at its root:
# catalog-info.yaml
apiVersion: backstage.io/v1alpha1
kind: Component
metadata:
name: payment-service
description: Handles payment processing and transaction management
annotations:
dev.azure.com/project-repo: MyOrg/payment-service
backstage.io/techdocs-ref: dir:.
sonarqube.org/project-key: payment-service
tags:
- dotnet
- payments
- tier-1
links:
- url: https://dev.azure.com/MyOrg/Payments/_dashboards
title: Azure DevOps Dashboard
icon: dashboard
spec:
type: service
lifecycle: production
owner: team-payments
system: payment-platform
providesApis:
- payment-api
consumesApis:
- customer-api
- notification-api
dependsOn:
- resource:payment-database
- resource:payment-servicebusAuto-Discovery with Azure DevOps
Instead of manually registering every repository, configure the Azure DevOps provider to auto-discover catalog-info.yaml files:
# app-config.production.yaml
catalog:
providers:
azureDevOps:
yourOrgName:
organization: https://dev.azure.com/YourOrg
project: '*'
repository: '*'
path: /catalog-info.yaml
schedule:
frequency: { minutes: 30 }
timeout: { minutes: 3 }
rules:
- allow:
- Component
- System
- API
- Resource
- Group
- UserThis scans all projects and repositories every 30 minutes. New services appear in Backstage automatically once they add a catalog-info.yaml.
Software Templates: Scaffolding That Enforces Standards
Software templates are where Backstage pays for itself. Instead of developers copying an old project and stripping out business logic, they fill in a form and get a new repository that meets all organizational standards from day one.
# templates/dotnet-microservice/template.yaml
apiVersion: scaffolder.backstage.io/v1beta3
kind: Template
metadata:
name: dotnet-microservice
title: .NET Microservice
description: Creates a new .NET 9 microservice with CI/CD, SonarQube, and infrastructure
tags:
- dotnet
- microservice
- recommended
spec:
owner: team-platform
type: service
parameters:
- title: Service Information
required: [name, description, owner]
properties:
name:
title: Service Name
type: string
pattern: '^[a-z][a-z0-9-]*$'
ui:help: 'Lowercase with hyphens. Example: payment-processor'
description:
title: Description
type: string
maxLength: 200
owner:
title: Owning Team
type: string
ui:field: OwnerPicker
ui:options:
catalogFilter:
kind: Group
- title: Technical Options
properties:
database:
title: Database
type: string
enum: ['none', 'postgresql', 'cosmos-db']
default: 'none'
messaging:
title: Messaging
type: string
enum: ['none', 'servicebus', 'eventhub']
default: 'none'
steps:
- id: fetch
name: Fetch Skeleton
action: fetch:template
input:
url: ./skeleton
values:
name: ${{ parameters.name }}
description: ${{ parameters.description }}
owner: ${{ parameters.owner }}
database: ${{ parameters.database }}
messaging: ${{ parameters.messaging }}
- id: publish
name: Create Repository
action: publish:azure
input:
allowedHosts: ['dev.azure.com']
repoUrl: >-
dev.azure.com?organization=YourOrg&project=Services&repo=${{ parameters.name }}
description: ${{ parameters.description }}
- id: register
name: Register in Catalog
action: catalog:register
input:
repoContentsUrl: ${{ steps['publish'].output.repoContentsUrl }}
catalogInfoPath: /catalog-info.yaml
output:
links:
- title: Repository
url: ${{ steps['publish'].output.remoteUrl }}
- title: Open in Backstage
icon: catalog
entityRef: ${{ steps['register'].output.entityRef }}The skeleton directory contains a complete project template with Dockerfile, CI/CD pipeline, Terraform modules, and pre-configured SonarQube analysis. Every new service starts correct.
TechDocs Integration
TechDocs turns Markdown files in your repositories into a searchable documentation site inside Backstage. No separate documentation platform needed.
Configuration for Azure Blob Storage
In production, use the "recommended" deployment where TechDocs are built in CI/CD and published to Azure Blob Storage:
# app-config.production.yaml
techdocs:
builder: 'external'
generator:
runIn: 'local'
publisher:
type: 'azureBlobStorage'
azureBlobStorage:
containerName: 'techdocs'
credentials:
accountName: ${TECHDOCS_STORAGE_ACCOUNT}
accountKey: ${TECHDOCS_STORAGE_KEY}CI/CD Pipeline for TechDocs
Add this to every repository's pipeline to publish docs on merge:
# azure-pipelines.yml (TechDocs stage)
- stage: PublishDocs
condition: and(succeeded(), eq(variables['Build.SourceBranch'], 'refs/heads/main'))
jobs:
- job: TechDocs
pool:
vmImage: 'ubuntu-latest'
steps:
- task: UsePythonVersion@0
inputs:
versionSpec: '3.11'
- script: |
pip install mkdocs-techdocs-core
npx @techdocs/cli generate --no-docker
npx @techdocs/cli publish \
--publisher-type azureBlobStorage \
--storage-name $(TECHDOCS_STORAGE_ACCOUNT) \
--azureAccountKey $(TECHDOCS_STORAGE_KEY) \
--entity default/component/$(SERVICE_NAME)
displayName: 'Generate and Publish TechDocs'Authentication with Microsoft Entra ID
Enterprise Backstage must integrate with your identity provider. For Azure environments, that means Entra ID (formerly Azure AD).
# app-config.production.yaml
auth:
environment: production
providers:
microsoft:
production:
clientId: ${AZURE_CLIENT_ID}
clientSecret: ${AZURE_CLIENT_SECRET}
tenantId: ${AZURE_TENANT_ID}
domainHint: yourcompany.com
additionalScopes:
- Mail.ReadConfigure the sign-in page to use Microsoft as the sole provider:
// packages/app/src/App.tsx
import { microsoftAuthApiRef } from '@backstage/core-plugin-api';
const app = createApp({
// ...
components: {
SignInPage: props => (
<SignInPage
{...props}
auto
provider={{
id: 'microsoft',
title: 'Microsoft',
message: 'Sign in with your corporate account',
apiRef: microsoftAuthApiRef,
}}
/>
),
},
});Map Entra ID groups to Backstage teams using the Microsoft Graph org provider:
catalog:
providers:
microsoftGraphOrg:
default:
tenantId: ${AZURE_TENANT_ID}
clientId: ${AZURE_CLIENT_ID}
clientSecret: ${AZURE_CLIENT_SECRET}
groupFilter: startswith(displayName, 'team-')
userFilter: accountEnabled eq true
schedule:
frequency: { hours: 1 }
timeout: { minutes: 5 }Custom Plugins: Extending Backstage for Your Needs
Out-of-the-box plugins cover many use cases, but enterprise environments always have custom requirements. Here is the structure of a simple plugin that displays Azure cost data for each service:
// plugins/azure-costs/src/components/CostOverview.tsx
import React from 'react';
import { useEntity } from '@backstage/plugin-catalog-react';
import { useApi, configApiRef } from '@backstage/core-plugin-api';
import {
InfoCard,
Progress,
ResponseErrorPanel,
} from '@backstage/core-components';
export const CostOverview = () => {
const { entity } = useEntity();
const config = useApi(configApiRef);
const resourceGroup =
entity.metadata.annotations?.['azure.com/resource-group'];
const { data, loading, error } = useCostData(resourceGroup);
if (loading) return <Progress />;
if (error) return <ResponseErrorPanel error={error} />;
return (
<InfoCard title="Azure Costs (Last 30 Days)">
<Typography variant="h4">
{data.currency} {data.totalCost.toFixed(2)}
</Typography>
<CostBreakdownChart data={data.breakdown} />
</InfoCard>
);
};Register it as an entity tab so it appears on every service page that has the azure.com/resource-group annotation.
Adoption Strategy: Making Backstage Stick
Deploying Backstage is the easy part. Getting 200 developers to actually use it is hard.
Phase 1: Seed the catalog (Weeks 1-4) — Use auto-discovery to populate the catalog with every repository. Even without catalog-info.yaml files, import repositories as unowned components. Visibility alone provides value.
Phase 2: Software templates (Weeks 4-8) — Build templates for your two or three most common project types. Every new service must go through Backstage. This creates a forcing function for adoption.
Phase 3: TechDocs migration (Weeks 8-12) — Migrate documentation from Confluence or SharePoint into TechDocs. Engineers search in Backstage first, find answers, and form the habit.
Phase 4: Custom plugins (Weeks 12-20) — Build plugins that surface data engineers already need: deployment status, cost dashboards, security findings. Make Backstage the single pane of glass.
Phase 5: Deprecate alternatives (Weeks 20+) — Retire standalone dashboards, wikis, and portals that Backstage replaces. If the old tools still exist, developers will use them out of habit.
When Backstage Is Overkill
Backstage is not always the right answer.
Small organizations (fewer than 50 engineers) — The overhead of maintaining a Backstage instance, writing plugins, and keeping the catalog current exceeds the navigation cost it eliminates. A well-organized wiki and a CI/CD dashboard cover most needs.
Single-product teams — If everyone works on the same product with the same tech stack, the "discovery" problem Backstage solves does not exist. You already know what is there.
No platform team — Backstage requires ongoing maintenance. Plugin updates, Backstage version upgrades, catalog curation. Without a dedicated platform team (even 1-2 engineers), Backstage becomes stale and abandoned.
Compliance-heavy environments with no customization budget — Backstage out of the box does not meet most enterprise compliance requirements. Custom auth, audit logging, and access control plugins require development effort.
Production Checklist
Before going live, verify:
- PostgreSQL uses private endpoints with TLS enforced
- Backstage runs behind Azure Application Gateway or Front Door with WAF
- Entra ID authentication is the only sign-in method
- Container images are scanned and pulled from a private ACR
- TechDocs storage uses managed identity for access (not storage keys)
- Catalog auto-discovery runs on schedule and catches new repositories
- At least three software templates are ready for developer use
- Monitoring is configured (Application Insights for the Node.js backend)
- Backup strategy for the PostgreSQL database is tested
Conclusion
Backstage transforms developer experience when it is deployed thoughtfully and maintained actively. On Azure, the combination of AKS or Container Apps, PostgreSQL Flexible Server, Blob Storage for TechDocs, and Entra ID for authentication provides a solid foundation. The real work is in catalog curation, template creation, and driving adoption.
Start with the catalog. Make discovery easy. Then layer on templates, TechDocs, and custom plugins as adoption grows.
If you are evaluating Backstage for your organization or need help deploying it on Azure with enterprise-grade security and governance, reach out at mbrahim@conceptualise.de. We help enterprise teams build internal developer platforms that developers actually use.
Topics