Learnixo
Back to blog
Cloud & DevOpsintermediate

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.

LearnixoJune 9, 20268 min read
Platform EngineeringGitOpsArgoCDBackstageIDPKubernetesDevOps
Share:𝕏

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:

YAML
# 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: true

When 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:

YAML
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=true

This 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:

YAML
# Install this first (wave -1)
metadata:
  annotations:
    argocd.argoproj.io/sync-wave: "-1"

Lower wave numbers deploy first. Use PreSync hooks for database migrations:

YAML
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: OnFailure

Progressive Delivery with Argo Rollouts

Argo Rollouts extends ArgoCD with canary and blue-green deployments:

YAML
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 rollout

Combine 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:

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-v1

This 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:

YAML
# 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:

TYPESCRIPT
// 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:

TSX
<Route path="/deployments" element={<DeploymentTrackerPage />} />

Backend plugins provide API endpoints to the frontend:

TYPESCRIPT
// 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.

YAML
# 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:

YAML
# catalog-info.yaml
metadata:
  annotations:
    argocd/app-name: order-service        # links to ArgoCD application
    argocd/app-namespace: argocd

In 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: 0

That'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?

Share:𝕏

Leave a comment

Have a question, correction, or just found this helpful? Leave a note below.