Back to Blog

    Custom Structure Templates: Building Your Own Rules from Scratch

    August 20, 2026Kenny Belitzky
    Custom Structure Templates: Building Your Own Rules from Scratch

    The bundled structures are a demonstration, not a destination. project/python and terraform/modules/generic show you what a definition looks like and get you generating in the first five minutes, but no organization's conventions are going to match a generic template. The value shows up when the definitions encode your decisions — your CI, your ownership model, your Terraform module layout.

    This is a guide to building that: authoring definitions, composing them, distributing them across an organization, and pinning them so a generation run means the same thing in six months.

    The anatomy of a definition

    A structure is a YAML file. The top-level keys StructKit understands are files:, folders:, variables:, pre_hooks:, post_hooks:, and sources:.

    description: >
      Standard Python service: src layout, pytest, ruff, Dockerfile,
      and the platform CI workflow.
    
    files:
      - pyproject.toml:
          content: |
            [project]
            name = "{{@ service_name | slugify @}}"
            version = "0.1.0"
            requires-python = ">={{@ python_version @}}"
    
            [tool.ruff]
            line-length = 100
      - src/{{@ service_name | slugify | replace("-", "_") @}}/__init__.py:
          content: |
            __version__ = "0.1.0"
      - tests/test_smoke.py:
          content: |
            def test_imports():
                import {{@ service_name | slugify | replace("-", "_") @}}  # noqa
      - .gitignore:
          file: github://github/gitignore/main/Python.gitignore
      - README.md:
          skip_if_exists: true
          content: |
            # {{@ service_name @}}
    
            Owned by @acme/{{@ owning_team @}}.
    
    variables:
      - service_name:
          description: "Human-readable service name"
          type: string
      - owning_team:
          description: "GitHub team that owns this service"
          type: string
      - python_version:
          description: "Minimum Python version"
          type: string
          default: "3.11"

    Three things in there are worth calling out.

    Variables without a default are prompts. service_name and owning_team have no default, so StructKit asks for them interactively. python_version has one, so it only asks if you want to override. This is how you distinguish "must be decided per project" from "we have an opinion, override if you must." In CI, set STRUCTKIT_NON_INTERACTIVE=true and supply values with -v.

    Paths are templated. src/{{@ service_name | slugify | replace("-", "_") @}}/__init__.py — filenames and directory names go through the same rendering as content, so the package directory follows the service name.

    skip_if_exists marks ownership. The README belongs to the team that owns the service; the .gitignore belongs to the definition. That distinction, made file by file, is what makes a definition safe to re-run against a repository that already exists.

    Delimiters

    StructKit uses Jinja2 with {{@ ... @}} for expressions, {%@ ... @%} for blocks, and {#@ ... @#} for comments.

    The non-standard delimiters exist because scaffolding tools generate files that contain {{ }} for their own reasons. Writing a GitHub Actions workflow, a Helm chart, or a Go template through a tool using standard Jinja2 delimiters means escaping everything. Here you do not:

    files:
      - .github/workflows/deploy.yml:
          content: |
            name: deploy-{{@ service_name | slugify @}}
            on:
              push:
                branches: [main]
            jobs:
              deploy:
                runs-on: ubuntu-latest
                steps:
                  - uses: actions/checkout@v5
                  - env:
                      TOKEN: ${{ secrets.DEPLOY_TOKEN }}
                    run: ./deploy.sh

    ${{ secrets.DEPLOY_TOKEN }} passes through untouched while {{@ service_name @}} renders. No escaping, no raw blocks.

    Alongside the standard Jinja2 filters, StructKit adds slugify (optional separator argument), default_branch (a GitHub repository's default branch), and latest_release (newest release tag, with optional strip_v and return_sha). Two variables are always available: file_name and file_directory.

    Compose small definitions rather than writing large ones

    The instinct when building templates is one big file per project type. It works until you have five of them and need to change the CI workflow in all five.

    folders: lets a definition pull in others, so the better unit is small and single-purpose:

    folders:
      - ./:
          struct:
            - acme/base/repo-hygiene      # LICENSE, CODEOWNERS, SECURITY.md
            - acme/ci/python              # the CI workflow
            - configs/editor-config       # bundled
            - docker-files                # bundled
          with:
            owning_team: "{{@ owning_team @}}"
      - infrastructure/:
          struct: terraform/modules/generic
          with:
            module_name: "{{@ service_name | slugify @}}"

    Now acme/ci/python is one file that every Python service composes. Changing it changes what every service gets on next generation. The with: clause passes variables down to the nested structure.

    A suggested repository layout for your own definitions:

    structures/
    ├── base/
    │   ├── repo-hygiene.yaml
    │   └── security.yaml
    ├── ci/
    │   ├── python.yaml
    │   └── go.yaml
    └── service/
        ├── python.yaml
        └── go.yaml

    Point StructKit at it with -s:

    structkit generate -s ~/acme/platform-structures/structures \
      service/python ./billing-api

    Or set STRUCTKIT_STRUCTURES_PATH once and drop the flag.

    Composition has a failure mode worth knowing about: a definition that references a structure that does not exist, or two that reference each other. graph_structure over MCP will report nested dependencies, missing references, and cycles — useful once your definition tree is more than a few levels deep.

    Named sources

    Passing -s with a path works on your machine. Across a team it does not — everyone needs the definitions checked out somewhere, and "somewhere" differs per person.

    Named sources fix this:

    structkit sources add company ./templates
    structkit sources add platform httpdss/platform-structures
    structkit sources add versioned github://httpdss/platform-structures@v1/structures
    structkit sources list
    structkit sources show platform
    structkit sources validate platform
    structkit sources remove company

    A source can be a local directory, GitHub shorthand (owner/repo), a github:// URL with an optional @ref and subdirectory, or a git URL over HTTPS, SSH, or file://. For branch names containing slashes, the positional @ref form cannot express them — use the query form:

    structkit sources add release_branch \
      github://httpdss/platform-structures/structures?ref=release/1.x

    Use a source by flag or by prefix:

    structkit list --source company
    structkit generate --source company project/python ./app
    structkit generate company/project/python ./app

    Sources live in $XDG_CONFIG_HOME/structkit/sources.yaml or ~/.config/structkit/sources.yaml; override with STRUCTKIT_SOURCES_CONFIG or structkit sources --config-path. Git-backed sources are cloned into $XDG_CACHE_HOME/structkit/sources or ~/.cache/structkit/sources — override with STRUCTKIT_SOURCES_CACHE. StructKit runs git fetch when a git-backed source is resolved or validated. Branch refs check out from origin/<branch>; tags and commit SHAs check out detached, so they never git pull.

    Resolution precedence, highest first:

    1. --structures-path (or STRUCTKIT_STRUCTURES_PATH, which populates the same option)
    2. --source, or a <source>/<structure> prefix
    3. Built-in StructKit structures

    File-local sources: the pattern to actually use

    Named sources are user-level configuration, which means a generation run depends on how each person's machine is set up. For anything committed to a repository, that is the wrong property.

    A .struct.yaml can declare its own sources:

    sources:
      platform:
        url: github://acme/platform-structures@v1.2.0/structures
    
    folders:
      - ./:
          struct: platform/service/python
          with:
            service_name: billing-api
            owning_team: payments

    Now the repository is self-describing. A new engineer, a CI runner, or an AI assistant runs structkit generate and gets an identical result, from an explicitly pinned version. Nothing depends on local configuration.

    Prefer tags or commit SHAs over branches. A definition pinned to @main produces different output week to week, which defeats the purpose.

    File-local sources override user-level sources of the same name at the top level. Nested structures inherit the parent's source context, and a nested struct cannot silently redefine an inherited name to a different URL — a guard against a deep dependency quietly swapping which version of a template you get. When you genuinely need two versions, name them separately:

    sources:
      platform_v1:
        url: github://acme/platform-structures@v1/structures
      platform_v2:
        url: github://acme/platform-structures@v2/structures
    
    folders:
      - ./:
          struct:
            - platform_v1/python/service
            - platform_v2/github/actions-ci

    Unqualified nested structs resolve from the current structures path. If platform/python/service references github/actions-ci, that child comes from the same pinned platform source unless it names another one.

    You can also reference a remote structure inline, without declaring a source:

    folders:
      - ./:
          struct: github://acme/platform-structures@v1.2.0/structures/python/service

    Mappings for data that is not a variable

    Variables are for per-project decisions. Mappings are for shared reference data — account IDs, team contacts, per-environment settings — that many definitions read and nobody should retype.

    mappings:
      teams:
        platform:
          email: platform@acme.com
          slack: "#platform-team"
      environments:
        prod:
          namespace: acme-prod
          replicas: 3

    Reference it as mappings.*, including dynamic keys:

    files:
      - k8s/deployment.yaml:
          content: |
            metadata:
              namespace: {{@ mappings.environments[target_env].namespace @}}
            spec:
              replicas: {{@ mappings.environments[target_env].replicas @}}

    Pass one or more files, merged in order with later files overriding earlier ones and deep-merging nested dictionaries:

    structkit generate \
      --mappings-file ./mappings/common.yaml \
      --mappings-file ./mappings/${ENVIRONMENT}.yaml \
      .struct.yaml ./output

    A missing key is an error, so use default for anything genuinely optional: {{@ mappings.team.devops | default "unassigned" @}}.

    Testing definitions before they reach anyone

    Templates are code. Treat them accordingly.

    # Is the YAML valid?
    structkit validate structures/service/python.yaml
    
    # What variables does it want?
    structkit info service/python
    
    # What would it produce? Nothing is written.
    structkit generate -d --diff -s ./structures service/python /tmp/out
    
    # Render to stdout instead of disk
    structkit generate -o console -s ./structures service/python /tmp/out

    -o console is particularly good in a pull request pipeline: a reviewer sees the rendered output of a template change rather than having to simulate it mentally.

    Wire it into CI for the definitions repository itself:

    - run: pip install structkit==3.2.1
    - run: find structures -name '*.yaml' | xargs -n1 structkit validate
    - name: Smoke-generate every service template
      env:
        STRUCTKIT_NON_INTERACTIVE: "true"
      run: |
        for s in service/python service/go; do
          structkit generate -s ./structures -d --diff \
            -v "service_name=test,owning_team=platform" "$s" /tmp/smoke
        done

    Then tag the repository. Consumers pin to tags, so a tag is your release boundary — and bumping a service from @v1.2.0 to @v1.3.0 becomes a visible, reviewable act rather than something that happens to a team overnight.

    Wrap-up

    Custom definitions are where StructKit stops being a scaffolding tool and starts being the place your conventions live. Keep them small and composable, keep them in one versioned repository, pin them from the consuming .struct.yaml with a file-local source, and put the same validation in front of them that you would put in front of any other code your organization depends on.

    See Custom Structures for sources in depth, Template Variables for filters, Mappings for external data, and Schema Reference for IDE validation of definitions as you write them.