Building Golden Paths with Backstage: Part 3 - Production from Day One — hero banner

January 03, 2026·6 min read

In Part 1, we built the foundation: a shared development cluster with namespace isolation and a quick-start template that gets developers from zero to deployed in under 5 minutes. In Part 2, we added preview environments that automatically spin up for every pull request.

Now it's time for production.


The Wrong Approach (Twice)

My first attempt at a production template was basically a copy of the quick-start template that pointed to a different cluster. Create a new repository, scaffold a new application, deploy to production. That was obviously wrong.

My second attempt was a "promote to production" template. A developer who already had a service in dev would run a separate template that created production infrastructure for their existing repo. Better, but still wrong.

Here's what I realized: if you know a service will eventually need production infrastructure, why not create it from the start?


The Insight: Production is Just Configuration

The difference between development and production isn't the code or the repository. It's configuration:

Aspect Development Production
Cluster shared-dev-cluster aks-app-spoke
Namespace team-dev team-prod
Image tag latest v1.0.0 (semver)
Replicas 2 3+
HPA disabled enabled
URL {service}.chrishouse.io {service}-prod.chrishouse.io
Routing Hub → East-West Gateway Hub → Spoke Gateway

If all of this is just YAML configuration, why can't we generate it all at once?


One Template, Both Environments

Instead of two separate templates (quick-start + promote), I merged them into a single golden path template with an optional production toggle:

parameters:
  - title: Service Details
    properties:
      service_name:
        title: Service Name
        type: string
      team:
        title: Team
        type: string
        ui:field: OwnerPicker
      description:
        title: Description
        type: string

  - title: Production Configuration
    properties:
      includeProduction:
        title: Include Production Infrastructure
        type: boolean
        default: true
        description: Create production ArgoCD app, namespace, and ingress routes

      prodReplicas:
        title: Production Replicas
        type: integer
        default: 3
        minimum: 2

      enableHPA:
        title: Enable Horizontal Pod Autoscaling
        type: boolean
        default: true

The magic is in that includeProduction checkbox. Default is true, but developers can uncheck it if they're just experimenting.


What Gets Created

When a developer runs the template with production enabled, a single PR contains everything:

infra/
├── quickstart-services/my-service/
│   ├── namespace-claim.yaml        # Dev namespace
│   ├── namespace-claim-prod.yaml   # Prod namespace
│   ├── argocd-application.yaml     # Dev ArgoCD app (tracks latest)
│   ├── argocd-application-prod.yaml # Prod ArgoCD app (tracks semver)
│   └── README.md
└── kubernetes/
    └── istio-hub/
        ├── virtualservice-my-service-dev.yaml   # Dev routing
        └── virtualservice-my-service-prod.yaml  # Prod routing

Six files. One PR. Both environments ready.

The template itself has separate folders (gitops-manifests-dev/ and gitops-manifests-prod/) as building blocks that get conditionally included, but the output is unified - everything lands in the same PR.

Compare this to the two-template approach:

  1. Run quick-start template → PR #1
  2. Wait for approval and merge
  3. Develop for a while
  4. Run promote template → PR #2
  5. Wait for approval and merge

Now it's just:

  1. Run golden path template → PR #1
  2. Done

Conditional Steps in Backstage

The template uses conditional steps to skip production resources when not needed:

steps:
  # Always runs - creates dev infrastructure
  - id: create-gitops-dev
    name: Create Dev GitOps Configuration
    action: fetch:template
    input:
      url: ./gitops-manifests-dev
      targetPath: ./gitops/quickstart-services/${{ parameters.service_name }}

  # Only runs if production is enabled
  - id: create-gitops-prod
    name: Create Prod GitOps Configuration
    if: ${{ parameters.includeProduction }}
    action: fetch:template
    input:
      url: ./gitops-manifests-prod
      targetPath: ./gitops/quickstart-services/${{ parameters.service_name }}

  # Only runs if production is enabled
  - id: create-hub-vs
    name: Create Hub Ingress Route
    if: ${{ parameters.includeProduction }}
    action: fetch:template
    input:
      url: ./hub-virtualservice
      targetPath: ./gitops/kubernetes/istio-hub

The if clause makes steps conditional. When includeProduction is false, the production steps are skipped entirely.


The Production ArgoCD Application

The production app uses ArgoCD Image Updater for automatic version detection:

apiVersion: argoproj.io/v1alpha1
kind: Application
metadata:
  name: my-service-prod
  namespace: argocd
  annotations:
    argocd-image-updater.argoproj.io/image-list: "app=ghcr.io/crh225/my-service"
    argocd-image-updater.argoproj.io/app.update-strategy: "semver"
    argocd-image-updater.argoproj.io/app.allow-tags: "regexp:^v[0-9]+\\.[0-9]+\\.[0-9]+$"
spec:
  source:
    repoURL: https://github.com/crh225/my-service
    targetRevision: main
    path: helm
    helm:
      parameters:
        - name: image.tag
          value: "v1.0.0"  # Initial version
        - name: replicaCount
          value: "3"
        - name: autoscaling.enabled
          value: "true"
  destination:
    name: aks-app-spoke
    namespace: team-prod

This creates a trunk-based release flow:

  • Development: Push to main → builds latest → auto-deploys
  • Production: Create GitHub Release → builds v1.x.x → Image Updater detects and deploys

Traffic Flow

Both dev and prod environments get external URLs through the Istio mesh. The key difference is which cluster receives the traffic.

Development Traffic

https://my-service.chrishouse.io
         │
         ▼
┌─────────────────────────────────────────┐
│ Hub Cluster (aks-mgmt-hub)              │
│                                         │
│ main-gateway                            │
│     │                                   │
│     ▼                                   │
│ VirtualService: my-service-dev-vs       │
│     │                                   │
│     ▼                                   │
│ ServiceEntry: shared-dev.internal       │
│     → shared-dev east-west gateway IP   │
└─────────────────────────────────────────┘
         │
         ▼
┌─────────────────────────────────────────┐
│ Shared Dev Cluster                      │
│                                         │
│ cross-network-gateway (east-west)       │
│     │                                   │
│     ▼                                   │
│ VirtualService: my-service              │
│     │                                   │
│     ▼                                   │
│ my-service.team-dev.svc.cluster.local   │
└─────────────────────────────────────────┘

Production Traffic

https://my-service-prod.chrishouse.io
         │
         ▼
┌─────────────────────────────────────────┐
│ Hub Cluster (aks-mgmt-hub)              │
│                                         │
│ main-gateway                            │
│     │                                   │
│     ▼                                   │
│ VirtualService: my-service-prod-vs      │
│     │                                   │
│     ▼                                   │
│ ServiceEntry: spoke.internal            │
│     → app-spoke gateway IP              │
└─────────────────────────────────────────┘
         │
         ▼
┌─────────────────────────────────────────┐
│ App Spoke Cluster (aks-app-spoke)       │
│                                         │
│ cross-network-gateway (east-west)       │
│     │                                   │
│     ▼                                   │
│ VirtualService: my-service              │
│     │                                   │
│     ▼                                   │
│ my-service.team-prod.svc.cluster.local  │
└─────────────────────────────────────────┘

Both services are immediately accessible at their URLs after ArgoCD syncs. No port-forwarding, no VPN - just HTTPS to a real domain.


Why This Matters

This change is more significant than it might appear:

1. Reduced Cognitive Load

Developers don't need to learn two templates. There's one golden path that handles everything. The mental model is simpler: "run the template, get a service."

2. Production-Ready from Day One

Even if a developer doesn't release to production immediately, the infrastructure is waiting. When they're ready, it's just:

  1. Create a GitHub Release with tag v1.0.0
  2. CI builds the image
  3. ArgoCD deploys to production

No additional PRs, no waiting for platform team approval again.

3. Consistent Configuration

When dev and prod are created together, they're guaranteed to be consistent. Same Helm chart, same value patterns, same team ownership. No drift between what was created in dev months ago and what gets created for prod now.

4. Faster Time to Production

The two-template approach had a hidden cost: the second template was often run weeks or months after the first. By then, developers had forgotten the exact configuration, team names had changed, and the promotion became a mini-project.

With everything created upfront, the path to production is just a git tag away.

5. Optional Complexity

The checkbox makes it optional. Experimenting with something that will never go to production? Uncheck the box. Building a real service? Leave it checked (the default).


The Complete Golden Path

The journey from idea to production is now:

Stage Action What Happens
Create Run golden path template Dev + Prod infrastructure created
Develop Push to main Auto-deploys to dev cluster
Test Open PR Preview environment spins up
Release Create GitHub Release v1.0.0 Production deploys automatically

One template. One PR. Both environments. Production when you're ready.


Developer Experience

From the developer's perspective:

  1. Open Backstage and select "Golden Path - Node.js Microservice"
  2. Enter service details (name, team, description)
  3. Configure production (replicas, HPA) or uncheck to skip
  4. Click create

The PR includes everything. After merge:

  • Dev deploys immediately on every push to main
  • Production waits for a GitHub Release

When ready for production:

  1. Go to your repository
  2. Create a Release with tag v1.0.0
  3. Wait 2-3 minutes
  4. Service is live at {service}-prod.chrishouse.io

Series Progress

  • Part 1: Foundation - Shared clusters, namespace isolation, golden path template
  • Part 2: Preview Environments - Ephemeral PR environments with Istio multi-cluster routing
  • Part 3: Production from Day One (this post) - Optional production infrastructure in the initial template

Key Takeaways

  1. Merge, don't separate. If dev and prod use the same code, create their infrastructure together.

  2. Make it optional. A checkbox is all you need. Default to "yes" for real services.

  3. Reduce the number of workflows. Every additional template is cognitive overhead. One template that does everything is better than specialized templates.

  4. Production-ready doesn't mean production-deployed. Create the infrastructure upfront; deploy when ready via git tags.

  5. Time to production matters. The faster developers can go from idea to production, the more value they deliver. Remove every unnecessary step.

The golden path is now truly golden: one template creates everything a service needs, from development through production. The only thing left is writing the code.


Implementation Repository

Full implementation: github.com/crh225/ARMServicePortal

Key files:

Enjoyed this post? Give it a clap!

SeriesBuilding Golden Paths with Backstage
Part 4 of 4

Comments