Platform Engineering in 2026: Building Internal Developer Platforms with Backstage and Crossplane [Strategy]
As Sujay Singh, a senior technology writer at TechNews Venture, I’ve tracked the evolution of software development methodologies for over a decade. The industry's relentless pursuit of efficiency, scalability, and developer satisfaction has brought us to a pivotal point: the widespread adoption of Platform Engineering. By 2026, the concept of an Internal Developer Platform (IDP) will no longer be a niche aspiration but a foundational component for high-performing technology organizations. This article will delve into a strategic approach for building a robust IDP using two leading open-source projects: Backstage for the developer experience and Crossplane for infrastructure orchestration.
Overview: The Rise of Internal Developer Platforms
The promise of DevOps was to bridge the chasm between development and operations. While it brought significant cultural shifts and automation, it often pushed operational complexities onto developers, leading to cognitive overload and slower innovation cycles. Platform Engineering addresses this by providing "golden paths"—curated, opinionated, and automated frameworks that abstract away underlying infrastructure complexities, allowing developers to focus purely on application logic.
An Internal Developer Platform (IDP) is the tangible embodiment of Platform Engineering. It's a self-service portal that offers developers everything they need to build, deploy, and operate their applications, from spinning up new microservices and provisioning databases to accessing logs and metrics. By 2026, a mature IDP will be characterized by:
- Self-Service Empowerment: Developers provision resources and deploy applications without direct ops intervention.
- Standardization: Enforced best practices for security, reliability, and cost-efficiency.
- Abstraction: Hiding the underlying complexity of cloud providers and Kubernetes.
- Observability: Integrated tooling for monitoring, logging, and tracing.
- Developer Experience (DX): A delightful, intuitive interface that enhances productivity.
In this strategic blueprint, Backstage, Spotify's open-source developer portal, serves as the primary interface for developers. It provides a unified catalog of services, software templates, documentation, and operational insights. Complementing Backstage, Crossplane acts as the universal control plane, extending Kubernetes to manage and provision external cloud infrastructure (databases, message queues, object storage, etc.) declaratively. This powerful combination delivers a seamless, GitOps-driven IDP where developers interact with Backstage, and Backstage orchestrates infrastructure via Crossplane.
Prerequisites for Implementation
Before embarking on building your IDP, ensure the following foundational components and knowledge are in place:
- Kubernetes Cluster: A running Kubernetes cluster (e.g., AWS EKS, GCP GKE, Azure AKS) is essential. Both Backstage and Crossplane will be deployed here.
- kubectl: The Kubernetes command-line tool, configured to connect to your cluster.
- Helm: The Kubernetes package manager, used for deploying Crossplane and potentially Backstage.
- Node.js and Yarn: Required for developing and running Backstage locally.
- Git Repository: A version control system (GitHub, GitLab, Bitbucket) for storing Backstage configurations, Crossplane Compositions, and software templates.
- Cloud Provider Account: An AWS, Azure, or GCP account with programmatic access (API keys or IAM roles) for Crossplane to manage resources. For this article, we'll primarily use AWS examples.
- Basic Understanding: Familiarity with Kubernetes concepts, YAML syntax, GitOps principles, and cloud infrastructure.
Detailed Steps: Building the IDP Core
Step 1: Setting up Crossplane as the Infrastructure Control Plane
Crossplane extends your Kubernetes API to allow you to provision and manage cloud infrastructure using kubectl. This is the engine that drives your infrastructure requests from Backstage.
1.1 Install Crossplane into your Kubernetes Cluster
First, add the Crossplane Helm repository and install it:
helm repo add crossplane-stable https://charts.crossplane.io/stable
helm repo update
helm install crossplane crossplane-stable/crossplane --namespace crossplane-system --create-namespace --wait
1.2 Install a Cloud Provider (e.g., AWS Provider)
Next, install the Crossplane AWS Provider. This allows Crossplane to interact with AWS APIs.
kubectl crossplane install provider crossplane/provider-aws:v0.40.0
kubectl get providers
Wait until the provider status shows `HEALTHY`.
1.3 Configure AWS Credentials for Crossplane
Crossplane needs credentials to interact with your AWS account. The most secure way is to use Kubernetes Service Accounts with IAM Roles for Service Accounts (IRSA) on EKS, or similar mechanisms on GKE/AKS. For demonstration, we'll use a secret.
Create an AWS IAM user with programmatic access and sufficient permissions (e.g., `AdministratorAccess` for a demo, but restrict strictly in production). Store the credentials as a Kubernetes Secret:
# Replace YOUR_AWS_ACCESS_KEY_ID and YOUR_AWS_SECRET_ACCESS_KEY
cat <
Then, create a `ProviderConfig` to reference these credentials:
cat <
1.4 Define a Composite Resource (XR) and Composition for an RDS Database
This is where Crossplane shines. We define an abstract `XPostgreSQLInstance` that developers can request, and a `Composition` that maps this abstract request to concrete AWS RDS resources.
# xrds.yaml - Composite Resource Definition (XRD)
apiVersion: apiextensions.crossplane.io/v1
kind: CompositeResourceDefinition
metadata:
name: xpostgresqlinstances.platform.example.org
spec:
group: platform.example.org
names:
kind: XPostgreSQLInstance
plural: xpostgresqlinstances
claimNames:
kind: PostgreSQLInstance
plural: postgresqlinstances
versions:
- name: v1alpha1
served: true
referenceable: true
schema:
openAPIV3Schema:
type: object
properties:
spec:
type: object
properties:
parameters:
type: object
properties:
storageGB:
type: integer
description: "Storage in GB for the PostgreSQL instance."
engineVersion:
type: string
description: "PostgreSQL engine version."
instanceType:
type: string
description: "AWS RDS instance type."
required: ["storageGB", "engineVersion", "instanceType"]
required: ["parameters"]
status:
type: object
properties:
rdsEndpoint:
type: string
rdsPort:
type: string
---
# composition.yaml - Composition for XPostgreSQLInstance
apiVersion: apiextensions.crossplane.io/v1
kind: Composition
metadata:
name: xpostgresqlinstances.platform.example.org
labels:
provider: aws
db: postgresql
spec:
compositeTypeRef:
apiVersion: platform.example.org/v1alpha1
kind: XPostgreSQLInstance
resources:
- name: rdsinstance
base:
apiVersion: rds.aws.crossplane.io/v1beta1
kind: DBInstance
spec:
forProvider:
region: us-east-1
dbSubnetGroupNameRef:
name: default-vpc-dbsubnetgroup # Ensure this exists or create one
masterUsername: masteruser
engine: postgres
engineVersion: "13.7"
skipFinalSnapshotBeforeDeletion: true
publiclyAccessible: false
vpcSecurityGroupIDs:
- sg-0a1b2c3d4e5f6g7h8 # Replace with an actual security group ID
writeConnectionSecretToRef:
namespace: crossplane-system
name: rds-conn-secret
patches:
- fromFieldPath: spec.parameters.storageGB
toFieldPath: spec.forProvider.allocatedStorage
- fromFieldPath: spec.parameters.engineVersion
toFieldPath: spec.forProvider.engineVersion
- fromFieldPath: spec.parameters.instanceType
toFieldPath: spec.forProvider.dbInstanceClass
- fromFieldPath: status.atProvider.endpoint.address
toFieldPath: status.rdsEndpoint
- fromFieldPath: status.atProvider.endpoint.port
toFieldPath: status.rdsPort
kubectl apply -f xrds.yaml
kubectl apply -f composition.yaml
Now, a developer can request a `PostgreSQLInstance` without knowing the underlying AWS RDS complexities.
Step 2: Deploying Backstage as the Developer Portal
Backstage will be the primary interface where developers interact with the platform. It provides the service catalog, software templates, and potentially operational dashboards.
2.1 Initialize a Backstage Application
On your local machine, create a new Backstage app. This sets up the basic structure.
npx @backstage/cli create-app --path my-idp-backstage
cd my-idp-backstage
yarn install
2.2 Configure Backstage for Database and Authentication
Edit `app-config.yaml` to point to a PostgreSQL database (recommended for production) and configure an authentication provider (e.g., GitHub OAuth).
Database Configuration Example (PostgreSQL):
# app-config.yaml
backend:
database:
client: pg
connection:
host: ${POSTGRES_HOST}
port: ${POSTGRES_PORT}
user: ${POSTGRES_USER}
password: ${POSTGRES_PASSWORD}
database: ${POSTGRES_DATABASE}
These environment variables would be supplied to the Backstage deployment in Kubernetes.
GitHub Auth Provider Example:
# app-config.yaml
auth:
environment: development
providers:
github:
development:
clientId: ${AUTH_GITHUB_CLIENT_ID}
clientSecret: ${AUTH_GITHUB_CLIENT_SECRET}
You'll need to register a new OAuth application in GitHub and get the client ID and secret.
2.3 Dockerize and Deploy Backstage to Kubernetes
Build a Docker image for your Backstage app:
# In your my-idp-backstage directory
yarn build:backend
docker build -t my-backstage-app:latest .
docker push my-backstage-app:latest # Push to your container registry
Deploy Backstage to Kubernetes using Helm (or Kustomize). A simplified example using a generic Helm chart:
# backstage-values.yaml
image:
repository: YOUR_CONTAINER_REGISTRY/my-backstage-app
tag: latest
env:
- name: POSTGRES_HOST
value: "my-postgres-service.default.svc.cluster.local" # Or external RDS endpoint
- name: POSTGRES_PORT
value: "5432"
- name: POSTGRES_USER
valueFrom:
secretKeyRef:
name: backstage-db-creds
key: user
- name: POSTGRES_PASSWORD
valueFrom:
secretKeyRef:
name: backstage-db-creds
key: password
- name: POSTGRES_DATABASE
value: "backstage_db"
- name: AUTH_GITHUB_CLIENT_ID
valueFrom:
secretKeyRef:
name: github-oauth-creds
key: client_id
- name: AUTH_GITHUB_CLIENT_SECRET
valueFrom:
secretKeyRef:
name: github-oauth-creds
key: client_secret
ingress:
enabled: true
className: nginx
host: backstage.your-domain.com
# Assuming you have a generic Backstage Helm chart or create your own
helm install backstage ./charts/backstage-app -f backstage-values.yaml --namespace backstage-system --create-namespace
Step 3: Integrating Backstage Scaffolder with Crossplane
This is the core of the IDP: enabling developers to provision infrastructure through Backstage templates, which then trigger Crossplane.
3.1 Create a Custom Scaffolder Action for Crossplane
Backstage Scaffolder actions are JavaScript/TypeScript functions that perform tasks. We'll create an action that generates a Crossplane XR (e.g., `PostgreSQLInstance`) and pushes it to a Git repository. A GitOps tool (like Argo CD or Flux CD) will then pick up this YAML and apply it to Kubernetes, triggering Crossplane.
Add a custom action to your Backstage backend:
In `packages/backend/src/plugins/scaffolder.ts`, add a custom action. First, install `simple-git`:
cd packages/backend
yarn add simple-git
Then, modify `scaffolder.ts`:
// packages/backend/src/plugins/scaffolder.ts
import { createTemplateAction } from '@backstage/plugin-scaffolder-backend';
import { SimpleGit, simpleGit } from 'simple-git';
import * as yaml from 'js-yaml'; // yarn add js-yaml
export function createCrossplanePostgresAction() {
return createTemplateAction<{
resourceName: string;
gitRepoUrl: string;
gitBranch: string;
storageGB: number;
engineVersion: string;
instanceType: string;
}>({
id: 'platform:crossplane-postgresql',
schema: {
input: {
required: ['resourceName', 'gitRepoUrl', 'gitBranch', 'storageGB', 'engineVersion', 'instanceType'],
type: 'object',
properties: {
resourceName: {
type: 'string',
title: 'Resource Name',
description: 'Name for the PostgreSQL instance.',
},
gitRepoUrl: {
type: 'string',
title: 'Git Repository URL',
description: 'URL of the GitOps repository for Crossplane XRs.',
},
gitBranch: {
type: 'string',
title: 'Git Branch',
description: 'Branch to push the XR YAML to (e.g., main).',
default: 'main',
},
storageGB: {
type: 'number',
title: 'Storage (GB)',
description: 'Allocated storage for the database.',
},
engineVersion: {
type: 'string',
title: 'Engine Version',
description: 'PostgreSQL engine version (e.g., 13.7).',
},
instanceType: {
type: 'string',
title: 'Instance Type',
description: 'AWS RDS instance type (e.g., db.t3.micro).',
},
},
},
},
async handler(ctx) {
const { resourceName, gitRepoUrl, gitBranch, storageGB, engineVersion, instanceType } = ctx.input;
const logger = ctx.logger;
logger.info(`Generating Crossplane PostgreSQL XR for ${resourceName}`);
const xrYaml = yaml.dump({
apiVersion: 'platform.example.org/v1alpha1',
kind: 'PostgreSQLInstance',
metadata: {
name: resourceName,
labels: {
'backstage.io/template-id': ctx.templateInfo?.entityRef,
'backstage.io/scaffolder-task-id': ctx.taskId,
},
},
spec: {
parameters: {
storageGB,
engineVersion,
instanceType,
},
},
});
const xrFilePath = `infrastructure/postgres/${resourceName}.yaml`;
logger.info(`Writing XR to temporary file: ${xrFilePath}`);
await ctx.create>.fs.writeFile(xrFilePath, xrYaml);
logger.info(`Cloning Git repository: ${gitRepoUrl}`);
const git: SimpleGit = simpleGit();
const repoDir = await ctx.createTemporaryDirectory();
await git.clone(gitRepoUrl, repoDir);
await git.cwd(repoDir);
await git.checkout(gitBranch);
const targetFilePath = `${repoDir}/${xrFilePath}`;
logger.info(`Copying generated XR to Git repository: ${targetFilePath}`);
await ctx.fs.copy(ctx.fs.path(xrFilePath), targetFilePath);
await git.add(targetFilePath);
await git.commit(`feat(${resourceName}): Create new PostgreSQL instance via Backstage Scaffolder`);
await git.push('origin', gitBranch);
logger.info(`Successfully pushed Crossplane XR to Git repository: ${gitRepoUrl} on branch ${gitBranch}`);
ctx.output('xrName', resourceName);
},
});
}
Then, register this action in `packages/backend/src/index.ts`:
// packages/backend/src/index.ts
// ... other imports
import { createCrossplanePostgresAction } from './plugins/scaffolder';
async function main() {
// ...
const scaffolderEnv = use
.at('/scaffolder')
.apply(
({ services }) =>
new ScaffolderService(
{
logger: services.logger,
database: services.database,
reader: services.urlReader,
config: services.config,
catalogClient: services.catalog,
identity: services.identity,
permissions: services.permissions,
auth: services.auth,
// Add custom actions here
actions: [
...defaultActions,
createCrossplanePostgresAction(),
],
},
),
);
// ...
}
Note on Git Authentication: For the `simple-git` action to push, the Backstage backend needs Git credentials configured, typically via SSH keys or HTTPS token environment variables. In a production Kubernetes deployment, this would involve mounting secrets containing these credentials.
3.2 Create a Backstage Software Template
Now, define a `template.yaml` that uses this custom action. This template will reside in a Git repository that Backstage monitors.
# templates/new-service-with-rds/template.yaml
apiVersion: scaffolder.backstage.io/v1beta3
kind: Template
metadata:
name: new-service-with-rds
title: New Service with PostgreSQL RDS
description: Creates a new microservice skeleton and provisions an AWS RDS PostgreSQL instance.
tags:
- go
- microservice
- postgresql
spec:
owner: platform-team
type: service
parameters:
- title: Service Information
properties:
serviceName:
title: Service Name
type: string
description: Unique name for your new service.
ui:autofocus: true
ui:options:
rows: 5
description:
title: Description
type: string
description: A brief description of your new service.
owner:
title: Owner
type: string
description: Who is the owner of this service?
ui:field: OwnerPicker
ui:options:
allowedKinds: [ "Group" ]
- title: Database Configuration
properties:
dbName:
title: Database Name
type: string
description: Name for the PostgreSQL database.
pattern: "^[a-z0-9](?:[a-z0-9]|-(?=[a-z0-9]))*$"
maxLength: 63
dbStorageGB:
title: Database Storage (GB)
type: number
default: 20
minimum: 10
maximum: 100
dbEngineVersion:
title: Database Engine Version
type: string
enum: ["13.7", "14.6", "15.2"]
default: "13.7"
dbInstanceType:
title: Database Instance Type
type: string
enum: ["db.t3.micro", "db.t3.small", "db.m5.large"]
default: "db.t3.micro"
- title: GitOps Repository
properties:
gitOpsRepoUrl:
title: GitOps Repository URL
type: string
description: The URL of your GitOps repository (e.g., for Crossplane XRs).
default: "https://github.com/your-org/platform-gitops.git" # Replace with your repo
gitOpsBranch:
title: GitOps Branch
type: string
description: The branch to push Crossplane configurations to.
default: "main"
steps:
- id: fetch-base
name: Fetch Base Skeleton
action: fetch:template
input:
url: ./skeleton # A subdirectory with basic service code (e.g., Go/Python microservice)
copyWithoutRender:
- .github/workflows/*
- id: generate-service
name: Generate Service
action: customize:template
input:
files:
- from: './catalog-info.yaml.hbs'
to: './catalog-info.yaml'
- from: './README.md.hbs'
to: './README.md'
- id: create-db-xr
name: Create Crossplane PostgreSQL Instance
action: platform:crossplane-postgresql
input:
resourceName: "{{ parameters.dbName }}"
gitRepoUrl: "{{ parameters.gitOpsRepoUrl }}"
gitBranch: "{{ parameters.gitOpsBranch }}"
storageGB: "{{ parameters.dbStorageGB }}"
engineVersion: "{{ parameters.dbEngineVersion }}"
instanceType: "{{ parameters.dbInstanceType }}"
- id: publish
name: Publish to Git
action: publish:github
input:
repoUrl: "github.com?owner=your-org&repo={{ parameters.serviceName }}" # Assuming a new repo per service
defaultBranch: main
repoVisibility: public # or private
- id: register
name: Register in Catalog
action: catalog:register
input:
repoContentsUrl: "https://github.com/your-org/{{ parameters.serviceName }}"
catalogInfoPath: "/catalog-info.yaml"
Once this template is registered in Backstage (via `app-config.yaml` or a catalog file), developers can navigate to the "Create" tab, select "New Service with PostgreSQL RDS," fill out the form, and Backstage will:
- Scaffold the basic service code.
- Generate the `PostgreSQLInstance` XR YAML.
- Push the XR YAML to the specified GitOps repository.
- (Via GitOps controller) Apply the XR to Kubernetes.
- Crossplane provisions the AWS RDS instance.
- Register the new service in the Backstage catalog.
This creates a powerful, self-service experience, abstracting complex cloud provisioning behind a simple form.
Security Considerations for your IDP
Security is paramount in an IDP, given its central role in both application development and infrastructure management. A compromise here could have wide-ranging implications.
Backstage Security
- Authentication & Authorization:
- Integrate with enterprise SSO (e.g., Okta, Azure AD, GitHub OAuth) for robust authentication.
- Implement Backstage's RBAC (Role-Based Access Control) plugin to control who can view, edit, or create resources and templates. Ensure least privilege.
- Secrets Management:
- Never hardcode secrets in `app-config.yaml`. Use environment variables or a secrets manager like HashiCorp Vault, AWS Secrets Manager, or Kubernetes Secrets (encrypted at rest).
- Supply Chain Security:
- Regularly scan Backstage dependencies for CVEs using tools like Snyk, Trivy, or Dependabot.
- Ensure Backstage plugins are from trusted sources or thoroughly vetted if custom-developed.
- Network Security:
- Deploy Backstage behind an ingress controller with TLS termination.
- Implement network policies in Kubernetes to restrict communication between Backstage pods and other services.
Crossplane Security
- IAM Roles for Service Accounts (IRSA):
- For cloud providers, use IRSA (AWS EKS), Workload Identity (GCP GKE), or Azure AD Workload Identity (Azure AKS) to grant Crossplane providers least-privilege access to cloud resources. Avoid long-lived access keys in secrets where possible.
- Example for AWS ProviderConfig with IRSA:
apiVersion: aws.crossplane.io/v1beta1 kind: ProviderConfig metadata: name: default spec: credentials: source: IRSA fsGroup: 2000 # Recommended for EKS IAM roles for service accounts # serviceAccountSelector: # matchLabels: # aws-account-id: "123456789012"
- Policy Enforcement:
- Use admission controllers like OPA Gatekeeper or Kyverno to enforce policies on Crossplane resources (XRs and managed resources). For example, prevent creation of publicly accessible databases or enforce specific instance types.
- Example Gatekeeper Constraint for RDS:
apiVersion: constraints.gatekeeper.sh/v1