Tutorial: Standardized Deployments with Templates
In this tutorial, you’ll create a reusable deployment template with parameterized values and JSON Schema validation, then instantiate it across multiple stacks. Templates eliminate YAML duplication and enforce consistency.
What you’ll learn:
- How to create a template with Tera syntax
- How to define a parameter schema using JSON Schema
- How to instantiate a template into a stack
- How template versioning works, and why an update hands you a new template ID
- How template targeting restricts which stacks can use a template
Prerequisites:
- A broker you can reach at
http://localhost:3000— either a Helm install withkubectl port-forward svc/brokkr-broker 3000:3000running, or the local development environment (angreal local up) - Your admin PAK for that broker (see Adapting the commands to your install)
curlandjqinstalled- Recommended: Deploy Your First Application, for stacks and deployment objects. Nothing here depends on the resources it created.
No agent is required. Templates are validated and rendered by the broker, and this tutorial stops at the deployment object it produces — it never targets an agent, so nothing reaches a cluster.
Step 1: Understand the Template Concept
Section titled “Step 1: Understand the Template Concept”A Brokkr template has two parts:
- Template content — Kubernetes YAML with Tera placeholders (e.g.,
{{ replicas }},{{ image_tag }}) - Parameters schema — a JSON Schema that defines which parameters exist, their types, defaults, and constraints
When you instantiate a template into a stack, you provide parameter values. Brokkr validates them against the schema, renders the Tera template, and creates a deployment object with the resulting YAML.
Step 2: Create a Template
Section titled “Step 2: Create a Template”Create a template for a standard web service deployment:
curl -s -X POST http://localhost:3000/api/v1/templates \ -H "Authorization: Bearer <your-admin-pak>" \ -H "Content-Type: application/json" \ -d '{ "name": "web-service", "description": "Standard web service with configurable replicas, image, and resource limits", "template_content": "apiVersion: v1\nkind: Namespace\nmetadata:\n name: {{ namespace }}\n---\napiVersion: apps/v1\nkind: Deployment\nmetadata:\n name: {{ service_name }}\n namespace: {{ namespace }}\n labels:\n app: {{ service_name }}\n managed-by: brokkr\nspec:\n replicas: {{ replicas }}\n selector:\n matchLabels:\n app: {{ service_name }}\n template:\n metadata:\n labels:\n app: {{ service_name }}\n spec:\n containers:\n - name: {{ service_name }}\n image: {{ image_repository }}:{{ image_tag }}\n ports:\n - containerPort: {{ container_port }}\n resources:\n requests:\n cpu: {{ cpu_request }}\n memory: {{ memory_request }}\n limits:\n cpu: {{ cpu_limit }}\n memory: {{ memory_limit }}\n---\napiVersion: v1\nkind: Service\nmetadata:\n name: {{ service_name }}\n namespace: {{ namespace }}\nspec:\n selector:\n app: {{ service_name }}\n ports:\n - port: 80\n targetPort: {{ container_port }}", "parameters_schema": "{\"type\": \"object\", \"required\": [\"service_name\", \"namespace\", \"image_repository\", \"image_tag\"], \"properties\": {\"service_name\": {\"type\": \"string\", \"description\": \"Name of the service\", \"minLength\": 1, \"maxLength\": 63}, \"namespace\": {\"type\": \"string\", \"description\": \"Kubernetes namespace\", \"minLength\": 1}, \"image_repository\": {\"type\": \"string\", \"description\": \"Container image repository\"}, \"image_tag\": {\"type\": \"string\", \"description\": \"Container image tag\", \"default\": \"latest\"}, \"replicas\": {\"type\": \"integer\", \"description\": \"Number of replicas\", \"default\": 2, \"minimum\": 1, \"maximum\": 20}, \"container_port\": {\"type\": \"integer\", \"description\": \"Container port\", \"default\": 8080}, \"cpu_request\": {\"type\": \"string\", \"description\": \"CPU request\", \"default\": \"100m\"}, \"memory_request\": {\"type\": \"string\", \"description\": \"Memory request\", \"default\": \"128Mi\"}, \"cpu_limit\": {\"type\": \"string\", \"description\": \"CPU limit\", \"default\": \"500m\"}, \"memory_limit\": {\"type\": \"string\", \"description\": \"Memory limit\", \"default\": \"256Mi\"}}}" }' | jq '{id, name, version, checksum}'The response shows the template was created with version: 1:
{ "id": "t1234567-...", "name": "web-service", "version": 1, "checksum": "abc123..."}Save the template ID:
TEMPLATE_ID="t1234567-..." # use the actual ID from the responseBecause you created this with an admin PAK, it is a system template: its generator_id is null and every generator can instantiate it. A template created with a generator PAK belongs to that generator, and no other generator can read or instantiate it — system templates are how a template gets shared across teams.
Step 3: Understand the Parameters Schema
Section titled “Step 3: Understand the Parameters Schema”The JSON Schema you provided defines 10 parameters: four required (service_name, namespace, image_repository, image_tag) and six more with documented defaults. The schema enforces constraints — for example, replicas must be between 1 and 20, and service_name must be 1-63 characters. One important nuance: schema default values are documentation and validation metadata only — the renderer does not inject them. Every variable the template body references must be supplied at instantiation (or the template must use Tera’s default filter, e.g. {{ replicas | default(value=2) }}).
The schema ensures that callers provide the required values and that constraints are enforced at instantiation time, before any YAML is rendered. See the Templates Reference for the full JSON Schema syntax guide.
Step 4: Create a Stack and Instantiate the Template
Section titled “Step 4: Create a Stack and Instantiate the Template”Create a stack, then instantiate the template into it. As in the earlier tutorials, stacks need an owning generator — look up the admin-generator the broker created at first startup:
GEN_ID=$(curl -s http://localhost:3000/api/v1/generators \ -H "Authorization: Bearer <your-admin-pak>" \ | jq -r '.[] | select(.name=="admin-generator") | .id')
# Create the stackSTACK_ID=$(curl -s -X POST http://localhost:3000/api/v1/stacks \ -H "Authorization: Bearer <your-admin-pak>" \ -H "Content-Type: application/json" \ -d "{\"name\": \"frontend-app\", \"description\": \"Frontend web application\", \"generator_id\": \"${GEN_ID}\"}" \ | jq -r '.id')
# Instantiate the templatecurl -s -X POST "http://localhost:3000/api/v1/stacks/${STACK_ID}/deployment-objects/from-template" \ -H "Authorization: Bearer <your-admin-pak>" \ -H "Content-Type: application/json" \ -d "{ \"template_id\": \"${TEMPLATE_ID}\", \"parameters\": { \"service_name\": \"frontend\", \"namespace\": \"frontend-app\", \"image_repository\": \"myregistry.example.com/frontend\", \"image_tag\": \"v2.1.0\", \"replicas\": 3, \"container_port\": 3000, \"cpu_request\": \"100m\", \"memory_request\": \"128Mi\", \"cpu_limit\": \"500m\", \"memory_limit\": \"512Mi\" } }" | jq '{id, sequence_id, yaml_checksum}'Brokkr validated the parameters, rendered the Tera template, and created a deployment object. The resulting YAML has all placeholders replaced with actual values.
Step 5: Verify the Rendered Output
Section titled “Step 5: Verify the Rendered Output”Fetch the deployment object to see the rendered YAML:
DO_ID=$(curl -s "http://localhost:3000/api/v1/stacks/${STACK_ID}/deployment-objects" \ -H "Authorization: Bearer <your-admin-pak>" | jq -r '.[0].id')
curl -s "http://localhost:3000/api/v1/deployment-objects/${DO_ID}" \ -H "Authorization: Bearer <your-admin-pak>" | jq -r '.yaml_content'You’ll see fully-rendered Kubernetes YAML with all template variables replaced:
apiVersion: v1kind: Namespacemetadata: name: frontend-app---apiVersion: apps/v1kind: Deploymentmetadata: name: frontend namespace: frontend-app labels: app: frontend managed-by: brokkrspec: replicas: 3 selector: matchLabels: app: frontend template: metadata: labels: app: frontend spec: containers: - name: frontend image: myregistry.example.com/frontend:v2.1.0 ports: - containerPort: 3000 resources: requests: cpu: 100m memory: 128Mi limits: cpu: 500m memory: 512Mi---apiVersion: v1kind: Servicemetadata: name: frontend namespace: frontend-appspec: selector: app: frontend ports: - port: 80 targetPort: 3000Step 6: Re-use the Template for Another Service
Section titled “Step 6: Re-use the Template for Another Service”The same template works for a different service by changing the parameters:
# Create a backend stackBACKEND_STACK=$(curl -s -X POST http://localhost:3000/api/v1/stacks \ -H "Authorization: Bearer <your-admin-pak>" \ -H "Content-Type: application/json" \ -d "{\"name\": \"backend-api\", \"description\": \"Backend API service\", \"generator_id\": \"${GEN_ID}\"}" \ | jq -r '.id')
# Instantiate with different parameterscurl -s -X POST "http://localhost:3000/api/v1/stacks/${BACKEND_STACK}/deployment-objects/from-template" \ -H "Authorization: Bearer <your-admin-pak>" \ -H "Content-Type: application/json" \ -d "{ \"template_id\": \"${TEMPLATE_ID}\", \"parameters\": { \"service_name\": \"api\", \"namespace\": \"backend-api\", \"image_repository\": \"myregistry.example.com/api\", \"image_tag\": \"v3.0.1\", \"replicas\": 5, \"container_port\": 8080, \"cpu_request\": \"250m\", \"memory_request\": \"256Mi\", \"cpu_limit\": \"1000m\", \"memory_limit\": \"1Gi\" } }" | jq '{id, sequence_id}'One template, multiple services, each with appropriate configuration.
Step 7: Update the Template (Versioning)
Section titled “Step 7: Update the Template (Versioning)”Templates are versioned. Updating a template creates a new version while preserving the old one:
UPDATED=$(curl -s -X PUT "http://localhost:3000/api/v1/templates/${TEMPLATE_ID}" \ -H "Authorization: Bearer <your-admin-pak>" \ -H "Content-Type: application/json" \ -d '{ "description": "Standard web service v2 - adds liveness probe", "template_content": "apiVersion: v1\nkind: Namespace\nmetadata:\n name: {{ namespace }}\n---\napiVersion: apps/v1\nkind: Deployment\nmetadata:\n name: {{ service_name }}\n namespace: {{ namespace }}\n labels:\n app: {{ service_name }}\n managed-by: brokkr\nspec:\n replicas: {{ replicas }}\n selector:\n matchLabels:\n app: {{ service_name }}\n template:\n metadata:\n labels:\n app: {{ service_name }}\n spec:\n containers:\n - name: {{ service_name }}\n image: {{ image_repository }}:{{ image_tag }}\n ports:\n - containerPort: {{ container_port }}\n livenessProbe:\n httpGet:\n path: /healthz\n port: {{ container_port }}\n initialDelaySeconds: 10\n periodSeconds: 30\n resources:\n requests:\n cpu: {{ cpu_request }}\n memory: {{ memory_request }}\n limits:\n cpu: {{ cpu_limit }}\n memory: {{ memory_limit }}\n---\napiVersion: v1\nkind: Service\nmetadata:\n name: {{ service_name }}\n namespace: {{ namespace }}\nspec:\n selector:\n app: {{ service_name }}\n ports:\n - port: 80\n targetPort: {{ container_port }}", "parameters_schema": "{\"type\": \"object\", \"required\": [\"service_name\", \"namespace\", \"image_repository\", \"image_tag\"], \"properties\": {\"service_name\": {\"type\": \"string\", \"minLength\": 1, \"maxLength\": 63}, \"namespace\": {\"type\": \"string\", \"minLength\": 1}, \"image_repository\": {\"type\": \"string\"}, \"image_tag\": {\"type\": \"string\", \"default\": \"latest\"}, \"replicas\": {\"type\": \"integer\", \"default\": 2, \"minimum\": 1, \"maximum\": 20}, \"container_port\": {\"type\": \"integer\", \"default\": 8080}, \"cpu_request\": {\"type\": \"string\", \"default\": \"100m\"}, \"memory_request\": {\"type\": \"string\", \"default\": \"128Mi\"}, \"cpu_limit\": {\"type\": \"string\", \"default\": \"500m\"}, \"memory_limit\": {\"type\": \"string\", \"default\": \"256Mi\"}}}" }' | jq '{id, name, version}')TEMPLATE_V1_ID=${TEMPLATE_ID}TEMPLATE_ID=$(echo "${UPDATED}" | jq -r '.id')echo "${UPDATED}"The response shows version: 2 — and a new template ID. Updating a template inserts a new versioned row; the old ID keeps pointing at version 1. Instantiation always uses the exact template ID you reference, so capture the new ID (done above) to use version 2; deployment objects already rendered from version 1 are unaffected. There is no “latest” shortcut: anything that keeps sending the old ID keeps deploying version 1, silently.
Step 8: Target the Template at Specific Stacks
Section titled “Step 8: Target the Template at Specific Stacks”Labels restrict which stacks a template may be instantiated into. Right now version 2 has none, so it goes anywhere. Add one:
curl -s -X POST "http://localhost:3000/api/v1/templates/${TEMPLATE_ID}/labels" \ -H "Authorization: Bearer <your-admin-pak>" \ -H "Content-Type: application/json" \ -d '"env:production"' | jq '{label}'Now instantiate version 2 into the frontend-app stack, which carries no labels:
curl -s -X POST "http://localhost:3000/api/v1/stacks/${STACK_ID}/deployment-objects/from-template" \ -H "Authorization: Bearer <your-admin-pak>" \ -H "Content-Type: application/json" \ -d "{ \"template_id\": \"${TEMPLATE_ID}\", \"parameters\": { \"service_name\": \"frontend\", \"namespace\": \"frontend-app\", \"image_repository\": \"myregistry.example.com/frontend\", \"image_tag\": \"v2.1.0\", \"replicas\": 3, \"container_port\": 3000, \"cpu_request\": \"100m\", \"memory_request\": \"128Mi\", \"cpu_limit\": \"500m\", \"memory_limit\": \"512Mi\" } }" | jq .The request is refused with template_stack_mismatch, and the response names the label the stack is missing. Give the stack that label and retry:
curl -s -X POST "http://localhost:3000/api/v1/stacks/${STACK_ID}/labels" \ -H "Authorization: Bearer <your-admin-pak>" \ -H "Content-Type: application/json" \ -d '"env:production"' | jq '{label}'Re-run the instantiation command above and it succeeds.
Two things to take away. First, labels are attached to one template version, not to the template name — the label you just added lives on version 2 only. Second, a version with no labels matches every stack, so the next update starts unrestricted: PUT gives you a fresh row with no labels, and your targeting quietly disappears until you re-apply it to the new ID. Re-applying labels is part of publishing a new version.
Step 9: Schema Validation in Action
Section titled “Step 9: Schema Validation in Action”Try instantiating with invalid parameters to see validation:
# Missing required field (service_name)curl -s -X POST "http://localhost:3000/api/v1/stacks/${STACK_ID}/deployment-objects/from-template" \ -H "Authorization: Bearer <your-admin-pak>" \ -H "Content-Type: application/json" \ -d "{ \"template_id\": \"${TEMPLATE_ID}\", \"parameters\": { \"namespace\": \"test\", \"image_repository\": \"nginx\", \"image_tag\": \"latest\" } }" | jq .
# Replicas out of range (max is 20)curl -s -X POST "http://localhost:3000/api/v1/stacks/${STACK_ID}/deployment-objects/from-template" \ -H "Authorization: Bearer <your-admin-pak>" \ -H "Content-Type: application/json" \ -d "{ \"template_id\": \"${TEMPLATE_ID}\", \"parameters\": { \"service_name\": \"test\", \"namespace\": \"test\", \"image_repository\": \"nginx\", \"image_tag\": \"latest\", \"replicas\": 100 } }" | jq .Both requests return validation errors, preventing invalid YAML from reaching your clusters.
Clean Up
Section titled “Clean Up”curl -s -X DELETE "http://localhost:3000/api/v1/stacks/${STACK_ID}" \ -H "Authorization: Bearer <your-admin-pak>"curl -s -X DELETE "http://localhost:3000/api/v1/stacks/${BACKEND_STACK}" \ -H "Authorization: Bearer <your-admin-pak>"curl -s -X DELETE "http://localhost:3000/api/v1/templates/${TEMPLATE_ID}" \ -H "Authorization: Bearer <your-admin-pak>"curl -s -X DELETE "http://localhost:3000/api/v1/templates/${TEMPLATE_V1_ID}" \ -H "Authorization: Bearer <your-admin-pak>"Both template versions are deleted — each version is its own row with its own ID.
What You’ve Learned
Section titled “What You’ve Learned”- Templates combine Tera-syntax YAML with JSON Schema parameter validation
- Instantiation checks access and stack matching, validates parameters, renders the template, and creates a deployment object
- Versioning preserves old versions: each version is its own row with its own ID, and instantiation renders the exact ID you reference — updating means picking up the new ID
- Targeting with labels restricts a template to matching stacks, and applies to a single version — re-apply labels after every update
- JSON Schema enforces types, required fields, ranges, and string constraints
- Templates reduce duplication — one template serves many stacks with different parameters
For the complete Tera template syntax (conditionals, loops, filters) and JSON Schema reference, see the Templates Reference.
Next Steps
Section titled “Next Steps”- Using Stack Templates — detailed how-to guide for template workflows
- Templates Reference — complete API reference for templates
- Core Concepts — how templates fit into the Brokkr architecture