Defining Your Full Stack in YAML: A Reference Walkthrough
The Short Version
A bunnyshell.yaml file describes an entire environment: every component's build and runtime configuration, how components depend on and reference each other, what gets exposed publicly and how, what data persists across restarts, and optionally, how a developer can remote-develop against a running component instead of rebuilding on every change. This is a guided tour through the full schema, from the top-level keys down through every component kind, organized the way a team actually encounters these pieces while building out a real stack rather than as an alphabetical reference.
Top-Level Structure
Every template starts with the same handful of top-level keys, most of them optional beyond the three genuinely required ones:
1kind: Environment # Required: always "Environment"
2name: string # Required: environment name
3type: primary | ephemeral # Required: environment type
4
5deployment:
6 strategy:
7 type: RollingUpdate | Recreate
8 maxUnavailable: int | percent
9 maxSurge: int | percent
10
11environmentVariables:
12 KEY: value
13 SECRET_KEY: SECRET["value"]
14
15environmentVariablesGroups:
16 group-name:
17 KEY: value
18
19components: [] # Required: the actual stack
20
21volumes: []
22
23security:
24 access:
25 allowedIps:
26 - '192.168.0.0/24'
27 realIpTrustedProxies: null
28
29dev: {}type is worth getting right early, since it affects defaults elsewhere: ephemeral environments are meant to be created and destroyed frequently (PR previews, test shards) while primary environments are the long-lived ones (staging, a demo instance) a team expects to keep running and occasionally update in place rather than recreate.
Components Common Attributes
Every component, regardless of kind, shares a base set of fields:
1components:
2 - kind: string
3 name: string
4 version: v1
5
6 gitRepo: string
7 gitBranch: string
8 gitApplicationPath: /
9
10 dependsOn: []
11
12 hosts:
13 - hostname: string
14 path: /
15 servicePort: 8080
16 public: false
17 externalAddress: string
18 selfManagedDns: false
19 displayPaths: []
20 k8s:
21 ingress:
22 className: bns-nginx
23 tlsSecretName: string
24 annotations: {}dependsOn deserves particular attention, since it's the mechanism controlling deploy, start, stop, and destroy ordering across the whole stack, not just deploy: on deploy and start, dependencies run before dependents; on stop and destroy, everything runs in parallel by default, since tearing down doesn't have the same ordering requirement building up does.
Docker Compose Components: Application, Service, Database
These three kinds share the same Docker Compose-style configuration shape and cover the majority of a typical stack:
1- kind: Application
2 name: api
3 gitRepo: 'https://github.com/org/api.git'
4 gitBranch: main
5 dockerCompose:
6 build:
7 context: .
8 dockerfile: Dockerfile
9 environment:
10 NODE_ENV: production
11 ports:
12 - '8080:8080'
13 hosts:
14 - hostname: 'api-{{ env.base_domain }}'
15 servicePort: 8080A Service component (a cache, a queue, a proxy) and a Database component use the identical dockerCompose block shape, typically referencing a pre-built third-party image rather than a build section, since there's usually no application source to build for these.
InitContainer and SidecarContainer
For workloads needing setup-before-start logic or a co-located helper process, InitContainer and SidecarContainer attach to a parent Application component's pod rather than existing as fully independent components:
1- kind: InitContainer
2 name: init-db
3 gitRepo: 'https://github.com/org/repo.git'
4 dockerCompose:
5 build:
6 context: ./init
7
8- kind: Application
9 name: api
10 pod:
11 init_containers:
12 - from: init-db
13 name: custom-name
14 shared_paths:
15 - path: /init/data
16 target:
17 path: /app/data
18 container: '@parent'
19 initial_contents: '@self'
20 sidecar_containers:
21 - from: sidecar-name
22 name: custom-sidecarshared_paths.initial_contents controls which side owns the shared filesystem path's starting content: @self when the init or sidecar container owns the files (a config-injector pattern), @target when the parent application owns them (the app builds assets a sidecar then serves). A detail easy to miss: a sidecar exposing its own port needs that port declared on the parent Application component's own ports list too, since they share one pod network namespace, and the ingress routes to whichever port servicePort names regardless of which container inside the pod actually listens on it.
1- kind: Application
2 name: app
3 dockerCompose:
4 ports:
5 - '9000:9000' # app's own port
6 - '8080:8080' # sidecar's port, must be declared here too
7 pod:
8 sidecar_containers:
9 - from: nginx
10 name: nginx
11 hosts:
12 - hostname: 'app-{{ env.base_domain }}'
13 servicePort: 8080 # routes to the sidecarHelm, KubernetesManifest, Terraform, and GenericComponent
For anything beyond a Docker Compose-style service, four component kinds cover the remaining common cases. Helm deploys a chart:
1- kind: Helm
2 name: my-release
3 runnerImage: 'dtzar/helm-kubectl:3.8.2'
4 gitRepo: 'https://github.com/org/charts.git'
5 deploy:
6 - 'helm upgrade --install --namespace {{ env.k8s.namespace }} my-release-{{ env.unique }} .'
7 destroy:
8 - 'helm uninstall my-release-{{ env.unique }} --namespace {{ env.k8s.namespace }}'KubernetesManifest applies raw manifests via kustomize. Terraform runs infrastructure-as-code with a managed backend. GenericComponent runs fully custom bash for anything not covered by the other kinds, commonly cloud-provider CLI workflows:
1- kind: GenericComponent
2 name: cloud-function
3 runnerImage: 'google/cloud-sdk:alpine'
4 deploy:
5 - 'gcloud run deploy fn-{{ env.unique }} --image={{ components.fn-image.image }}'
6 exportVariables: [SERVICE_URL]Each of these four accepts deploy, destroy, start, and stop lifecycle scripts, giving genuinely full control over what happens at each stage of the environment's life, at the cost of writing that logic explicitly rather than getting it for free the way Docker Compose components do.
StaticApplication for Frontend Builds
A frontend that builds to static files (React, Vue, a static site generator) gets its own kind with build-specific fields rather than needing a Dockerfile at all:
1- kind: StaticApplication
2 name: frontend
3 gitRepo: 'https://github.com/org/frontend.git'
4 buildNodeVersion: lts
5 buildCommand: 'npm run build'
6 buildOutputDir: dist
7 buildArguments:
8 VITE_API_URL: '{{ components.api.ingress.hosts[0].url }}'
9 hosts:
10 - hostname: 'app-{{ env.base_domain }}'
11 servicePort: 8080buildArguments here is worth noting specifically: it's how a frontend build gets the backend API's actual generated URL baked in at build time, via the same interpolation used everywhere else, without needing a runtime environment variable a static site (with no server process to read one) couldn't use anyway.
Volumes
Persistent data needs an explicit volume declaration, and the choice between the two types depends entirely on whether more than one pod needs concurrent access:
1volumes:
2 - name: db-data
3 size: 10Gi
4 type: diskdisk (block storage, ReadWriteOnce) is the default and correct choice for a single-pod workload like most databases. network (a network filesystem, ReadWriteMany) is for the less common case of multiple pods needing the same volume simultaneously, or a workload that gets rescheduled across nodes and needs its data to follow it.
Custom Domains and SSL
The default auto-generated subdomain needs no domain or certificate configuration at all. A custom domain needs selfManagedDns: true (the platform will not create DNS records for a domain you own; ExternalDNS or manual DNS management is expected) plus a certificate strategy:
1# Auto-provisioned certificate via cert-manager (most common for a custom domain)
2hosts:
3 - hostname: 'api.example.com'
4 selfManagedDns: true
5 k8s:
6 ingress:
7 tlsSecretName: ''
8 annotations:
9 cert-manager.io/cluster-issuer: letsencrypt-prodFor ephemeral environments specifically, a per-environment Let's Encrypt certificate risks that provider's rate limits under high creation volume; a wildcard certificate (*.example.com) issued once via DNS challenge and reused across every environment avoids this entirely, and is the recommended pattern for any domain pattern shared across many short-lived environments.
Remote Development Configuration
The dev section, entirely optional, lets a specific component sync local file changes into its running remote container instead of requiring a rebuild-and-redeploy cycle on every change:
1dev:
2 api:
3 - containers:
4 api:
5 command: ['php-fpm']
6 syncPaths:
7 - remotePath: /var/www/
8 localPath: ~/project
9 portForwards:
10 - "9003<9003" # Reverse: remote debugger port reachable locally
11 - "80>8080" # Forward: local port reaches the remote container
12 resources:
13 limits:
14 memory: 750MThis is worth adding specifically to components a developer actively iterates on inside a running environment (rather than testing via full redeploys), and skipping entirely for components that don't need this workflow, since it adds real configuration weight for a capability not every component needs.
Common Mistakes When Writing One of These From Scratch
Forgetting servicePort needs to match the container's actual listening port, not an assumed default. A hosts entry with the wrong servicePort produces an environment that deploys cleanly and a URL that times out, a confusing combination for anyone debugging it without knowing to check this specific field first.
Declaring a network volume for a single-pod database out of caution. This works, but costs more and performs worse than the disk type a single-pod workload actually needs, since network filesystems carry overhead a block-storage disk volume doesn't. Reach for network only when a genuine multi-pod access requirement exists.
Treating dependsOn as optional when a component "usually" starts fast enough anyway. This is the single most common source of intermittent, hard-to-reproduce deploy failures in a growing stack: a dependency that happens to be fast enough during testing and just slow enough under different load to occasionally not be ready when a dependent component starts, without an explicit dependsOn making the ordering guarantee actually enforced rather than accidental.
Copying a security.access.allowedIps block from one environment to another without updating the CIDR ranges. A restriction copied verbatim from a different team's environment can silently lock out (or, worse, silently fail to restrict) the new environment's intended audience, since the ranges that made sense for the original environment's context don't automatically apply to a different one.
Putting the Pieces Together
A genuinely complete, if compact, stack combines several of these pieces in one file: a database with a persistent disk volume, an API built from source with a custom domain, and a static frontend built separately, wired together entirely through the interpolation syntax covered in Variable Interpolation and Cross-Component Wiring rather than any hardcoded values, so the exact same file works correctly whether it's creating environment number one or environment number two hundred.
Related Reading
For the full interpolation syntax that wires every cross-component reference in this schema together, see Variable Interpolation and Cross-Component Wiring. For the build-only component kind referenced by several of the examples above, see Building Docker Images as Environment Components.
Reading an Unfamiliar Template for the First Time
A developer inheriting an existing bunnyshell.yaml they didn't write benefits from reading it in a specific order rather than top to bottom: first components, noting each one's kind to understand the overall shape of the stack (how many services, what each one is for), then dependsOn relationships across all of them to understand deploy ordering, then hosts blocks to see what's actually exposed publicly, and only then the finer details (environmentVariables, security.access, dev). This order mirrors how the stack actually comes together at deploy time, and tends to build an accurate mental model faster than reading the file in the order it happens to be written, which is rarely the same as the order that makes it easiest to understand.
Checklist
-
typeis set correctly (ephemeralfor short-lived,primaryfor long-lived) since it affects sensible defaults elsewhere -
dependsOnreflects every genuine ordering requirement between components, not just the obvious ones - Volume type matches actual access pattern:
diskfor single-pod,networkonly when genuinely needed - Custom domains use a wildcard certificate for any pattern shared across many ephemeral environments, not a per-environment certificate
- The
devsection is added only to components that actually benefit from remote development, not applied blanket across the stack
See the whole schema, then write your stack once.
14-day full-feature trial. No credit card required. Pay-as-you-go from $0.007/min per environment.
Frequently Asked Questions
Do I need to understand every component kind to write a working bunnyshell.yaml?
No. Most stacks need only Application, Service, and Database, which cover a git-built app, a third-party image, and a persistent data store respectively. The less common kinds (Terraform, Helm, KubernetesManifest, GenericComponent) exist for specific situations and are worth learning when one of those situations actually comes up, not upfront.
What decides whether I need a disk or a network volume?
Whether more than one pod needs to read or write the same volume simultaneously. A single-pod workload like most databases uses a disk volume (ReadWriteOnce). Anything where multiple pods need concurrent access to the same files needs a network volume (ReadWriteMany), backed by a network filesystem rather than block storage.
Why would a sidecar container need its port declared on the parent component too?
Because a sidecar shares the same pod network namespace as its parent container, so from the ingress's perspective, any port the sidecar listens on needs to appear in the parent Application component's own ports list, even though the parent process itself isn't the one using that port.
Is the dev section required?
No, it's optional and only relevant for components a developer wants to remote-develop against, syncing local file changes into a running remote container instead of rebuilding and redeploying on every change. Components without a dev section deploy and run normally without any remote-development capability, which is the default and fine for most components most of the time.
Should every hostname use auto-generated cert-manager certificates or a pre-existing one?
Auto-generated cert-manager certificates (tlsSecretName left empty with a cluster-issuer annotation) are the right default for most hostnames, including every ephemeral environment's auto-generated subdomain. A pre-existing certificate secret is worth using specifically for a stable custom domain where the team already manages certificates through an existing process outside the cluster.