Skip to content

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:

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.

A Brokkr template has two parts:

  1. Template content — Kubernetes YAML with Tera placeholders (e.g., {{ replicas }}, {{ image_tag }})
  2. 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.

Create a template for a standard web service deployment:

Terminal window
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:

Terminal window
TEMPLATE_ID="t1234567-..." # use the actual ID from the response

Because 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.

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:

Terminal window
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 stack
STACK_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 template
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 '{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.

Fetch the deployment object to see the rendered YAML:

Terminal window
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: v1
kind: Namespace
metadata:
name: frontend-app
---
apiVersion: apps/v1
kind: Deployment
metadata:
name: frontend
namespace: frontend-app
labels:
app: frontend
managed-by: brokkr
spec:
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: v1
kind: Service
metadata:
name: frontend
namespace: frontend-app
spec:
selector:
app: frontend
ports:
- port: 80
targetPort: 3000

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

Terminal window
# Create a backend stack
BACKEND_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 parameters
curl -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.

Templates are versioned. Updating a template creates a new version while preserving the old one:

Terminal window
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:

Terminal window
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:

Terminal window
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:

Terminal window
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.

Try instantiating with invalid parameters to see validation:

Terminal window
# 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.

Terminal window
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.

  • 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.