The Platform Engineering Challenge
Extend the Backstage golden path so one form provisions AWS with Terraform, deploys with Argo CD, and enforces policy and cost tracking.
Blueprint Walkthrough
Architecture Overview & Problem Statement
This capstone extends the golden path from the Mini IDP so that one Backstage form provisions everything a service needs. A GitHub Actions workflow runs Terraform to create the namespace, resource quota, ECR repository, and an IAM role for the service. The repository, CI pipeline, and manifests come from the skeleton, Argo CD deploys them, Kyverno blocks non-compliant workloads at admission, Prometheus scrapes the service, and Kubecost shows what the namespace costs. You finish with a working platform, three unguided challenges, and a repository you can walk an interviewer through.
This is the third and last of the connected platform projects. It starts from the template and ApplicationSet you built in Build a Mini Internal Developer Platform, which itself needs the portal from Build an Internal Developer Portal with Backstage. It also needs an EKS cluster with Prometheus, as built in Build Production AWS Infrastructure with Terraform. The earlier projects each build one layer. This one adds the pieces that turn a template into a governed platform: infrastructure provisioning, policy, and cost.
What you build:
- A Terraform module and a GitHub Actions workflow that provision per-service resources on demand
- Kyverno policies that apply only to platform-managed namespaces
- An extended skeleton with an HPA, a PodDisruptionBudget, network policies, and a ServiceMonitor
- An updated ApplicationSet that waits for provisioning instead of failing
- Cost visibility per namespace, and three challenge scenarios
fills form] --> B[Backstage] B -->|dispatch| A[GitHub Actions
Terraform] B -->|create repo| G[Service repo
CI builds image] A -->|provision| K[Cluster and AWS
namespace, ECR, IAM] G -->|image tag| C[Argo CD] C -->|syncs| K K -->|pod status| B classDef dev fill:#475569,color:#fff classDef portal fill:#F97316,color:#fff classDef ci fill:#8B5CF6,color:#fff classDef cd fill:#326CE5,color:#fff classDef infra fill:#10B981,color:#fff class D,G dev class B portal class A ci class C cd class K infra
Who does what
The central design rule is that Backstage orchestrates and never executes. Each system does the work it is best at.
| System | Owns | Why |
|---|---|---|
| Backstage | The form, repository creation, catalog entry | It is the front door, so it holds no cloud credentials |
| GitHub Actions + Terraform | Namespace, quota, ECR, IAM role | Per-service state, audit log, and temporary credentials through OIDC |
| Argo CD | Deploying what is in Git | Every deployment comes from Git and is drift-checked |
| Kyverno | Rejecting non-compliant workloads | It runs at admission, so kubectl, CI, and Argo CD all face the same rules |
| Prometheus, Kubecost | Metrics and cost | They observe, and never block |
Long-lived platform pieces such as the cluster and VPC stay in their own Terraform state. Per-service resources use a separate state per service, so one service's apply can never lock or corrupt another's.
admission} KYV -->|compliant| WL[Workload
in cluster] KYV -->|violation| DENY[Sync fails
with policy name] WL --> PROM[Prometheus
scrapes] WL --> KC[Kubecost
cost by namespace] classDef gate fill:#EF4444,color:#fff classDef ok fill:#10B981,color:#fff classDef obs fill:#8B5CF6,color:#fff classDef cd fill:#326CE5,color:#fff class KYV,DENY gate class WL ok class PROM,KC obs class ARGO cd
TipBuild and test each layer on its own before wiring them together. If you connect the template, Terraform, Argo CD, and Kyverno at once and it fails, every part is a suspect. The milestones below are ordered so each piece can be checked alone.
Milestone 1: Check the Prerequisites and Set Up Access
This milestone confirms the foundations and creates the access that GitHub Actions needs. A failure in a later step is much easier to read when you know this part works.
You need:
- The Mini IDP running: Backstage with the Node.js template and the
golden-path-servicesApplicationSet from that project - An EKS cluster with Argo CD, the Prometheus Operator stack, and
metrics-server(the HPA needs it) - A repository named
platform-infrastructurein your GitHub organisation - Two IAM roles that trust GitHub through OIDC, with no stored AWS keys: a Terraform role for the
platform-infrastructurerepository, and a deploy role for service repositories, whose trust policy allowsrepo:YOUR_ORG/*and whose permissions cover ECR push - An EKS access entry that lets the Terraform role create namespaces, quotas, and limit ranges
- Repository variables in
platform-infrastructure:TERRAFORM_ROLE_ARN,TF_STATE_BUCKET,EKS_CLUSTER_NAME; and an organisation variableAWS_DEPLOY_ROLE_ARN
kubectl get nodeskubectl get applicationset golden-path-services -n argocdkubectl top nodes# Expected: nodes Ready, the ApplicationSet exists, and top returns numbersSecurityA deploy role trusted by
repo:YOUR_ORG/*is fine for a lab, because any repository in your organisation could assume it. In production, scope each service to its own role, and give the Backstage GitHub token only the access it needs, ideally through a GitHub App.
Milestone 2: Provision Per-Service Resources with Terraform
This milestone builds the module and workflow that Backstage will trigger. Terraform creates the parts of a service that must be controlled by the platform and not by the team: the namespace with its quota, the image repository, and a least-privilege IAM role.
Create services/namespace/main.tf in platform-infrastructure. Backend settings are passed at init time so each service gets its own state file.
terraform { required_version = ">= 1.11.0" required_providers { aws = { source = "hashicorp/aws" version = "~> 6.0" } kubernetes = { source = "hashicorp/kubernetes" version = "~> 2.0" } } backend "s3" {}} variable "service_name" { type = string validation { condition = can(regex("^[a-z][a-z0-9-]*[a-z0-9]$", var.service_name)) error_message = "Use lowercase letters, numbers and hyphens, starting with a letter." }} variable "team" { type = string} variable "cluster_name" { type = string} provider "aws" { region = "ap-south-1"} data "aws_eks_cluster" "platform" { name = var.cluster_name} data "aws_eks_cluster_auth" "platform" { name = var.cluster_name} data "aws_caller_identity" "current" {} # Without this block the kubernetes resources below cannot reach the clusterprovider "kubernetes" { host = data.aws_eks_cluster.platform.endpoint cluster_ca_certificate = base64decode(data.aws_eks_cluster.platform.certificate_authority[0].data) token = data.aws_eks_cluster_auth.platform.token} resource "kubernetes_namespace" "service" { metadata { name = var.service_name labels = { "platform.devops-network.io/managed" = "true" "platform.devops-network.io/service" = var.service_name "team" = var.team } }} resource "kubernetes_resource_quota" "service" { metadata { name = "service-quota" namespace = kubernetes_namespace.service.metadata[0].name } spec { hard = { "pods" = "20" "requests.cpu" = "4" "requests.memory" = "8Gi" "limits.cpu" = "8" "limits.memory" = "16Gi" } }} # Gives pods sensible defaults so quota and cost attribution always workresource "kubernetes_limit_range" "service" { metadata { name = "service-defaults" namespace = kubernetes_namespace.service.metadata[0].name } spec { limit { type = "Container" default_request = { cpu = "100m" memory = "128Mi" } default = { cpu = "500m" memory = "512Mi" } } }} resource "aws_ecr_repository" "service" { name = var.service_name image_tag_mutability = "IMMUTABLE" force_delete = true # lab only, so teardown works image_scanning_configuration { scan_on_push = true } tags = { Service = var.service_name Team = var.team ManagedBy = "terraform" }} # CI tags images with commit SHAs, so keep the newest 20 of any tagresource "aws_ecr_lifecycle_policy" "service" { repository = aws_ecr_repository.service.name policy = jsonencode({ rules = [{ rulePriority = 1 description = "Keep the last 20 images" selection = { tagStatus = "any" countType = "imageCountMoreThan" countNumber = 20 } action = { type = "expire" } }] })} locals { oidc_issuer = replace(data.aws_eks_cluster.platform.identity[0].oidc[0].issuer, "https://", "")} data "aws_iam_policy_document" "irsa_trust" { statement { effect = "Allow" actions = ["sts:AssumeRoleWithWebIdentity"] principals { type = "Federated" identifiers = ["arn:aws:iam::${data.aws_caller_identity.current.account_id}:oidc-provider/${local.oidc_issuer}"] } condition { test = "StringEquals" variable = "${local.oidc_issuer}:sub" values = ["system:serviceaccount:${var.service_name}:${var.service_name}"] } }} resource "aws_iam_role" "service" { name = "${var.service_name}-irsa" assume_role_policy = data.aws_iam_policy_document.irsa_trust.json} # The service may read only its own secretsresource "aws_iam_role_policy" "own_secrets" { name = "${var.service_name}-own-secrets" role = aws_iam_role.service.id policy = jsonencode({ Version = "2012-10-17" Statement = [{ Effect = "Allow" Action = ["secretsmanager:GetSecretValue"] Resource = "arn:aws:secretsmanager:*:${data.aws_caller_identity.current.account_id}:secret:services/${var.service_name}/*" }] })} output "ecr_repository_url" { value = aws_ecr_repository.service.repository_url} output "irsa_role_arn" { value = aws_iam_role.service.arn}The IAM role is for the application's own AWS calls, such as reading a secret. It is not for pulling the image: the cluster nodes pull images from ECR using the node role, and a pod's IRSA role plays no part in that.
Create .github/workflows/provision-service.yml. The inputs go through environment variables instead of being pasted into shell commands, so a crafted input cannot inject commands into the runner.
name: Provision Service on: workflow_dispatch: inputs: service_name: description: 'Service name' required: true type: string team: description: 'Owning team' required: true type: string action: description: 'apply or destroy' required: true type: choice options: - apply - destroy default: apply # Two runs for the same service must never overlapconcurrency: group: provision-${{ inputs.service_name }} cancel-in-progress: false permissions: id-token: write contents: read jobs: terraform: runs-on: ubuntu-latest env: TF_VAR_service_name: ${{ inputs.service_name }} TF_VAR_team: ${{ inputs.team }} TF_VAR_cluster_name: ${{ vars.EKS_CLUSTER_NAME }} ACTION: ${{ inputs.action }} STATE_BUCKET: ${{ vars.TF_STATE_BUCKET }} defaults: run: working-directory: services/namespace steps: - uses: actions/checkout@v4 - uses: aws-actions/configure-aws-credentials@v4 with: role-to-assume: ${{ vars.TERRAFORM_ROLE_ARN }} aws-region: ap-south-1 - uses: hashicorp/setup-terraform@v3 with: terraform_version: '^1.11.0' - name: Terraform init run: | terraform init \ -backend-config="bucket=$STATE_BUCKET" \ -backend-config="key=services/$TF_VAR_service_name/terraform.tfstate" \ -backend-config="region=ap-south-1" \ -backend-config="use_lockfile=true" - name: Terraform plan run: | FLAG="" if [ "$ACTION" = "destroy" ]; then FLAG="-destroy"; fi terraform plan $FLAG -out=tfplan - name: Terraform apply run: terraform apply -auto-approve tfplanTest this workflow by hand before Backstage touches it: run it from the Actions tab for a throwaway service name and confirm the namespace, quota, ECR repository, and role appear. Then run it again with destroy.
Common MistakePutting the cluster, VPC, and every service namespace in one Terraform state. A new service's apply then locks the whole platform, and one corrupted state file affects everything. One state per service keeps failures contained.
Milestone 3: Enforce Policy with Kyverno
This milestone adds admission-time rules. Kyverno runs as a Kubernetes admission webhook, so every resource is checked whether it arrives from Argo CD, CI, or a person with kubectl. Running the same checks only in CI can be bypassed by anyone who applies a manifest directly.
helm repo add kyverno https://kyverno.github.io/kyverno/helm repo updatehelm search repo kyverno/kyvernohelm install kyverno kyverno/kyverno -n kyverno --create-namespace --version <version from the search>kubectl get pods -n kyvernoThe policies below apply only to namespaces carrying the managed label that Terraform sets, so they never touch system namespaces. Create policies/platform-policies.yaml:
apiVersion: kyverno.io/v1kind: ClusterPolicymetadata: name: require-run-as-non-rootspec: validationFailureAction: Enforce background: true rules: - name: run-as-non-root match: any: - resources: kinds: [Pod] namespaceSelector: matchLabels: platform.devops-network.io/managed: "true" validate: message: "Pods must set securityContext.runAsNonRoot to true." pattern: spec: securityContext: runAsNonRoot: true---apiVersion: kyverno.io/v1kind: ClusterPolicymetadata: name: require-resource-limitsspec: validationFailureAction: Enforce background: true rules: - name: cpu-memory-limits match: any: - resources: kinds: [Pod] namespaceSelector: matchLabels: platform.devops-network.io/managed: "true" validate: message: "Every container must set CPU and memory limits." pattern: spec: containers: - resources: limits: cpu: "?*" memory: "?*"---apiVersion: kyverno.io/v1kind: ClusterPolicymetadata: name: disallow-latest-tagspec: validationFailureAction: Enforce background: true rules: - name: no-latest match: any: - resources: kinds: [Pod] namespaceSelector: matchLabels: platform.devops-network.io/managed: "true" validate: message: "Use an explicit image tag, never latest." pattern: spec: containers: - image: "!*:latest"kubectl apply -f policies/platform-policies.yamlkubectl get clusterpolicyKyverno also generates the same checks for Deployments from these Pod rules, so a bad Deployment is rejected when Argo CD applies it, not later when a pod fails to start. Field names change between Kyverno versions, so check the current policy reference if validationFailureAction is reported as deprecated.
Test it alone: create a namespace with the managed label and try to apply a Pod that runs as root. It must be rejected with the policy name in the message.
SecurityNever fix a rejected deployment by turning the policy off. Fix the manifest. Use a documented
PolicyExceptiononly for a justified, narrow case, and write the reason next to it.
Milestone 4: Extend the Skeleton with Platform Guardrails
This milestone upgrades the Mini IDP skeleton so every new service ships with the reliability and observability defaults. Copy the Mini IDP template to templates/platform-engineering-challenge/ and change the files below. The Dockerfile, package.json, catalog-info.yaml, docs, and src/index.js carry over, except that the metrics counter should use the label status and the request counter should be named http_requests_total so SLO queries work later.
The template passes these values: name, description, owner, team, instanceSize, awsAccountId, awsRegion, ecrRegistry, and destination.
k8s/deployment.yaml pulls from ECR and sizes resources from the form. Backstage templates use {% %} for conditions, not ${{ }}.
apiVersion: apps/v1kind: Deploymentmetadata: name: ${{ values.name }} labels: app: ${{ values.name }} team: ${{ values.team }}spec: selector: matchLabels: app: ${{ values.name }} strategy: type: RollingUpdate rollingUpdate: maxUnavailable: 0 maxSurge: 1 template: metadata: labels: app: ${{ values.name }} team: ${{ values.team }} spec: serviceAccountName: ${{ values.name }} securityContext: runAsNonRoot: true runAsUser: 1001 containers: - name: ${{ values.name }} image: ${{ values.ecrRegistry }}/${{ values.name }} ports: - name: http containerPort: 4000 securityContext: allowPrivilegeEscalation: false{% if values.instanceSize == 'small' %} resources: requests: { cpu: 100m, memory: 128Mi } limits: { cpu: 500m, memory: 512Mi }{% elif values.instanceSize == 'medium' %} resources: requests: { cpu: 250m, memory: 256Mi } limits: { cpu: 1000m, memory: 1Gi }{% else %} resources: requests: { cpu: 500m, memory: 512Mi } limits: { cpu: 2000m, memory: 2Gi }{% endif %} livenessProbe: httpGet: path: /health/live port: 4000 initialDelaySeconds: 15 periodSeconds: 10 readinessProbe: httpGet: path: /health/ready port: 4000 initialDelaySeconds: 10 periodSeconds: 5k8s/serviceaccount.yaml carries the IAM role the Terraform module created. Its name is predictable, so the template needs no outputs from Terraform:
apiVersion: v1kind: ServiceAccountmetadata: name: ${{ values.name }} annotations: eks.amazonaws.com/role-arn: arn:aws:iam::${{ values.awsAccountId }}:role/${{ values.name }}-irsak8s/service.yaml, k8s/hpa.yaml, k8s/pdb.yaml, and k8s/servicemonitor.yaml follow. The Service must carry the app label and name its port http, or the ServiceMonitor finds nothing.
apiVersion: v1kind: Servicemetadata: name: ${{ values.name }} labels: app: ${{ values.name }}spec: selector: app: ${{ values.name }} ports: - name: http port: 4000 targetPort: 4000---apiVersion: autoscaling/v2kind: HorizontalPodAutoscalermetadata: name: ${{ values.name }}spec: scaleTargetRef: apiVersion: apps/v1 kind: Deployment name: ${{ values.name }} minReplicas: 2 maxReplicas: 6 metrics: - type: Resource resource: name: cpu target: type: Utilization averageUtilization: 70---apiVersion: policy/v1kind: PodDisruptionBudgetmetadata: name: ${{ values.name }}spec: minAvailable: 1 selector: matchLabels: app: ${{ values.name }}---apiVersion: monitoring.coreos.com/v1kind: ServiceMonitormetadata: name: ${{ values.name }} labels: release: kube-prometheus-stackspec: selector: matchLabels: app: ${{ values.name }} endpoints: - port: http path: /metrics interval: 15sk8s/networkpolicies.yaml isolates ingress to the namespace and allows only the callers that must reach the service. Prometheus runs in another namespace, so it needs its own rule, or scraping silently stops. Network policies are only enforced if your CNI supports them, so confirm that for your cluster.
apiVersion: networking.k8s.io/v1kind: NetworkPolicymetadata: name: allow-same-namespacespec: podSelector: {} policyTypes: [Ingress] ingress: - from: - podSelector: {}---apiVersion: networking.k8s.io/v1kind: NetworkPolicymetadata: name: allow-monitoringspec: podSelector: matchLabels: app: ${{ values.name }} policyTypes: [Ingress] ingress: - from: - namespaceSelector: matchLabels: kubernetes.io/metadata.name: monitoring ports: - port: 4000---apiVersion: networking.k8s.io/v1kind: NetworkPolicymetadata: name: allow-ingress-controllerspec: podSelector: matchLabels: app: ${{ values.name }} policyTypes: [Ingress] ingress: - from: - namespaceSelector: matchLabels: kubernetes.io/metadata.name: ingress-nginx ports: - port: 4000This baseline restricts ingress only. Restricting egress as well is a good hardening step, but it breaks calls to databases and AWS APIs unless you allow them explicitly.
k8s/kustomization.yaml lists every resource and holds the image tag:
apiVersion: kustomize.config.k8s.io/v1beta1kind: Kustomizationresources: - serviceaccount.yaml - deployment.yaml - service.yaml - hpa.yaml - pdb.yaml - networkpolicies.yaml - servicemonitor.yamlimages: - name: ${{ values.ecrRegistry }}/${{ values.name }} newTag: bootstrap.github/workflows/ci.yml builds to ECR. The first push to a new repository can start CI before Terraform has created the ECR repository, so the job waits for it. Wrap GitHub expressions in {% raw %} so Backstage does not try to fill them in.
name: CI on: push: branches: [main] paths-ignore: - 'k8s/**' permissions: id-token: write contents: write jobs: build: runs-on: ubuntu-latest steps: - uses: actions/checkout@v4 - uses: aws-actions/configure-aws-credentials@v4 with: role-to-assume: {% raw %}${{ vars.AWS_DEPLOY_ROLE_ARN }}{% endraw %} aws-region: ${{ values.awsRegion }} - name: Wait for the ECR repository run: | for i in $(seq 1 40); do if aws ecr describe-repositories --repository-names ${{ values.name }} >/dev/null 2>&1; then exit 0 fi echo "Waiting for provisioning to finish..." sleep 15 done echo "ECR repository never appeared" exit 1 - uses: aws-actions/amazon-ecr-login@v2 - name: Build and push run: | IMAGE=${{ values.ecrRegistry }}/${{ values.name }}:{% raw %}${{ github.sha }}{% endraw %} docker build -t "$IMAGE" . docker push "$IMAGE" - name: Point the manifests at the new image run: | cd k8s kustomize edit set image ${{ values.ecrRegistry }}/${{ values.name }}=${{ values.ecrRegistry }}/${{ values.name }}:{% raw %}${{ github.sha }}{% endraw %} git config user.name "platform-bot" git config user.email "platform-bot@users.noreply.github.com" git commit -am "deploy: {% raw %}${{ github.sha }}{% endraw %}" git pushCommon MistakeGranting the pod's IRSA role ECR pull permissions and expecting that to pull images. The kubelet on the node pulls images, using the node's identity. IRSA is for what the running application itself calls in AWS.
Milestone 5: Update the ApplicationSet to Wait for Provisioning
This milestone fixes a timing problem. The namespace now comes from Terraform, so Argo CD must not create it, and it must keep retrying until Terraform has finished. Edit argocd/golden-path-services from the Mini IDP and replace its syncPolicy:
syncPolicy: automated: prune: true selfHeal: true retry: limit: 10 backoff: duration: 30s factor: 2 maxDuration: 5mRemoving CreateNamespace=true matters. If Argo CD created the namespace first, Terraform would fail with "already exists", and the namespace would miss its quota and labels.
kubectl apply -f argocd/golden-path-services.yamlMilestone 6: Extend the Template
This milestone adds the provisioning step. It goes first, before the repository exists, so a failure leaves nothing half-built. Backstage's github:actions:dispatch action starts the workflow but does not wait for it, which is why the CI wait loop and the Argo CD retry exist.
templates/platform-engineering-challenge/template.yaml:
apiVersion: scaffolder.backstage.io/v1beta3kind: Templatemetadata: name: platform-engineering-challenge title: Production Service on the Platform description: Provisions AWS and Kubernetes resources, then creates and deploys a policy-compliant service tags: - nodejs - kubernetes - recommendedspec: owner: group:platform-team type: service parameters: - title: Service details required: - name - description - owner properties: name: title: Service name type: string pattern: '^[a-z][a-z0-9-]*[a-z0-9]$' ui:autofocus: true description: title: Description type: string owner: title: Owner team type: string ui:field: OwnerPicker ui:options: catalogFilter: kind: Group instanceSize: title: Service size type: string enum: [small, medium, large] enumNames: - 'Small (100m CPU, 128Mi)' - 'Medium (250m CPU, 256Mi)' - 'Large (500m CPU, 512Mi)' default: small - title: Repository required: - repoUrl properties: repoUrl: title: Repository location type: string ui:field: RepoUrlPicker ui:options: allowedHosts: - github.com steps: - id: provision name: Provision AWS and Kubernetes resources action: github:actions:dispatch input: repoUrl: github.com?owner=YOUR_GITHUB_ORG&repo=platform-infrastructure workflowId: provision-service.yml branchOrTagName: main workflowInputs: service_name: ${{ parameters.name }} team: ${{ parameters.owner | parseEntityRef | pick('name') }} action: apply - id: fetch-template name: Render service files action: fetch:template input: url: ./skeleton values: name: ${{ parameters.name }} description: ${{ parameters.description }} owner: ${{ parameters.owner }} team: ${{ parameters.owner | parseEntityRef | pick('name') }} instanceSize: ${{ parameters.instanceSize }} awsAccountId: 'YOUR_AWS_ACCOUNT_ID' awsRegion: 'ap-south-1' ecrRegistry: 'YOUR_AWS_ACCOUNT_ID.dkr.ecr.ap-south-1.amazonaws.com' destination: ${{ parameters.repoUrl | parseRepoUrl }} - id: publish-github name: Create GitHub repository action: publish:github input: repoUrl: ${{ parameters.repoUrl }} description: ${{ parameters.description }} repoVisibility: private defaultBranch: main topics: - golden-path - nodejs - id: register-catalog name: Register in catalog action: catalog:register input: repoContentsUrl: ${{ steps['publish-github'].output.repoContentsUrl }} catalogInfoPath: '/catalog-info.yaml' output: links: - title: Repository url: ${{ steps['publish-github'].output.remoteUrl }} - title: Open in catalog icon: catalog entityRef: ${{ steps['register-catalog'].output.entityRef }}The Backstage GitHub token needs the workflow scope to dispatch workflows. A token with that scope can trigger workflows in every repository it can reach, so use a dedicated machine account or a GitHub App.
Because private repositories are used here, keep the repo-creds secret for Argo CD from the Mini IDP, and register the template in catalog.locations as before.
Common MistakeBuilding one huge template for every kind of service. Share the small universal part (security context, limits, owner, catalog entry) and give each kind of workload its own template, such as a batch job or a static site. A template that fits nobody gets bypassed, and bypassed services escape every guardrail.
Milestone 7: Add Cost Visibility
This milestone shows what each team's namespace costs. Terraform already labels each namespace with team, and Kubecost reports cost by namespace without further setup. Install Kubecost, or OpenCost, by following the current install instructions for your version, and connect it to your Prometheus.
kubectl get pods -n kubecostkubectl port-forward -n kubecost deployment/kubecost-cost-analyzer 9091:9090 & curl -s "http://localhost:9091/model/allocation?window=1h&aggregate=namespace" | jq 'keys'Allow 15 to 30 minutes before new namespaces show meaningful numbers. To attribute cost by the team label instead of the namespace, check your version's documentation for the label mapping setting.
TipCost is derived from resource requests as well as usage. A service that requests far more than it uses looks expensive and wasteful in Kubecost, which is the problem Challenge C asks you to fix.
Milestone 8: Run the Platform End to End
This milestone proves the whole chain works. Start a timer, open Create in Backstage, choose Production Service on the Platform, and fill in analytics-service, a description, an owner team, and medium size. Click Create.
Verify each link in the order it happens:
# 1. Provisioning workflow succeededgh run list --repo YOUR_GITHUB_ORG/platform-infrastructure --limit 3 # 2. Terraform created the namespace, quota and limitskubectl get namespace analytics-service --show-labelskubectl get resourcequota,limitrange -n analytics-service # 3. The ECR repository and IAM role existaws ecr describe-repositories --repository-names analytics-service --region ap-south-1 \ --query 'repositories[0].repositoryUri' --output textaws iam get-role --role-name analytics-service-irsa --query 'Role.RoleName' --output text # 4. The service repository exists and CI pushed an imagegh repo view YOUR_GITHUB_ORG/analytics-service --json name,repositoryTopicsgh run list --repo YOUR_GITHUB_ORG/analytics-service --limit 3 # 5. Argo CD created and synced the Applicationkubectl get applications -n argocd # 6. Pods run, and the HPA and PDB existkubectl get pods,hpa,pdb,networkpolicy -n analytics-service # 7. Prometheus is scrapingcurl -s "http://localhost:9090/api/v1/query" \ --data-urlencode 'query=up{namespace="analytics-service"}' | jq '.data.result[].value[1]'# Expected: "1"Stop the timer when the pods are Running, and write the time down. Then open the service in Backstage and check the Kubernetes tab. Finally, push a code change and confirm the loop: CI builds a new image, a deploy: commit changes the tag, and Argo CD rolls it out.
Milestone 9: Solve Three Challenges
These scenarios have no step-by-step instructions. Use everything you have built.
Challenge A: Add a database with Crossplane
Give payment-reconciler a PostgreSQL database that is created from a Kubernetes manifest in the service repository and managed by Argo CD like any other resource.
Install Crossplane and an AWS provider, then write a CompositeResourceDefinition and a Composition that turn a small claim into an RDS instance in your private database subnets, with the connection details written to a Secret in the service's namespace. Add an enableDatabase option to the template, render the claim only when it is set (use {% if %}), and give the Deployment the Secret through envFrom.
Success looks like this:
kubectl get postgresqlinstance -n payment-reconciler# Expected: READY True (RDS can take 5 to 10 minutes) kubectl get secret payment-reconciler-db-credentials -n payment-reconciler# Expected: the Secret exists kubectl get deployment payment-reconciler -n payment-reconciler -o json \ | jq '.spec.template.spec.containers[0].env // [] | map(select(.name == "DB_PASSWORD"))'# Expected: [] (no password written directly in the Deployment)Also search the repository history to confirm no credential was ever committed.
Challenge B: Break and fix a policy
Change k8s/deployment.yaml in analytics-service to runAsNonRoot: false and runAsUser: 0, and push. Observe what happens in Argo CD, find the policy name in the error, then fix the manifest and push again.
Success looks like this:
argocd app get analytics-service --show-operation# While broken: the sync fails with an admission webhook denial naming require-run-as-non-root argocd app get analytics-service# After the fix: Synced and HealthyThe pods that were already running keep serving during the failed sync, because Kyverno rejects the new Deployment version and does not remove the old one. Explain why that is the right behaviour.
Challenge C: Cut the service's cost by at least 40 percent
analytics-service was created as medium and uses far less than it requests. Reduce its requested resources by at least 40 percent without breaking its availability.
Check actual usage with kubectl top pods -n analytics-service. Install the Vertical Pod Autoscaler in recommendation-only mode and read its suggested sizes. Update the requests in k8s/deployment.yaml, push the change through Git, and compare Kubecost's cost for the namespace before and after. Remember that the HPA keeps at least two replicas, so requests are multiplied by two.
Then confirm availability held, using traffic you generate yourself:
curl -s "http://localhost:9090/api/v1/query" --data-urlencode \ 'query=1 - (sum(rate(http_requests_total{namespace="analytics-service",status!~"2.."}[1h])) / sum(rate(http_requests_total{namespace="analytics-service"}[1h])))' \ | jq '.data.result[0].value[1]'# Expected: 0.999 or higherMilestone 10: Prepare the System Design Review
These are the questions an interviewer asks about a platform like this. Answer them in your own words, from what you built, and note the weak points as well as the strengths.
- How would you design this for 50 teams deploying on their own? Namespace per service with quotas, so no team can starve the rest. Policy enforced at admission, so there is no approval queue. A self-service template, so a new team needs no ticket. Cost attributed by namespace and team label. Say where this lab falls short: it uses one Argo CD project, and a real platform gives each team its own project and RBAC.
- A new engineer joins. How do they get a running service? They fill in a form and the platform provisions, creates, deploys, and registers the service. Use your measured time, and name the manual steps that still exist, such as first-time repository access.
- Production returns 502 errors. How do you debug? Work from the outside in: Argo CD sync and health, pod status and restarts, whether the Service has endpoints, network policies, then error-rate metrics lined up with the last change in Git.
- How do you make every service meet security standards? Kyverno at admission, with the template compliant by default so a new service starts with zero violations. Be honest about the limits: a cluster admin can change webhooks, and the policies cover only the rules you wrote.
Validation & Testing
The platform is complete when all of these are true for analytics-service:
- The provisioning workflow succeeded, and the namespace has its quota, limit range, and
managedlabel - The ECR repository and IAM role exist, and the Terraform state is separate for this service
- CI built an image tagged with the commit SHA and committed that tag to the manifests
- Argo CD synced the Application, and it is Healthy with at least two pods Running
- The HPA, PDB, and network policies exist, and Prometheus shows the service as
up - Kyverno rejects a root-user Deployment in this namespace, and accepts the compliant one
- Kubecost or OpenCost lists the namespace and its cost
- Backstage shows the service with its owner and live Kubernetes status
- Challenges A, B, and C pass their success checks
If a step fails, find the first broken link in the chain and fix it before looking further:
# Dispatch failed: is the workflow active, and does the token have the workflow scope?gh workflow list --repo YOUR_GITHUB_ORG/platform-infrastructure # Terraform failed: read the failing step, and check for a stale state lockgh run view --repo YOUR_GITHUB_ORG/platform-infrastructure --log-failed # Argo CD does not see the service: check the ApplicationSet and the repo topickubectl describe applicationset golden-path-services -n argocd | tail -20 # Sync rejected: read the denial, then fix the manifestargocd app get analytics-service --show-operationClean up with the same workflow: run Provision Service with action set to destroy for each service, delete the repositories, and remove the Argo CD Applications. For a template that fails halfway, the destroy run is the rollback, which is why provisioning is the first step.
For your portfolio, include the architecture diagrams, a screen recording of the form creating a service, your measured time from form to running pods, the Kyverno denial message from Challenge B, and the cost before and after in Challenge C. Add a one-page write-up of what you would change before running this for real: GitHub App authentication, per-team Argo CD projects, per-service deploy roles, and egress policies.