GitOps + Backstage: The Platform Engineering Stack That Actually Works
Deep dive into GitOps with ArgoCD and Backstage developer portals — the two tools that form the backbone of most successful Internal Developer Platforms. Covers app-of-apps, progressive delivery, software templates, and custom plugins.
Why These Two Tools Together
Most platform engineering stacks converge on two tools:
- ArgoCD — for GitOps-based deployment and drift detection
- Backstage — for the developer portal, service catalog, and self-service templates
Neither is sufficient alone. ArgoCD without a portal means developers need Kubernetes YAML knowledge. Backstage without GitOps means developers can create services but deployments are still manual and inconsistent.
Together they form the core of an IDP:
- Backstage scaffolds the new service (repo, CI workflow, ArgoCD manifests)
- ArgoCD deploys it continuously as developers push changes
ArgoCD Deep Dive
The GitOps Reconciliation Loop
ArgoCD runs a continuous loop inside your cluster:
1. Read desired state from Git (every 3 minutes by default, or on webhook)
2. Compare to actual state in cluster
3. If diverged: report OutOfSync (and optionally auto-sync)
4. If converged: mark Synced ✓This gives you drift detection — if someone kubectl edits a resource directly, ArgoCD notices and alerts.
Directory Structure for GitOps
gitops-repo/
├── apps/ # ArgoCD Application definitions
│ ├── app-of-apps.yaml # root application
│ ├── platform/ # platform-level apps (ingress, cert-manager)
│ └── services/ # product team apps
│ ├── order-service/
│ │ ├── deployment.yaml
│ │ ├── service.yaml
│ │ ├── hpa.yaml
│ │ └── kustomization.yaml
│ └── payment-service/
│ └── ...
├── clusters/
│ ├── production/
│ │ └── values.yaml # cluster-specific overrides
│ └── staging/
│ └── values.yaml
└── charts/ # shared Helm charts
└── microservice/ # golden path Helm chart
├── Chart.yaml
└── templates/App-of-Apps Pattern
Instead of registering each service in ArgoCD manually, the app-of-apps pattern manages all Application CRDs from Git:
# apps/app-of-apps.yaml — the root application
apiVersion: argoproj.io/v1alpha1
kind: Application
metadata:
name: app-of-apps
namespace: argocd
spec:
project: default
source:
repoURL: https://github.com/org/gitops-repo
targetRevision: HEAD
path: apps/services # points to a directory of Application YAMLs
destination:
server: https://kubernetes.default.svc
namespace: argocd
syncPolicy:
automated:
prune: true
selfHeal: trueWhen a developer creates a new service and a Backstage template generates the ArgoCD Application YAML into apps/services/new-service/, ArgoCD picks it up automatically. No manual registration.
ApplicationSets: Multi-Cluster, Multi-Environment
ApplicationSets let you template Application CRDs across clusters or environments with a single manifest:
apiVersion: argoproj.io/v1alpha1
kind: ApplicationSet
metadata:
name: all-services
namespace: argocd
spec:
generators:
- git:
repoURL: https://github.com/org/gitops-repo
revision: HEAD
directories:
- path: apps/services/* # discovers all service directories
template:
metadata:
name: "{{path.basename}}"
spec:
project: default
source:
repoURL: https://github.com/org/gitops-repo
targetRevision: HEAD
path: "{{path}}"
destination:
server: https://kubernetes.default.svc
namespace: "{{path.basename}}"
syncPolicy:
syncOptions:
- CreateNamespace=trueThis single ApplicationSet discovers and deploys every service in apps/services/ automatically.
Sync Waves and Hooks
When you need ordered deployment (install CRDs before controllers, run migrations before app), use sync waves:
# Install this first (wave -1)
metadata:
annotations:
argocd.argoproj.io/sync-wave: "-1"Lower wave numbers deploy first. Use PreSync hooks for database migrations:
apiVersion: batch/v1
kind: Job
metadata:
name: db-migrate
annotations:
argocd.argoproj.io/hook: PreSync
argocd.argoproj.io/hook-delete-policy: HookSucceeded
spec:
template:
spec:
containers:
- name: migrate
image: my-app:latest
command: ["dotnet", "migrate"]
restartPolicy: OnFailureProgressive Delivery with Argo Rollouts
Argo Rollouts extends ArgoCD with canary and blue-green deployments:
apiVersion: argoproj.io/v1alpha1
kind: Rollout
metadata:
name: payment-service
spec:
replicas: 10
strategy:
canary:
steps:
- setWeight: 10 # 10% to new version
- pause:
duration: 5m # wait 5 minutes
- analysis: # check error rate
templates:
- templateName: success-rate
- setWeight: 50 # 50% if analysis passed
- pause:
duration: 5m
- setWeight: 100 # full rolloutCombine with Istio VirtualService for precise traffic weighting.
Backstage Deep Dive
The Software Catalog
The catalog is the backbone of Backstage. Every entity (Component, API, Resource, System, Domain) registers with a catalog-info.yaml:
apiVersion: backstage.io/v1alpha1
kind: Component
metadata:
name: order-service
description: Handles order lifecycle — create, pay, fulfill, cancel
links:
- url: https://grafana.internal/d/orders
title: Grafana Dashboard
icon: dashboard
- url: https://alerts.internal/service/order-service
title: Runbook
icon: docs
annotations:
github.com/project-slug: org/order-service
backstage.io/techdocs-ref: dir:.
prometheus.io/alert: order-service
argocd/app-name: order-service
grafana/dashboard-selector: service=order-service
pagerduty.com/service-id: P0RDRS
spec:
type: service
lifecycle: production
owner: team-orders
system: e-commerce
dependsOn:
- resource:default/orders-database
- component:default/inventory-service
- component:default/notification-service
providesApis:
- order-api-v3
consumesApis:
- payment-api-v2
- warehouse-api-v1This single file powers:
- Ownership: who owns this service (for on-call routing)
- Dependency graph: visualize which services depend on each other
- API contracts: what APIs are provided and consumed
- Quick links: Grafana, runbook, ArgoCD status embedded in the portal
Software Templates (Scaffolder)
The Scaffolder generates entire projects from templates. A developer fills a form in Backstage and gets a fully wired service:
# template.yaml — a Backstage software template
apiVersion: scaffolder.backstage.io/v1beta3
kind: Template
metadata:
name: dotnet-microservice
title: .NET Microservice
description: Create a new .NET 8 microservice with CI/CD, Kubernetes manifests, and Backstage registration
tags:
- dotnet
- recommended
- golden-path
spec:
owner: team-platform
type: service
parameters:
- title: Service Information
required: [name, description, owner]
properties:
name:
title: Service Name
type: string
pattern: "^[a-z][a-z0-9-]*$"
description: Lowercase, kebab-case (e.g., order-processor)
description:
title: Description
type: string
owner:
title: Owner Team
type: string
ui:field: OwnerPicker
ui:options:
catalogFilter:
kind: Group
- title: Infrastructure
properties:
needsDatabase:
title: PostgreSQL Database
type: boolean
default: false
environment:
title: Initial Environment
type: string
enum: [staging, production]
default: staging
steps:
- id: fetch-template
name: Fetch base template
action: fetch:template
input:
url: ./skeleton
values:
name: ${{ parameters.name }}
description: ${{ parameters.description }}
owner: ${{ parameters.owner }}
needsDatabase: ${{ parameters.needsDatabase }}
- id: create-repo
name: Create GitHub repository
action: publish:github
input:
allowedHosts: [github.com]
description: ${{ parameters.description }}
repoUrl: github.com?repo=${{ parameters.name }}&owner=myorg
defaultBranch: main
gitAuthorName: Platform Bot
gitAuthorEmail: platform@myorg.com
- id: create-argocd-app
name: Register in ArgoCD
action: argocd:create-resources
input:
appName: ${{ parameters.name }}
argoInstance: main
namespace: ${{ parameters.name }}
repoUrl: ${{ steps.create-repo.output.remoteUrl }}
labelValue: ${{ parameters.name }}
path: k8s/
- id: register-catalog
name: Register in Backstage Catalog
action: catalog:register
input:
repoContentsUrl: ${{ steps.create-repo.output.repoContentsUrl }}
catalogInfoPath: /catalog-info.yaml
output:
links:
- title: Repository
url: ${{ steps.create-repo.output.remoteUrl }}
- title: Open in ArgoCD
url: https://argocd.internal/applications/${{ parameters.name }}
- title: Open in Catalog
entityRef: ${{ steps.register-catalog.output.entityRef }}What the developer does: fill the form, click "Create".
What happens automatically: GitHub repo created, CI/CD workflow committed, Kubernetes manifests written, ArgoCD application registered, Backstage catalog entry created.
Building a Custom Plugin
Backstage plugins are React components registered into the portal. A minimal frontend plugin:
// packages/app/src/plugins/deployment-tracker/index.ts
import { createPlugin, createRoutableExtension } from '@backstage/core-plugin-api';
export const deploymentTrackerPlugin = createPlugin({
id: 'deployment-tracker',
routes: {
root: rootRouteRef,
},
});
export const DeploymentTrackerPage = deploymentTrackerPlugin.provide(
createRoutableExtension({
name: 'DeploymentTrackerPage',
component: () =>
import('./components/DeploymentTracker').then(m => m.DeploymentTracker),
mountPoint: rootRouteRef,
}),
);Register in App.tsx:
<Route path="/deployments" element={<DeploymentTrackerPage />} />Backend plugins provide API endpoints to the frontend:
// src/plugins/deployment-tracker-backend/src/service/router.ts
export async function createRouter(options: RouterOptions): Promise<express.Router> {
const router = Router();
router.get('/deployments/:serviceName', async (req, res) => {
const deployments = await getDeploymentsFromArgoCD(req.params.serviceName);
res.json(deployments);
});
return router;
}TechDocs: Docs as Code
Every service gets documentation alongside its code. Backstage renders Markdown in the portal via MkDocs.
# mkdocs.yml in every service repo
site_name: Order Service
docs_dir: docs/
nav:
- Overview: index.md
- Architecture: architecture.md
- API Reference: api.md
- Runbook: runbook.md
- ADRs: adrs/Developers write docs in Markdown, check them into Git alongside code. Backstage renders them at catalog/order-service/docs/.
Wiring ArgoCD and Backstage Together
The ArgoCD Backstage plugin shows deployment status directly in the catalog:
# catalog-info.yaml
metadata:
annotations:
argocd/app-name: order-service # links to ArgoCD application
argocd/app-namespace: argocdIn the Backstage portal, the catalog page for order-service shows:
- Current sync status (Synced / OutOfSync / Progressing)
- Last deployed version (Git commit SHA + message)
- Health status
- Recent deployment history
Developers never need to open the ArgoCD UI for normal operations.
The Self-Service Flow End to End
Here's what the complete self-service flow looks like for a developer at a company that has this stack running:
1. Developer opens Backstage → "Create Component"
2. Selects ".NET Microservice" golden path template
3. Fills in: name, owner, needs database: yes
4. Clicks "Create"
5. [Automated] GitHub repo created from template
6. [Automated] .NET 8 solution with Dockerfile, test project committed
7. [Automated] GitHub Actions workflow (lint, test, build, scan, push image)
8. [Automated] Kubernetes manifests (Deployment, Service, HPA, PodDisruptionBudget)
9. [Automated] Crossplane PostgreSQLClaim YAML created (if database: yes)
10. [Automated] ArgoCD Application registered in gitops-repo
11. [Automated] Backstage catalog entry created
12. Developer pushes first commit → CI runs → image pushed
13. ArgoCD detects new image → deploys to staging
14. Backstage shows: Synced ✓, health: Healthy, version: abc1234
15. Grafana dashboard auto-provisioned with RED metrics
Total time from "create" to first observable deploy: ~15 minutes
Tickets filed with platform team: 0That's the goal.
Common Mistakes
1. Backstage before the catalog is populated. An empty catalog is worse than no catalog — it looks abandoned. Seed the catalog with your top 20 services before announcing Backstage.
2. ArgoCD with self-heal in production from day one. Run in manual sync mode first. Understand what gets synced. Enable self-heal when you trust it.
3. Software templates that are too opinionated about tech stack. Offer 2-3 templates (Node.js, .NET, Python) — not one mega-template. Flexibility in the tech, consistency in the infrastructure.
4. Skipping adoption metrics. Instrument Backstage with analytics. If nobody uses the scaffolder, the golden path has a problem.
5. Platform team doing ticket work instead of product work. Every ticket your platform team handles manually is a product gap. Build the self-service feature, don't just handle the ticket.
Enjoyed this article?
Explore the Cloud & DevOps learning path for more.
Found this helpful?
Leave a comment
Have a question, correction, or just found this helpful? Leave a note below.