Variable Interpolation and Cross-Component Wiring in bunnyshell.yaml
GuideSeptember 29, 202612 min read

Variable Interpolation and Cross-Component Wiring in bunnyshell.yaml

The Short Version

A single bunnyshell.yaml template needs to work correctly for every environment created from it, even though each environment gets its own generated namespace, its own unique identifier, its own component hostnames, and potentially its own variable overrides. Hardcoding any of these values would make the template correct for exactly one environment and broken for every other one created from it. Interpolation, the {{ expression }} syntax used throughout the file, is what lets the same template describe a pattern rather than a fixed set of values, resolved fresh and correctly for each environment at the moment it actually deploys.

The Variable Scope Hierarchy

Before wiring components together, it matters where a given variable's value actually comes from, since the same variable name can exist at more than one scope simultaneously, and the platform resolves a strict priority order when that happens:

Text
1Project Variables        (lowest priority: organization- or project-wide defaults)
2  ↓ overridden by
3Environment Variables    (set per environment, in environmentVariables)
4  ↓ overridden by
5Environment Variable Groups  (must be explicitly included by a component to apply)
6  ↓ overridden by
7Component Variables      (highest priority: set on one specific component)

This ordering is what lets a project-wide default (a shared logging level, a default region) live in one place while a single component, or a single environment, overrides it without needing to touch or duplicate the default itself. A component-level override always wins over anything broader, which is the behavior that makes "override just this one thing for just this one component" a one-line change instead of a template-wide restructuring.

Secrets: SECRET[] and ENCRYPTED[]

Secret values use a distinct syntax from plain variables, and understanding the lifecycle of that syntax matters for reading a template correctly:

YAML
1environmentVariables:
2  DB_PASSWORD: SECRET['a-plaintext-value-only-at-authoring-time']
3  API_KEY: SECRET["value with spaces or, commas need quotes"]

SECRET["value"] is the authoring-time syntax: a plain-text value written once, when creating or editing the template. Quoting matters here specifically; a plain single word needs no quotes, but any value containing a comma, a space, or a quote character needs to be quoted to parse correctly. The moment the template is saved, the platform encrypts the value and the stored definition shows ENCRYPTED[...] in its place, meaning nobody, including someone with read access to the saved template, sees the original plain-text value again; only the encrypted form persists. SECRET[] and ENCRYPTED[] are mutually exclusive and should never be nested; writing SECRET[] is how a human or a script provides a new value, ENCRYPTED[] is how the platform represents an already-stored one.

For piping a value into the encryption step directly via CLI rather than through the template editor, echo -n (not bare echo) is required to avoid a trailing newline silently corrupting the encrypted value:

Bash
$ echo -n "my-secret-value" | bns secrets encrypt
ENCRYPTED[ckXzb+vOS9ebl6M+uYcYJnjrCWg6ZSUEjxgELT6/SRI3d18a4dGCBLXIypJ+UjnilZEybndubHQ=]

See Encrypting and Decrypting Secrets via CLI for the full workflow this fits into, including decryption for local debugging.

The Interpolation Values Available

Inside {{ }}, a fixed set of values is available, each resolved against the specific environment being deployed:

YAML
1{{ org.name }}                          # Organization name
2{{ org.unique }}                        # Organization's unique identifier
3{{ project.name }}                      # Project name
4{{ template.vars.VAR_NAME }}            # A variable defined on the template itself
5{{ env.unique }}                        # e.g. "qmrxve", generated per environment
6{{ env.name }}                          # Environment name
7{{ env.type }}                          # "primary" or "ephemeral"
8{{ env.base_domain }}                   # Shared domain for auto-generated hostnames
9{{ env.urlHandle }}                     # URL-safe environment handle
10{{ env.k8s.namespace }}                 # The environment's Kubernetes namespace
11{{ env.k8s.version }}                   # e.g. "1.33"
12{{ env.vars.VAR_NAME }}                 # An environment-level variable
13{{ env.varGroups.GROUP.VAR_NAME }}      # A variable from an included variable group
14{{ env.cluster.displayName }}           # Human-readable cluster name
15{{ env.cluster.cloud }}                 # "aws", "azure", "gcp", etc.
16{{ components.NAME.name }}              # Another component's actual name
17{{ components.NAME.gitBranch }}         # That component's git branch
18{{ components.NAME.image }}             # Full built image reference
19{{ components.NAME.imageName }}         # Just the repository path
20{{ components.NAME.imageTag }}          # Just the tag
21{{ components.NAME.exported.VAR }}      # A variable that component explicitly exported
22{{ components.NAME.ingress.hosts[0].url }}  # That component's first host's full URL
23{{ component.name }}                    # Self-reference, inside the component's own block
24{{ component.gitBranch }}               # Self-reference to its own branch

The distinction between component.X (singular, self-referencing inside a component's own configuration block) and components.NAME.X (plural, referencing a different, named component) is a common early mistake; using component.image from inside a different component's block resolves to nothing useful, since self-reference syntax only applies within the block describing that specific component.

Filters: Transforming a Value Inline

A value can be piped through one or more filters before use, which covers a surprising amount of what would otherwise need a small script:

YAML
1{{ "Hello World" | lower }}                        # hello world
2{{ "hello world" | title }}                         # Hello World
3{{ "Hello World" | slug }}                          # hello-world
4{{ "  padded  " | trim }}                            # padded
5{{ undefined_var | default('N/A') }}                 # N/A
6{{ "a b" | url_encode }}                             # a%20b
7{{ [1, 2] | merge([3, 4]) }}                         # [1, 2, 3, 4]
8{{ {"a": 1} | merge({"b": 2}) }}                     # {"a": 1, "b": 2}
9{{ "now" | date('Y-m-d') }}                          # 2026-09-29
10{{ "now" | date_modify('+1 day') | date('Y-m-d') }}  # 2026-09-30

slug is worth calling out specifically since it shows up often in practice: turning a human-readable name or branch name into something safe to use as a Kubernetes resource name or a URL path segment, which have much stricter character rules than a display string does.

YAML
hosts:
  - hostname: '{{ component.gitBranch | slug }}-{{ env.base_domain }}'

A branch named feature/new-checkout-flow becomes feature-new-checkout-flow after the slug filter, safe as a DNS label, whereas the raw branch name with its slash would not be.

Conditional Logic

{% if %} blocks let a single template branch its output based on runtime values, most commonly the environment type:

YAML
1deploy:
2  - |
3    cat << EOF > values.yaml
4    replicas: {% if env.type == "ephemeral" %}1{% else %}3{% endif %}
5    resources:
6      requests:
7        memory: {% if env.type == "ephemeral" %}256Mi{% else %}512Mi{% endif %}
8    EOF

This single template produces a lightweight, single-replica configuration for every throwaway PR environment while a long-lived primary or staging environment gets the fuller, more production-like sizing, without maintaining two separate templates for what is otherwise the identical stack.

Environment Variable Groups

Groups exist specifically to organize related variables together and avoid repeating the same values across multiple components:

YAML
1environmentVariablesGroups:
2  mysql:
3    DB_PASSWORD: SECRET['secret']
4    MYSQL_DATABASE: my_app_db
5
6environmentVariables:
7  BASE_DOMAIN: my-app.example.com
8
9components:
10  - kind: Database
11    name: mysql
12    dockerCompose:
13      environment:
14        MYSQL_ROOT_PASSWORD: '{{ env.varGroups.mysql.DB_PASSWORD }}'
15
16  - kind: Application
17    name: app
18    dockerCompose:
19      environment:
20        DB_PASSWORD: '{{ env.varGroups.mysql.DB_PASSWORD }}'

A group's values are not automatically available everywhere; a component references them explicitly through {{ env.varGroups.GROUP.VAR }}, which is a deliberate design, not an oversight, since it means a component's actual dependencies are visible directly in its own configuration rather than implicit from a global namespace of every variable ever defined anywhere in the template.

Cross-Component Wiring With Exported Variables

The pattern that ties everything together for genuinely dynamic values, ones that don't exist until a component actually runs (a database endpoint from a Terraform apply, a generated service URL from a cloud-run deploy) is exportVariables:

YAML
1components:
2  - kind: Terraform
3    name: database
4    exportVariables: [DB_HOST, DB_PORT]
5    deploy:
6      - 'DB_HOST=terraform output --raw host'
7      - 'DB_PORT=terraform output --raw port'
8
9  - kind: Application
10    name: api
11    dependsOn: [database]
12    dockerCompose:
13      environment:
14        DATABASE_URL: 'postgres://{{ env.vars.DB_USER }}:{{ env.vars.DB_PASS }}@{{ components.database.exported.DB_HOST }}:{{ components.database.exported.DB_PORT }}/{{ env.vars.DB_NAME }}'

The dependsOn: [database] declaration is what actually guarantees database's deploy step, and therefore its export, completes before api's deploy step tries to reference it. The interpolation syntax alone doesn't enforce this ordering; a reference to an exported variable from a component with no corresponding dependsOn relationship is a common and subtle bug, since it can appear to work by coincidence (if the referenced component happens to finish first anyway) and then fail unpredictably once deploy timing shifts even slightly.

Common Patterns Worth Reusing Directly

Dynamic hostnames, so every environment gets a unique, non-colliding URL automatically:

YAML
hosts:
  - hostname: '{{ env.vars.SERVICE_PREFIX }}-{{ env.base_domain }}'

Conditional replica counts, so ephemeral and long-lived environments share one template without one being wastefully oversized or the other being under-provisioned:

YAML
replicas: {% if env.type == "ephemeral" %}1{% else %}3{% endif %}

Database connection strings assembled from parts, rather than one hardcoded string that would need per-environment editing:

YAML
DATABASE_URL: 'postgres://{{ env.vars.DB_USER }}:{{ env.vars.DB_PASS }}@{{ components.postgres.exported.HOST }}:5432/{{ env.vars.DB_NAME }}'

Each of these patterns shares the same underlying property: the template file itself never changes between environments, only the resolved values do, which is exactly the guarantee that makes one bunnyshell.yaml a genuinely reusable definition rather than a per-environment configuration file that happens to be checked into the same repository.

Debugging an Interpolation That Doesn't Resolve

An unresolved or incorrectly-resolved interpolation expression usually falls into one of a small number of recognizable causes, worth checking in order before assuming something deeper is wrong.

A typo in the path. {{ components.api.exported.DB_HOST }} when the component is actually named api-service resolves to nothing, silently, rather than raising an obvious error, since the templating engine has no way to know the referenced name was a typo rather than an intentionally missing value. Double-checking the exact component name as declared, not as remembered, catches this quickly.

A missing dependsOn. As covered above, the syntax resolving correctly depends entirely on the referenced component having actually produced the value by the time the reference is evaluated, which dependsOn is what guarantees, not the interpolation syntax itself.

Confusing component (singular, self-reference) with components.NAME (plural, cross-reference). {{ component.image }} inside a component's own block refers to itself; the same expression has no meaning inside a different component's block, where {{ components.OTHER_NAME.image }} is needed instead.

An export that was declared but never actually set. exportVariables: [DB_HOST] declares the export, but if the deploy script never actually runs a step that sets DB_HOST, the export exists as a name with no value, which resolves to an empty string rather than an error, silently producing a broken connection string or config value downstream instead of a clear failure at the source.

Bash
$ bns environments show --id <ENV_ID> -o json | \
    python3 -c "import json,sys; d=json.load(sys.stdin); print(d['components'])"

Inspecting the fully resolved environment definition after deploy, rather than only the template before interpolation, is the most direct way to confirm exactly what a given expression actually resolved to for a specific environment, which is often faster than reasoning about the template in the abstract.

For the specific DockerImage component kind whose {{ components.NAME.image }} output this interpolation syntax most commonly wires into other components, see Building Docker Images as Environment Components. For the complete top-level schema this interpolation syntax is used throughout, see Defining Your Full Stack in YAML.

Checklist

  • Variable overrides are placed at the narrowest scope that needs them, not duplicated at a broader scope
  • Every cross-component interpolation reference (exported, image, ingress.hosts) has a matching dependsOn declaration
  • Secrets are written with SECRET[] at authoring time and never manually edited once stored as ENCRYPTED[]
  • Branch names or other free-text values used in a hostname are passed through the slug filter first
  • Conditional logic (env.type == "ephemeral") is used to share one template across environment types instead of maintaining duplicates
✦✦

Write one template that correctly wires itself for every environment.

14-day full-feature trial. No credit card required. Pay-as-you-go from $0.007/min per environment.

Frequently Asked Questions

What actually gets substituted when an environment deploys?

Every {{ expression }} in the template is evaluated against that specific environment's actual values (its generated unique identifier, its variables, its components' resolved image tags and hostnames) at deploy time, producing a fully resolved configuration with no remaining template syntax, before anything is applied to the cluster.

What is the priority order when the same variable name exists at multiple scopes?

Component Variables override Environment Variable Groups (which must be explicitly included to apply), which override Environment Variables, which override Project Variables. The narrowest, most specific scope always wins, which lets a single component override a project-wide default without needing to change that default for everyone else.

Why does SECRET[] turn into ENCRYPTED[] after saving?

SECRET["value"] is the plain-text input syntax used when writing or editing a template. Once saved, the platform encrypts the value and stores it as ENCRYPTED[...], so the original plain-text value is never persisted or visible again in the stored definition, only the encrypted form.

Can one component reference another component that hasn't deployed yet?

The interpolation itself will resolve correctly if dependsOn is declared, since dependsOn controls deploy ordering. Without an explicit dependsOn relationship, there is no guarantee the referenced component has actually produced the value (an exported variable, a built image, an assigned hostname) by the time the referencing component's own deploy step runs, even though the syntax itself is valid.

Do interpolation filters run at deploy time or need to be pre-computed?

At deploy time, as part of resolving the template for that specific environment. A filter like | slug or | date('Y-m-d') computes its result fresh for each environment, using that environment's own actual values, not a value baked in when the template was written.