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.