Skip to main content

03 - Golden Paths: Definition and Implementation of Standardized Flows

A golden path is a supported template, not a mandate. I build one with Cookiecutter and Backstage and check it with an executable test.

What do you think about this article?

03 - Golden Paths: Definition and Implementation of Standardized Flows
Platform EngineeringPhoto:Ruben BoekelooPexels

At a Glance

A golden path is a supported template, not a mandate. I build one with Cookiecutter and Backstage and check it with an executable test.

What a golden path is and what it promises

A golden path is the «opinionated and supported» way to build something: a service, a website, a data pipeline. This is the definition Spotify gives of it, where the concept was born as a tutorial written during a Hack Week for backend services, at a time when the only way to find out how to do something was to ask a colleague, a practice the post calls «rumour-driven development». The CNCF document on platforms reduces it to a concrete object: a starter project template together with its documentation.

The pact is hidden in one word, «supported». Whoever stays on the path has the help of the platform team; whoever leaves it may do so, but knows that support is gone. That is why it is not a cage: in Spotify's post the stated goal is not to restrict engineers or to set standards for their own sake, but to stop every team from reinventing the wheel and to leave them fewer decisions to make. The DORA 2024 report describes golden paths as highly automated, self-service workflows, and among the tasks that use them it lists creating a new application, provisioning a database, testing and managing DNS.

In this article I build a real golden path, small but executable: a Cookiecutter template that generates a service, the same thing described as a Backstage template, and a test that checks that the template produces a healthy project. Article 4 focuses on Backstage, the portal that exposes them; for the question «which golden path to build first» the demand-versus-supply script of article 1 applies. Article 2 explains where the golden path sits among the platform's three planes.

Which golden path to build: one per type, starting from the most frequent

A single golden path for everything does not exist: an HTTP service, an event consumer and an infrastructure module have different structures. The categories that appear most often are these:

  • HTTP service: code, health check, pipeline, catalog descriptor.
  • Web application: frontend build, publishing, caching.
  • Event consumer: handling of retries and of messages that cannot be processed.
  • Scheduled job: scheduling, structured logs, alert on failure.
  • Infrastructure module: tests, documentation, semantic versions.

The list is not a work plan: it is the vocabulary with which to ask the teams what they really create. You start from a single path, the one that covers the most new services (a count you already have, in the catalog or in the request tracker), and you add the second only when the first has users and an owner. A golden path without an owner ages immediately: choosing which one to build first is also choosing who maintains it.

The template: the definition and the skeleton

Cookiecutter is a Python tool (not an npm package: the invocation npx cookiecutter does not exist) that generates a project from a template folder. The current version on 2026-11-13 is 2.7.1, compatible with Python 3.10 to 3.14. The cookiecutter.json file is the definition of the golden path: it declares the questions to ask and the default values.

{
  "service_name": "servizio-esempio",
  "owner": "team-checkout",
  "database": ["postgresql", "none"]
}

In the file, service_name and owner are free text with a default value, while database is a list: Cookiecutter treats it as a choice among options (the «choice variables» of the documentation) and uses the first as the default. With --no-input no question is asked and the values passed on the command line are used, or the defaults. It is the behavior a pipeline needs, where nobody can answer.

The template folder is called {{cookiecutter.service_name}}: the service name enters the path, and inside every file the Jinja placeholders are replaced. The most interesting file is the catalog descriptor, because it also uses a conditional block: the database dependency appears only if the choice is not none. Here the template is shown first and then the result of rendering with database=postgresql.

apiVersion: backstage.io/v1alpha1
kind: Component
metadata:
  name: {{ cookiecutter.service_name }}
  description: Service created by the golden path "servizio-http"
spec:
  type: service
  lifecycle: experimental
  owner: {{ cookiecutter.owner }}
{%- if cookiecutter.database != "none" %}
  dependsOn:
    - resource:{{ cookiecutter.service_name }}-db
{%- endif %}

# Output (rendered with service_name=pagamenti, owner=team-checkout, database=postgresql):
# apiVersion: backstage.io/v1alpha1
# kind: Component
# metadata:
#   name: pagamenti
#   description: Service created by the golden path "servizio-http"
# spec:
#   type: service
#   lifecycle: experimental
#   owner: team-checkout
#   dependsOn:
#     - resource:pagamenti-db

The minus sign in {%- if ... %} removes the blank line the block would otherwise leave in the YAML, and without it the file would remain valid but unpleasant. The other four files of the skeleton are a README.md, a Dockerfile that runs as an unprivileged user, a small HTTP server with a /health path and a GitHub Actions pipeline that compiles the code. I did not run the Dockerfile (no Docker on this machine), nor start the server (no open port), nor launch the pipeline: I verified that the code compiles and that the pipeline's YAML is readable. The actual generation produces this:

cookiecutter template --no-input -o generato service_name=pagamenti owner=team-checkout database=postgresql

# Output (cd generato && find . -type f | LC_ALL=C sort):
# ./pagamenti/.github/workflows/ci.yml
# ./pagamenti/Dockerfile
# ./pagamenti/README.md
# ./pagamenti/app/main.py
# ./pagamenti/catalog-info.yaml

The same path in the portal: the Backstage template

The skeleton alone requires the developer to install Cookiecutter, run it and then create the repository, configure it and register the component in the catalog. Backstage's Software Template performs these steps behind a web form. The following file describes the same data (name, owner, database) and three actions in sequence: generate the skeleton, publish the repository, register the catalog entry.

apiVersion: scaffolder.backstage.io/v1beta3
kind: Template
metadata:
  name: servizio-http
  title: HTTP service
  description: Creates a service with repository, pipeline and catalog entry
  tags:
    - recommended
spec:
  owner: group:default/platform-team
  type: service

  parameters:
    - title: Service details
      required: [name, owner]
      properties:
        name:
          title: Service name
          type: string
          pattern: "^[a-z][a-z0-9-]*$"
          description: Lowercase letters, digits and hyphens
        owner:
          title: Owner team
          type: string
          ui:field: OwnerPicker
    - title: Technical options
      properties:
        database:
          title: Database
          type: string
          enum: [postgresql, none]
          default: postgresql

  steps:
    - id: fetch
      name: Generate the skeleton
      action: fetch:template
      input:
        url: ./skeleton
        values:
          name: ${{ parameters.name }}
          owner: ${{ parameters.owner }}
          database: ${{ parameters.database }}

    - id: publish
      name: Publish the repository
      action: publish:github
      input:
        repoUrl: github.com?owner=example-org&repo=${{ parameters.name }}

    - id: register
      name: Register in the catalog
      action: catalog:register
      input:
        repoContentsUrl: ${{ steps.publish.output.repoContentsUrl }}
        catalogInfoPath: /catalog-info.yaml

  output:
    links:
      - title: Repository
        url: ${{ steps.publish.output.remoteUrl }}

Three points in the file deserve attention. The name parameter has a pattern that rejects invalid names already in the form, before the template starts. The owner field uses the OwnerPicker selector, which proposes the catalog's groups instead of a free field. In the steps, ${{ parameters.name }} inserts the form's value (the syntax is a subset of Nunjucks), while ${{ steps.publish.output.repoContentsUrl }} takes the output of the previous step: that is why the register step knows where to find the descriptor just published.

An installation warning from the documentation: the publish:github action is not among the included actions, but belongs to the @backstage/plugin-scaffolder-backend-module-github module, which must be added to the backend. repoUrl has the form github.com?owner=...&repo=... and example-org here is an invented name. Passing all the parameters to the skeleton (database included) is necessary for the skeleton to use them: a parameter that is declared and not passed has no effect.

To avoid finding out about errors at first use, the script below validates the template with the official validator templateEntityV1beta3Validator from the @backstage/plugin-scaffolder-common package, validates with componentEntityV1alpha1Validator the descriptor produced by Cookiecutter, and adds a check that the validator does not make: that every parameters.X used in the steps is declared. Below the script, two runs: the first on the correct files, the second on two copies spoiled by hand (a parameter written db instead of database and a descriptor without owner).

// Validates Backstage entities with the official validators of the catalog-model package
// and checks that every "parameters.X" reference of a template exists.
const fs = require("fs");
const path = require("path");
const mod = (n) => require(path.join(__dirname, "node_modules", n));
const YAML = mod("yaml");
const { componentEntityV1alpha1Validator: componente } = mod("@backstage/catalog-model");
const { templateEntityV1beta3Validator: template } = mod("@backstage/plugin-scaffolder-common");

async function valida(file) {
  const documenti = YAML.parseAllDocuments(fs.readFileSync(file, "utf8")).map((d) => d.toJS());
  for (const e of documenti) {
    const validatore = e.kind === "Template" ? template : componente;
    try {
      const ok = await validatore.check(e);
      console.log(`${file}: ${e.kind} ${e.metadata.name} -> ${ok ? "valid" : "kind not handled"}`);
    } catch (err) {
      console.log(`${file}: ${e.kind} ${e.metadata.name} -> NOT valid: ${err.message}`);
      process.exitCode = 1;
    }
    if (e.kind === "Template") {
      const dichiarati = new Set(e.spec.parameters.flatMap((p) => Object.keys(p.properties || {})));
      const usati = [...JSON.stringify(e.spec.steps).matchAll(/parameters\.(\w+)/g)].map((m) => m[1]);
      const mancanti = [...new Set(usati)].filter((n) => !dichiarati.has(n));
      console.log(`  declared parameters: ${[...dichiarati].join(", ")}; used but not declared: ${mancanti.join(", ") || "none"}`);
      if (mancanti.length) process.exitCode = 1;
    }
  }
}

(async () => { for (const f of process.argv.slice(2)) await valida(f); })();

// Output:
// $ node valida.cjs template-backstage.yaml generato/pagamenti/catalog-info.yaml
// template-backstage.yaml: Template servizio-http -> valid
//   declared parameters: name, owner, database; used but not declared: none
// generato/pagamenti/catalog-info.yaml: Component pagamenti -> valid
// (exit code: 0)
// $ node valida.cjs template-sbagliato.yaml catalog-sbagliato.yaml
// template-sbagliato.yaml: Template servizio-http -> valid
//   declared parameters: name, owner, database; used but not declared: db
// catalog-sbagliato.yaml: Component pagamenti -> NOT valid: /spec must have required property 'owner' - missingProperty: owner
// (exit code: 1)

The most instructive result is the second: the template with the wrong parameter is «valid» for the validator, because the schema checks the structure and not the content of the expressions, and it is the additional check that flags db. The descriptor without an owner, instead, is rejected by the schema itself with /spec must have required property 'owner'. The exit code 1 is what makes it possible to put the script in a pipeline. The packages used are @backstage/catalog-model 1.10.2 and @backstage/plugin-scaffolder-common 2.3.1, installed in a temporary folder.

Governing templates: a test that generates and checks

A template is software with users, so it is versioned and tested. Five practices separate a maintained template from an abandoned one:

  • Semantic versions: every release has a number, and incompatible changes are declared.
  • Review: whoever changes the template does so with a review by the platform team and by those who use it.
  • Generation test: the template is generated in a temporary folder and the result is checked.
  • Declared retirement: an obsolete template has a date and a migration path.
  • Change log: every version explains what changes and why.

The only item that can be fully automated is the third, and it is the most neglected. The following test generates the template with Cookiecutter, in a temporary folder, and checks that the five mandatory files exist, that no unresolved placeholders remain, that the descriptor reports the requested name and owner, that the pipeline's YAML is readable and that the code compiles. Run on a healthy template and on two spoiled ones (one without a descriptor and with a syntax error, one that uses a variable that was never declared), it reports everything.

"""Template conformance test: generates it and checks that the result is a healthy project."""
import py_compile
import re
import sys
import tempfile
from pathlib import Path

import yaml
from cookiecutter.exceptions import CookiecutterException
from cookiecutter.main import cookiecutter

OBBLIGATORI = ["README.md", "Dockerfile", "catalog-info.yaml", "app/main.py", ".github/workflows/ci.yml"]
RESIDUO = re.compile(r"\{\{|\{%")


def verifica(template, nome="pagamenti", owner="team-checkout", database="postgresql"):
    errori = []
    with tempfile.TemporaryDirectory() as tmp:
        try:
            cookiecutter(str(template), no_input=True, output_dir=tmp, extra_context={
                "service_name": nome, "owner": owner, "database": database})
        except CookiecutterException as e:
            return [f"generation failed: {type(e).__name__}"]
        radice = Path(tmp) / nome
        for rel in OBBLIGATORI:
            if not (radice / rel).is_file():
                errori.append(f"missing {rel}")
        for f in radice.rglob("*"):
            if f.is_file() and RESIDUO.search(f.read_text(encoding="utf-8")):
                errori.append(f"unresolved placeholder in {f.relative_to(radice)}")
        catalogo = radice / "catalog-info.yaml"
        if catalogo.is_file():
            c = yaml.safe_load(catalogo.read_text(encoding="utf-8"))
            if c["metadata"]["name"] != nome:
                errori.append("catalog-info: the name does not match the service")
            if c["spec"]["owner"] != owner:
                errori.append("catalog-info: owner not set")
        ci = radice / ".github/workflows/ci.yml"
        if ci.is_file():
            yaml.safe_load(ci.read_text(encoding="utf-8"))
        main = radice / "app/main.py"
        if main.is_file():
            try:
                py_compile.compile(str(main), doraise=True, cfile=str(Path(tmp) / "main.pyc"))
            except py_compile.PyCompileError:
                errori.append("app/main.py does not compile")
    return errori


if __name__ == "__main__":
    problemi = verifica(Path(sys.argv[1]))
    print("template conforming" if not problemi else "NOT conforming: " + "; ".join(problemi))
    sys.exit(1 if problemi else 0)

# Output:
# python3 verifica_template.py template
# template conforming
# (exit code: 0)
# python3 verifica_template.py template-rotto
# NOT conforming: missing catalog-info.yaml; app/main.py does not compile
# (exit code: 1)
# python3 verifica_template.py template-rotto2
# NOT conforming: generation failed: UndefinedVariableInTemplate
# (exit code: 1)

Note that Cookiecutter, by itself, stops with UndefinedVariableInTemplate if a file uses an undeclared variable: the test catches the exception and reports it as «not conforming» instead of being interrupted. This check avoids the worst case, a golden path that fails for everyone at first use, but it says nothing about the quality of the generated project.

One problem remains that no template test solves: projects that have already been generated are not updated when the template changes, because they are copies. A tool built on top of Cookiecutter exists, cruft, which makes it possible to check and apply a template's updates to an existing project (cruft check and cruft update); its repository, however, shows no commits since December 2024 (data read on 2026-11-13), so it must be evaluated before building a process on it. Alternatively the changes are published as automatic change requests, or you accept that the template only applies to new services.

Measuring adoption without imposing it

A golden path works if it is chosen. Four signals tell you so, and they are collected from the catalog and from the repository:

  • Share of new services created from the template, counted on the catalog (the generated descriptor carries the trace).
  • Time from first commit to first release in staging, for services born from the template.
  • Deviations and reasons: how many services are born outside the path, and what their authors say.
  • Average age of the template and date of the last update.

The figures you often see in presentations («adoption above 80%», «onboarding reduced by 80%») have no primary source and I do not report them. From the DORA 2024 report data the lesson is a different one: whoever is required to use the platform exclusively for the whole application lifecycle has a throughput lower by 6%, while the productivity gain (+5%) comes from developer independence. A frequent deviation is information about the path, not a fault of the teams. The metrics the platform must watch are in article 7 and the qualitative listening to developers in article 9.

Traps

Four recurring traps, all judgments from experience and not research results:

  • Mandatory, rigid path: turns the golden path into a bottleneck.
  • Template that ages: new projects start with dependencies that are already outdated.
  • Missing documentation: the skeleton works, but nobody knows why it is built that way.
  • A single template for everything: more options, more branches, more tests to write.

The rule I draw from them is of the same kind: the best golden path is the one developers choose because it is the most convenient. If using it takes a mandate, the defect lies in the path. It is the distinction in the CNCF maturity model between adoption «pushed from outside» and adoption «pulled by users».

What you can do now

You can define a golden path as a template with questions and default values, generate it without input and verify the result with a test that runs in a pipeline. You can describe the same path as a Backstage template, validate it with the official validators and check the parameter references as well. You can say which measures tell you whether the path is chosen, and which figures must not be reported without a source. The next article moves on to the portal that exposes them, Backstage.

Frequently asked questions

What is a golden path?

It is the opinionated and supported way to build something: for the CNCF a project template with its documentation, for Spotify the recommended way to use the company's tools. Whoever leaves it may do so, but loses the platform team's support.

Is a golden path mandatory?

No, and it is better that it is not. The DORA 2024 report finds a throughput lower by 6% for those who are required to use the platform exclusively for the whole application lifecycle. The path is made convenient, not mandatory.

How do you test a project template?

You generate it in a temporary folder and check the result: mandatory files present, no unresolved placeholders, consistent catalog descriptor, readable YAML, code that compiles. The article's script does it with Cookiecutter and also reports a failed generation.

What happens to projects already generated when the template changes?

They do not change: they are copies. You can use a tool like cruft to check and apply updates, publish automatic changes to the repositories, or accept that the template only applies to new services.

The same series also includes security with Zero Trust and policies, the IDP implementation plan and the comparison between on-prem and cloud AI.

Sources

The scripts were run twice with identical output (Python 3.14, Cookiecutter 2.7.1 and Jinja2 3.1.6 in a temporary virtual environment; Node 24 with the Backstage packages indicated). The generated Dockerfile, server and GitHub Actions pipeline were not run. Organization and team names are invented.

Commenti

Caricamento commenti...

Sign in to comment

Discuss this article in the community

Join the Telegram group to discuss with other developers, ask questions and share your experiences.

Did you like this article?

Explore other content on the blog or discover my projects