Building an Internal Developer Platform (IDP) is much more than installing Backstage or creating a collection of Terraform modules. A useful platform gives developers a complete path from “I need a new service” to “my service is running securely in production.”
In this tutorial, we will build a reference Internal Developer Platform that combines:
- Backstage for the developer portal
- Kubernetes for application workloads
- Terraform for infrastructure provisioning
- AWS for cloud infrastructure
- GitHub for source control
- GitHub Actions for CI/CD
- Argo CD-style GitOps concepts for deployment
- Docker for application packaging
- PostgreSQL as an example managed dependency
- Prometheus-compatible metrics and health checks
- Kubernetes-native secrets as a simple baseline
- Multiple application languages
- Golden-path templates
- Platform APIs and CLI concepts
The objective is to create a repository structure that could serve as the foundation for a real internal platform.
The Architecture We Are Building
Our finished platform will look conceptually like this:
+----------------------+
| Developers |
+----------+-----------+
|
+------------------+------------------+
| | |
v v v
Backstage Portal CLI/API Git Push
| | |
+------------------+------------------+
|
v
+----------------------+
| Platform Control |
| Templates / Policies |
+----------+-----------+
|
+-------------------+-------------------+
| | |
v v v
Git Repository Terraform Deployment
| |
v v
AWS Kubernetes
| |
+---------+---------+
|
v
Running Applications
|
+--------------------+------------------+
| | |
v v v
Metrics Logs Traces
The platform separates developer experience from infrastructure implementation.
A developer should be able to select:
Create Service
and provide:
Name: payments-api
Language: Go
Database: PostgreSQL
Environment: development
The platform then generates the application repository, CI/CD configuration, Kubernetes resources, Backstage metadata, and infrastructure definitions.
Repository Structure
We will use a monorepo for the platform itself.
internal-developer-platform/
│
├── README.md
│
├── backstage/
│ ├── app-config.yaml
│ ├── app-config.production.yaml
│ ├── catalog-info.yaml
│ ├── package.json
│ ├── packages/
│ │ ├── app/
│ │ └── backend/
│ └── plugins/
│
├── terraform/
│ ├── environments/
│ │ ├── dev/
│ │ │ ├── main.tf
│ │ │ ├── variables.tf
│ │ │ └── outputs.tf
│ │ └── production/
│ │ ├── main.tf
│ │ ├── variables.tf
│ │ └── outputs.tf
│ │
│ └── modules/
│ ├── network/
│ ├── eks/
│ ├── database/
│ ├── ecr/
│ └── iam/
│
├── kubernetes/
│ ├── base/
│ │ ├── namespace.yaml
│ │ ├── deployment.yaml
│ │ ├── service.yaml
│ │ ├── ingress.yaml
│ │ └── serviceaccount.yaml
│ │
│ └── overlays/
│ ├── development/
│ └── production/
│
├── templates/
│ ├── go-service/
│ ├── python-service/
│ ├── node-service/
│ ├── java-service/
│ └── dotnet-service/
│
├── services/
│ ├── example-go/
│ ├── example-python/
│ ├── example-node/
│ ├── example-java/
│ └── example-dotnet/
│
├── .github/
│ └── workflows/
│ ├── platform-ci.yml
│ └── terraform.yml
│
└── docs/
├── getting-started.md
├── architecture.md
├── development.md
└── operations.md
This separation is deliberate.
Backstage is responsible for the developer experience.
Terraform is responsible for infrastructure.
Kubernetes manifests describe application runtime state.
Application repositories contain application-specific code.
CI/CD automates delivery.
Prerequisites
For a local development environment, install:
git
docker
kubectl
terraform
helm
node
npm
python
go
java
dotnet
For the cloud environment, you also need:
aws
and credentials with appropriate permissions.
Verify the major tools:
terraform version
kubectl version --client
docker version
node --version
python --version
go version
java --version
dotnet --version
You do not need every programming language installed merely to operate the platform.
They are included because our platform will demonstrate that the same developer experience can support different application stacks.
Create the Platform Repository
Create the project:
mkdir internal-developer-platform
cd internal-developer-platform
git init
Create the directories:
mkdir -p \
backstage \
terraform/modules \
terraform/environments/dev \
terraform/environments/production \
kubernetes/base \
kubernetes/overlays/development \
kubernetes/overlays/production \
templates \
services \
docs \
.github/workflows
Your initial repository is now:
internal-developer-platform/
├── backstage/
├── terraform/
├── kubernetes/
├── templates/
├── services/
├── docs/
└── .github/
Build the AWS Network With Terraform
Start with networking.
Create:
terraform/modules/network/main.tf
Use:
variable "name" {
type = string
}
variable "vpc_cidr" {
type = string
default = "10.0.0.0/16"
}
resource "aws_vpc" "this" {
cidr_block = var.vpc_cidr
enable_dns_hostnames = true
enable_dns_support = true
tags = {
Name = var.name
}
}
resource "aws_subnet" "private" {
count = 2
vpc_id = aws_vpc.this.id
cidr_block = cidrsubnet(var.vpc_cidr, 4, count.index)
availability_zone = data.aws_availability_zones.available.names[count.index]
tags = {
Name = "${var.name}-private-${count.index}"
}
}
resource "aws_subnet" "public" {
count = 2
vpc_id = aws_vpc.this.id
cidr_block = cidrsubnet(var.vpc_cidr, 4, count.index + 8)
availability_zone = data.aws_availability_zones.available.names[count.index]
tags = {
Name = "${var.name}-public-${count.index}"
}
}
data "aws_availability_zones" "available" {
state = "available"
}
output "vpc_id" {
value = aws_vpc.this.id
}
output "private_subnet_ids" {
value = aws_subnet.private[*].id
}
output "public_subnet_ids" {
value = aws_subnet.public[*].id
}
The network module deliberately has a simple interface.
The developer does not need to understand subnet calculations.
They consume:
module "network" {
source = "../../modules/network"
name = "platform-dev"
}
This is one of the core ideas behind platform engineering:
Complex implementation, simple interface.
Create the Terraform Environment
Create:
terraform/environments/dev/main.tf
Add:
terraform {
required_version = ">= 1.8.0"
required_providers {
aws = {
source = "hashicorp/aws"
version = "~> 6.0"
}
}
}
provider "aws" {
region = var.aws_region
}
module "network" {
source = "../../modules/network"
name = "platform-dev"
vpc_cidr = "10.20.0.0/16"
}
output "vpc_id" {
value = module.network.vpc_id
}
Create:
variables.tf
with:
variable "aws_region" {
type = string
default = "eu-central-1"
}
Initialize Terraform:
cd terraform/environments/dev
terraform init
Validate:
terraform validate
Then:
terraform plan
At this stage, Terraform can describe the infrastructure without yet provisioning Kubernetes.
Create an EKS Cluster
The next platform capability is Kubernetes.
Create:
terraform/modules/eks/main.tf
A production-grade EKS implementation will normally use AWS IAM, networking, node groups, security groups, and cluster add-ons.
The module interface should hide most of that complexity:
variable "cluster_name" {
type = string
}
variable "kubernetes_version" {
type = string
default = "1.33"
}
variable "subnet_ids" {
type = list(string)
}
variable "vpc_id" {
type = string
}
The module can then create the cluster and node groups.
The important platform design principle is that application teams should consume:
module "platform_cluster" {
source = "../../modules/eks"
cluster_name = "platform-dev"
kubernetes_version = "1.33"
subnet_ids = module.network.private_subnet_ids
vpc_id = module.network.vpc_id
}
rather than maintaining their own EKS implementation.
Create an ECR Module
Applications need a container registry.
Create:
terraform/modules/ecr/main.tf
variable "repository_name" {
type = string
}
resource "aws_ecr_repository" "this" {
name = var.repository_name
image_tag_mutability = "IMMUTABLE"
image_scanning_configuration {
scan_on_push = true
}
encryption_configuration {
encryption_type = "AES256"
}
}
output "repository_url" {
value = aws_ecr_repository.this.repository_url
}
Now the platform can expose:
module "payments_repository" {
source = "../../modules/ecr"
repository_name = "payments-api"
}
The developer receives a registry without needing to manually configure:
- repository policies
- image scanning
- encryption
- naming
- lifecycle settings
Add PostgreSQL as a Platform Capability
Create:
terraform/modules/database/main.tf
variable "identifier" {
type = string
}
variable "username" {
type = string
}
variable "password" {
type = string
sensitive = true
}
variable "subnet_ids" {
type = list(string)
}
resource "aws_db_subnet_group" "this" {
name = "${var.identifier}-subnet-group"
subnet_ids = var.subnet_ids
}
resource "aws_db_instance" "this" {
identifier = var.identifier
engine = "postgres"
instance_class = "db.t4g.micro"
allocated_storage = 20
username = var.username
password = var.password
db_subnet_group_name = aws_db_subnet_group.this.name
publicly_accessible = false
skip_final_snapshot = true
storage_encrypted = true
}
output "endpoint" {
value = aws_db_instance.this.address
}
A production implementation should use a proper secret-management mechanism instead of passing credentials directly through Terraform variables.
The example illustrates the platform abstraction.
Build the Kubernetes Application Base
Create:
kubernetes/base/deployment.yaml
apiVersion: apps/v1
kind: Deployment
metadata:
name: application
spec:
replicas: 2
selector:
matchLabels:
app: application
template:
metadata:
labels:
app: application
spec:
containers:
- name: application
image: APPLICATION_IMAGE
ports:
- containerPort: 8080
resources:
requests:
cpu: 100m
memory: 128Mi
limits:
cpu: 500m
memory: 512Mi
securityContext:
runAsNonRoot: true
allowPrivilegeEscalation: false
readOnlyRootFilesystem: true
readinessProbe:
httpGet:
path: /ready
port: 8080
livenessProbe:
httpGet:
path: /health
port: 8080
Create:
service.yaml
apiVersion: v1
kind: Service
metadata:
name: application
spec:
selector:
app: application
ports:
- port: 80
targetPort: 8080
Create:
namespace.yaml
apiVersion: v1
kind: Namespace
metadata:
name: applications
The platform now has a reusable runtime template.
Use Kustomize for Environments
Create:
kubernetes/overlays/development/kustomization.yaml
apiVersion: kustomize.config.k8s.io/v1beta1
kind: Kustomization
namespace: applications
resources:
- ../../base
images:
- name: APPLICATION_IMAGE
newName: example.registry/application
newTag: development
Production can use:
kubernetes/overlays/production/kustomization.yaml
apiVersion: kustomize.config.k8s.io/v1beta1
kind: Kustomization
namespace: applications
resources:
- ../../base
replicas:
- name: application
count: 3
images:
- name: APPLICATION_IMAGE
newName: example.registry/application
newTag: production
This provides environment-specific configuration without duplicating the entire application manifest.
Create the Go Golden Path
The first application template will be Go.
Create:
templates/go-service/
with:
go-service/
├── main.go
├── go.mod
├── Dockerfile
├── deployment.yaml
├── catalog-info.yaml
└── README.md
The application:
package main
import (
"encoding/json"
"net/http"
)
func health(w http.ResponseWriter, r *http.Request) {
w.Header().Set("Content-Type", "application/json")
json.NewEncoder(w).Encode(map[string]string{
"status": "ok",
})
}
func ready(w http.ResponseWriter, r *http.Request) {
w.Header().Set("Content-Type", "application/json")
json.NewEncoder(w).Encode(map[string]string{
"status": "ready",
})
}
func main() {
http.HandleFunc("/health", health)
http.HandleFunc("/ready", ready)
http.ListenAndServe(":8080", nil)
}
The Dockerfile:
FROM golang:1.24 AS builder
WORKDIR /src
COPY go.mod .
COPY main.go .
RUN CGO_ENABLED=0 GOOS=linux go build -o application main.go
FROM alpine:3.22
RUN adduser -D appuser
USER appuser
COPY --from=builder /src/application /application
EXPOSE 8080
ENTRYPOINT ["/application"]
The golden path creates a production-oriented application without forcing the developer to manually build the entire structure.
Create the Python Golden Path
Create:
templates/python-service/
├── app.py
├── requirements.txt
├── Dockerfile
├── catalog-info.yaml
└── README.md
Application:
from fastapi import FastAPI
app = FastAPI()
@app.get("/health")
def health():
return {"status": "ok"}
@app.get("/ready")
def ready():
return {"status": "ready"}
Requirements:
fastapi
uvicorn
Dockerfile:
FROM python:3.13-slim
WORKDIR /app
RUN useradd --create-home appuser
COPY requirements.txt .
RUN pip install --no-cache-dir -r requirements.txt
COPY app.py .
USER appuser
EXPOSE 8080
CMD ["uvicorn", "app:app", "--host", "0.0.0.0", "--port", "8080"]
The platform treats Python exactly like Go from an operational perspective.
Both applications expose:
/health
/ready
Both are containerized.
Both have CI/CD.
Both run on Kubernetes.
Both appear in Backstage.
Create the Node.js and TypeScript Golden Path
Create:
templates/node-service/
├── package.json
├── src/
│ └── index.ts
├── tsconfig.json
├── Dockerfile
└── catalog-info.yaml
package.json:
{
"name": "node-service",
"version": "1.0.0",
"scripts": {
"build": "tsc",
"start": "node dist/index.js",
"test": "node --test"
},
"dependencies": {
"express": "^5.1.0"
},
"devDependencies": {
"@types/express": "^5.0.0",
"typescript": "^5.0.0"
}
}
src/index.ts:
import express from "express";
const app = express();
app.get("/health", (_req, res) => {
res.json({ status: "ok" });
});
app.get("/ready", (_req, res) => {
res.json({ status: "ready" });
});
app.listen(8080, () => {
console.log("Server listening on port 8080");
});
Dockerfile:
FROM node:22-alpine AS builder
WORKDIR /app
COPY package*.json ./
RUN npm ci
COPY . .
RUN npm run build
FROM node:22-alpine
WORKDIR /app
COPY package*.json ./
RUN npm ci --omit=dev
COPY --from=builder /app/dist ./dist
USER node
EXPOSE 8080
CMD ["npm", "start"]
Create the Java Golden Path
A Spring Boot service can follow the same platform contract.
@RestController
public class HealthController {
@GetMapping("/health")
public Map<String, String> health() {
return Map.of("status", "ok");
}
@GetMapping("/ready")
public Map<String, String> ready() {
return Map.of("status", "ready");
}
}
The application language changes.
The platform contract does not.
That distinction is critical.
Create the .NET Golden Path
For ASP.NET Core:
var builder = WebApplication.CreateBuilder(args);
var app = builder.Build();
app.MapGet("/health", () =>
Results.Ok(new { status = "ok" }));
app.MapGet("/ready", () =>
Results.Ok(new { status = "ready" }));
app.Run();
A Dockerfile could use:
FROM mcr.microsoft.com/dotnet/sdk:9.0 AS build
WORKDIR /src
COPY . .
RUN dotnet publish -c Release -o /app/publish
FROM mcr.microsoft.com/dotnet/aspnet:9.0
WORKDIR /app
COPY --from=build /app/publish .
USER app
EXPOSE 8080
ENTRYPOINT ["dotnet", "Application.dll"]
Again, the application stack is different, but the operational interface remains standardized.
Install Backstage
Create the Backstage application:
npx @backstage/create-app@latest
Choose a name such as:
platform-portal
Then:
cd platform-portal
yarn install
yarn dev
Backstage provides the developer-facing interface.
The platform portal should eventually provide workflows such as:
+--------------------------------------+
| Internal Developer Platform |
+--------------------------------------+
| |
| Create a Service |
| |
| [ Go Service ] |
| [ Python Service ] |
| [ Node.js Service ] |
| [ Java Service ] |
| [ .NET Service ] |
| |
+--------------------------------------+
Add the Service Catalog
Create a catalog definition:
apiVersion: backstage.io/v1alpha1
kind: Component
metadata:
name: payments-api
description: Payment processing service
spec:
type: service
lifecycle: production
owner: team-payments
system: commerce
Register the catalog location in Backstage.
Once registered, developers can find the service through the portal.
A service page should expose information such as:
Payments API
Owner:
Team Payments
Lifecycle:
Production
Repository:
payments-api
Runtime:
Kubernetes
Environment:
Production
Version:
1.8.4
Health:
Healthy
This transforms Backstage from a documentation website into an operational developer portal.
Create the Backstage Template
The Backstage software template should ask for the minimum information required.
Example:
apiVersion: scaffolder.backstage.io/v1beta3
kind: Template
metadata:
name: go-service
title: Go Service
description: Create a production-ready Go service
spec:
owner: platform-team
type: service
parameters:
- title: Service Information
required:
- name
- owner
properties:
name:
title: Service Name
type: string
owner:
title: Owner
type: string
steps:
- id: fetch
name: Fetch template
action: fetch:template
input:
url: ./skeleton
values:
name: ${{ parameters.name }}
owner: ${{ parameters.owner }}
- id: publish
name: Publish repository
action: publish:github
input:
repoUrl: github.com/company/${{
parameters.name
}}
- id: register
name: Register service
action: catalog:register
input:
repoContentsUrl: ${{ steps.publish.output.repoContentsUrl }}
Now the platform has its first true golden path.
Connect Backstage to Infrastructure Automation
The ideal workflow is:
Backstage
|
v
Software Template
|
+--------------------+
| |
v v
Git Repository Infrastructure
|
v
Terraform
|
v
AWS
The Backstage template can generate Terraform variables:
application_name = "payments-api"
environment = "development"
database_enabled = true
The infrastructure pipeline then provisions the required resources.
The important design decision is that Backstage should orchestrate, not become a replacement for Terraform.
Backstage knows what the developer requested.
Terraform knows how to create infrastructure.
Kubernetes knows how to run workloads.
GitHub Actions knows how to execute automation.
Each tool has a clear responsibility.
Build CI With GitHub Actions
Create:
.github/workflows/application.yml
name: Application CI
on:
push:
branches:
- main
pull_request:
jobs:
test:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- name: Run tests
run: |
echo "Run application tests here"
build:
needs: test
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- name: Build container
run: |
docker build \
-t application:${{ github.sha }} .
- name: Scan container
run: |
echo "Run container security scanner"
- name: Push container
run: |
echo "Push image to ECR"
A production platform should replace the placeholder scanner and registry commands with approved enterprise tooling.
The key point is that application teams should not need to reinvent this workflow.
Standardize CI With Reusable Workflows
Instead of copying the entire workflow into every repository, create a reusable workflow.
For example:
name: Platform Application Pipeline
on:
workflow_call:
inputs:
language:
required: true
type: string
jobs:
test:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- name: Test
run: |
echo "Testing ${{ inputs.language }} application"
container:
needs: test
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- name: Build container
run: docker build -t application:${{ github.sha }} .
The application repository can then contain only:
name: Application
on:
push:
branches:
- main
jobs:
platform:
uses: company/platform/.github/workflows/application.yml@main
with:
language: go
This is one of the most powerful platform patterns.
The platform team can improve the centralized pipeline without requiring every development team to manually copy the improvements.
Introduce GitOps
For production environments, use a desired-state repository.
Example:
environment-config/
├── development/
│ └── payments-api/
│ └── kustomization.yaml
│
├── staging/
│ └── payments-api/
│ └── kustomization.yaml
│
└── production/
└── payments-api/
└── kustomization.yaml
Production:
apiVersion: kustomize.config.k8s.io/v1beta1
kind: Kustomization
resources:
- deployment.yaml
- service.yaml
images:
- name: payments-api
newName: ACCOUNT.dkr.ecr.REGION.amazonaws.com/payments-api
newTag: 1.8.4
replicas:
- name: payments-api
count: 5
The GitOps controller continuously reconciles Kubernetes with the desired state stored in Git.
This provides:
- Auditable deployments
- Reproducibility
- Rollback capability
- Declarative infrastructure
- Separation between build and deployment
Implement Application Health Standards
Every golden-path service should implement at least:
/health
/ready
The platform can enforce these endpoints through generated Kubernetes configuration.
For more mature environments, add:
/metrics
and standard telemetry libraries.
The platform should provide the contract.
Application teams implement the business logic.
Standardize Kubernetes Security
The platform should generate secure defaults.
For example:
securityContext:
runAsNonRoot: true
allowPrivilegeEscalation: false
readOnlyRootFilesystem: true
capabilities:
drop:
- ALL
Pod-level security can also be standardized.
Resource requests should be mandatory:
resources:
requests:
cpu: "100m"
memory: "128Mi"
limits:
cpu: "500m"
memory: "512Mi"
This prevents applications from consuming unlimited cluster resources.
Add Network Policies
A platform should not assume every workload needs unrestricted network access.
Example:
apiVersion: networking.k8s.io/v1
kind: NetworkPolicy
metadata:
name: payments-api
spec:
podSelector:
matchLabels:
app: payments-api
policyTypes:
- Ingress
- Egress
ingress:
- from:
- namespaceSelector:
matchLabels:
name: ingress
egress:
- to:
- namespaceSelector:
matchLabels:
name: database
The actual policy should be adapted to the cluster architecture.
The important platform principle is that secure network behavior should be generated automatically.
Handle Secrets Properly
A development example might use:
apiVersion: v1
kind: Secret
metadata:
name: payments-db
type: Opaque
stringData:
username: payments
password: development-password
However, this should not be committed to a real production Git repository.
For production, integrate the platform with a dedicated secrets manager.
The desired developer experience should remain simple:
Developer requests PostgreSQL
|
v
Platform provisions database
|
v
Secret manager stores credentials
|
v
Workload receives secret securely
The complexity belongs inside the platform.
Add Observability
A platform should automatically provide operational visibility.
For Kubernetes workloads, standardize:
annotations:
prometheus.io/scrape: "true"
prometheus.io/path: "/metrics"
prometheus.io/port: "8080"
Applications can expose metrics using their respective ecosystems.
Go:
http.HandleFunc("/metrics", func(w http.ResponseWriter, r *http.Request) {
w.Write([]byte("# application_metrics 1\n"))
})
Python:
from prometheus_client import generate_latest
@app.get("/metrics")
def metrics():
return generate_latest()
Node.js:
app.get("/metrics", (_req, res) => {
res.type("text/plain");
res.send("# application_metrics 1\n");
});
The platform should eventually standardize telemetry through OpenTelemetry rather than requiring each team to invent its own instrumentation strategy.
Build a Platform CLI
Once the API exists, provide a CLI.
A developer experience might look like:
platform login
Then:
platform service create payments-api \
--language go \
--database postgres
Check status:
platform service status payments-api
Deploy:
platform service deploy payments-api \
--environment staging
View logs:
platform service logs payments-api
The CLI should call the same platform APIs used by Backstage.
Never duplicate business logic between:
Backstage
CLI
ChatOps
Instead:
+-----------+
| Backstage |
+-----+-----+
|
+---------+ |
| CLI +---------+
+---------+ |
v
+-------------+
| Platform API|
+------+------+
|
+-----------+-----------+
| | |
v v v
Terraform Kubernetes Git
Define the Platform API
A minimal API might expose:
POST /services
GET /services
GET /services/{name}
DELETE /services/{name}
POST /deployments
GET /deployments/{id}
POST /databases
GET /databases/{id}
GET /environments
GET /catalog
A service-creation request could be:
{
"name": "payments-api",
"language": "go",
"owner": "team-payments",
"database": {
"type": "postgres"
}
}
The response:
{
"id": "svc_12345",
"name": "payments-api",
"status": "provisioning"
}
The platform can then execute a workflow asynchronously.
Define Platform Events
As the platform becomes more sophisticated, introduce events.
For example:
ServiceCreated
RepositoryCreated
InfrastructureProvisioned
DeploymentStarted
DeploymentSucceeded
DeploymentFailed
DatabaseProvisioned
ServiceDeleted
A service-creation workflow might therefore look like:
ServiceCreated
|
+--> Create Repository
|
+--> Provision Infrastructure
|
+--> Configure CI
|
+--> Register Catalog
|
+--> Deploy Development
|
v
ServiceReady
Events also make it easier to integrate notifications, audit systems, and analytics.
Add Platform Policies
Policies should protect the organization without unnecessarily blocking developers.
Example policy rules:
Production services must have:
- Owner
- Two or more replicas
- Resource requests
- Health checks
- Approved container image
- Non-root execution
- Monitoring
A policy engine can evaluate:
service:
environment: production
runtime:
replicas: 1
security:
runAsRoot: true
and reject it:
{
"allowed": false,
"violations": [
"Production requires at least two replicas",
"Containers may not run as root"
]
}
This is much better than allowing deployment and discovering the problem later.
Make the Golden Path Configurable
Do not hard-code everything.
For example:
service:
name: payments-api
language: go
runtime:
replicas: 3
resources:
cpu:
request: 100m
limit: 500m
memory:
request: 128Mi
limit: 512Mi
database:
enabled: true
type: postgres
network:
public: false
observability:
metrics: true
tracing: true
The platform converts this high-level specification into the implementation details.
This is the essence of an internal platform product.
Separate Platform Configuration From Application Configuration
A useful distinction is:
Platform configuration
runtime:
replicas: 3
security:
runAsNonRoot: true
observability:
enabled: true
Application configuration
application:
paymentProvider: stripe
timeout: 5s
The platform owns infrastructure concerns.
The application team owns business behavior.
Keeping these boundaries clear prevents the platform from becoming a giant configuration system for every possible application property.
Add Production Promotion
A useful deployment lifecycle is:
Developer Commit
|
v
Pull Request
|
v
Automated Tests
|
v
Security Checks
|
v
Development
|
v
Staging
|
v
Approval / Policy
|
v
Production
Promotion should move an immutable artifact.
For example:
payments-api:sha-82c7a1
should be promoted rather than rebuilding the application for production.
That ensures the artifact tested in staging is the artifact deployed to production.
Implement Rollbacks
A deployment platform should make rollback simple.
For Kubernetes:
kubectl rollout history deployment/payments-api
and:
kubectl rollout undo deployment/payments-api
In a GitOps environment, rollback should ideally happen by reverting the desired-state change.
The developer experience could become:
platform deployment rollback payments-api
The platform then performs the appropriate Git or Kubernetes operation.
Make Backstage the Single Front Door
A mature Backstage portal could provide:
+------------------------------------------------------+
| Internal Developer Platform |
+------------------------------------------------------+
| Search services... |
+------------------------------------------------------+
My Services
--------------------------------------------------------
Payments API Healthy Production
Orders API Healthy Production
Customer API Warning Staging
Quick Actions
--------------------------------------------------------
[ Create Service ]
[ Create Database ]
[ Create Queue ]
[ Request Environment ]
Platform
--------------------------------------------------------
Documentation
Infrastructure
APIs
Teams
Security
Templates
The portal becomes a starting point for engineering work.
It should not merely be a catalog of links.
Give Teams Ownership
Every service should have an owner.
Example:
spec:
owner: team-payments
Teams should also have metadata:
apiVersion: backstage.io/v1alpha1
kind: Group
metadata:
name: team-payments
spec:
type: team
profile:
displayName: Payments Team
children: []
This enables ownership-based workflows.
For example:
Alert
|
v
Service
|
v
Owner
|
v
Team
Ownership is one of the most important pieces of an internal service catalog.
Create Platform Documentation
The platform repository should contain documentation such as:
docs/
├── getting-started.md
├── service-creation.md
├── deployments.md
├── databases.md
├── secrets.md
├── observability.md
├── security.md
└── troubleshooting.md
A getting-started guide should focus on what developers actually do:
1. Open the Developer Portal.
2. Select Create Service.
3. Select a language.
4. Enter the service name.
5. Select required dependencies.
6. Create the service.
7. Wait for the development deployment.
8. Open the service page.
Platform documentation should minimize cognitive load rather than expose every internal implementation detail.
Test the Complete Developer Journey
Do not test only Terraform.
Test the entire workflow.
A platform acceptance test should effectively simulate:
Developer
|
v
Backstage
|
v
Create Service
|
v
Repository
|
v
CI
|
v
Container Registry
|
v
Kubernetes
|
v
Health Check
|
v
Backstage Catalog
A successful test should verify:
Repository exists
CI succeeds
Container exists
Kubernetes deployment succeeds
Service responds
Catalog entry exists
Monitoring exists
Owner exists
This is what makes the platform a product rather than a collection of infrastructure components.
Suggested Final Repository
After implementing the major pieces, the platform repository could look like:
internal-developer-platform/
│
├── backstage/
│ ├── app-config.yaml
│ ├── catalog-info.yaml
│ ├── package.json
│ ├── packages/
│ │ ├── app/
│ │ └── backend/
│ └── templates/
│ ├── go-service/
│ ├── python-service/
│ ├── node-service/
│ ├── java-service/
│ └── dotnet-service/
│
├── terraform/
│ ├── modules/
│ │ ├── network/
│ │ ├── eks/
│ │ ├── ecr/
│ │ ├── database/
│ │ └── iam/
│ │
│ └── environments/
│ ├── dev/
│ ├── staging/
│ └── production/
│
├── kubernetes/
│ ├── base/
│ │ ├── deployment.yaml
│ │ ├── service.yaml
│ │ ├── namespace.yaml
│ │ └── serviceaccount.yaml
│ │
│ └── overlays/
│ ├── development/
│ ├── staging/
│ └── production/
│
├── platform-api/
│ ├── services/
│ ├── deployments/
│ ├── infrastructure/
│ └── policies/
│
├── platform-cli/
│ ├── cmd/
│ └── internal/
│
├── .github/
│ └── workflows/
│ ├── platform-ci.yml
│ ├── terraform.yml
│ └── release.yml
│
├── docs/
│ ├── architecture.md
│ ├── getting-started.md
│ ├── services.md
│ ├── infrastructure.md
│ ├── security.md
│ └── operations.md
│
└── README.md
Production Hardening Checklist
Before calling the platform production-ready, address the following areas.
Identity
[ ] SSO
[ ] RBAC
[ ] Team ownership
[ ] Service accounts
[ ] Short-lived credentials
Infrastructure
[ ] Terraform state management
[ ] State locking
[ ] Multi-AZ infrastructure
[ ] Disaster recovery
[ ] Cluster upgrades
[ ] Backup strategy
Kubernetes
[ ] Network policies
[ ] Pod security
[ ] Resource quotas
[ ] Requests and limits
[ ] Autoscaling
[ ] Pod disruption budgets
Security
[ ] Secret manager
[ ] Container scanning
[ ] Dependency scanning
[ ] Image signing
[ ] Policy enforcement
[ ] Audit logging
CI/CD
[ ] Automated tests
[ ] Reusable workflows
[ ] Artifact immutability
[ ] Deployment approvals
[ ] Rollbacks
[ ] GitOps
Observability
[ ] Logs
[ ] Metrics
[ ] Traces
[ ] Alerts
[ ] SLOs
[ ] Deployment visibility
Developer Experience
[ ] Backstage
[ ] Service catalog
[ ] Golden paths
[ ] CLI
[ ] Documentation
[ ] Feedback mechanism
The Most Important Design Principle
The most important lesson from this implementation is that the platform should be organized around capabilities, not tools.
Do not tell developers:
"Here is Terraform."
"Here is Kubernetes."
"Here is AWS."
"Here is Backstage."
"Here is GitHub Actions."
"Good luck."
Instead, tell them:
"Create a service."
"Add a database."
"Deploy to staging."
"Deploy to production."
"View your service."
"View your logs."
"View your metrics."
"Rollback your deployment."
The platform internally decides whether those capabilities are implemented using:
Backstage
Terraform
Kubernetes
AWS
GitHub
GitHub Actions
Argo CD
Prometheus
OpenTelemetry
That abstraction boundary is what turns infrastructure into a platform.
Complete End-to-End Workflow
The final developer experience should look like this:
DEVELOPER
|
v
+----------------+
| Backstage |
+-------+--------+
|
Create Service
|
v
+----------------+
| Golden Path |
+-------+--------+
|
+-----------------+------------------+
| | |
v v v
Application Terraform Catalog
Template Configuration Metadata
| | |
v v v
GitHub AWS Backstage
|
v
GitHub Actions
|
+------> Test
|
+------> Security Scan
|
+------> Build Image
|
+------> Push to ECR
|
v
GitOps Config
|
v
Kubernetes
|
+------> Deployment
+------> Service
+------> Ingress
+------> Secrets
+------> Monitoring
|
v
Running Application
|
v
Observability
The developer does not have to manually execute every step.
The platform turns a complex infrastructure workflow into a product experience.
Conclusion
An Internal Developer Platform should ultimately make software delivery feel less like assembling infrastructure and more like consuming a well-designed engineering product.
The technical foundation described in this tutorial—Terraform, AWS, Kubernetes, Backstage, GitHub Actions, containers, GitOps, observability, policies, and service catalogs—provides the building blocks.
But the real platform is the experience created by combining those building blocks.
Backstage gives developers a front door.
Terraform provides reproducible infrastructure.
AWS provides cloud primitives.
Kubernetes provides a standardized workload runtime.
Container images provide an application packaging boundary.
GitHub provides source control.
CI/CD automates validation and artifact creation.
GitOps provides declarative deployment.
Observability provides operational feedback.
Policies provide guardrails.
The golden paths connect everything together.
The platform’s most important interface is therefore not its Terraform code or Kubernetes YAML. It is the workflow presented to developers.
A developer should be able to say:
I need a new API.
The platform should turn that request into:
Repository
+
Application Template
+
CI Pipeline
+
Container Image
+
Infrastructure
+
Kubernetes Deployment
+
Security
+
Observability
+
Documentation
+
Ownership
That is the fundamental promise of an Internal Developer Platform.
The second important principle is standardize the platform contract, not the programming language.
A Go developer, Python developer, TypeScript developer, Java developer, and .NET developer should not have to learn completely different operational processes.
Their applications can have different frameworks and architectures while sharing platform expectations:
Health checks
Containerization
CI/CD
Security
Resource management
Observability
Ownership
Deployment
This allows engineering organizations to maintain technological diversity without accepting operational chaos.
The third principle is self-service with guardrails.
A platform should not give every developer unrestricted access to every cloud resource.
Instead, it should provide safe capabilities:
Create PostgreSQL
Create Redis
Create object storage
Create Kubernetes service
Create scheduled job
Deploy application
while enforcing organizational requirements underneath.
That allows developers to move quickly without sacrificing security, reliability, or compliance.
The fourth principle is platform evolution.
The first version of an IDP should not attempt to solve every infrastructure problem.
Start with one high-value golden path.
For example:
Create API
|
+--> Repository
+--> CI
+--> Container
+--> Kubernetes
+--> Observability
Get real developers using it.
Measure the results.
Then add capabilities based on actual demand.
A platform that solves five common problems exceptionally well is usually more valuable than one that theoretically supports fifty workflows but makes all of them difficult.
Finally, an IDP must be treated as a product with customers.
Those customers happen to be inside the organization.
They still have expectations.
They want fast workflows.
They want good documentation.
They want reliable services.
They want understandable errors.
They want predictable interfaces.
They want flexibility when they need it.
They want the platform team to listen.
Therefore, the platform team should continuously measure:
Time to first deployment
Deployment frequency
Lead time
Change failure rate
Platform adoption
Golden-path adoption
Infrastructure ticket volume
Developer satisfaction
Platform reliability
These metrics reveal whether the platform is actually delivering value.
The final architecture should not be viewed as a collection of technologies:
Backstage + Terraform + Kubernetes + AWS
It should be viewed as a system of developer capabilities:
Create
Provision
Build
Test
Deploy
Observe
Operate
Secure
Recover
That distinction is the difference between an infrastructure platform and an Internal Developer Platform built as a product.
A mature IDP absorbs infrastructure complexity without hiding the important parts. It gives developers a paved road while retaining escape hatches for advanced use cases. It provides secure defaults without turning every workflow into an approval process. It centralizes organizational standards without forcing every application into the same programming language or architecture.
Most importantly, it allows platform engineering to scale organizational capability rather than simply scaling infrastructure.
The ultimate goal is simple:
Developer intent
|
v
Simple platform interaction
|
v
Automated infrastructure
|
v
Secure software delivery
|
v
Observable production service
When this loop works well, developers spend more time solving business problems, platform engineers spend more time improving reusable capabilities, and the organization gains a scalable foundation for software delivery.
That is what it means to truly build an Internal Developer Platform as a product.