Building Docker Images as Environment Components
GuideSeptember 29, 202610 min read

Building Docker Images as Environment Components

The Short Version

Most components in a bunnyshell.yaml both build and run: an Application component builds an image from source and immediately runs it as a container. The DockerImage component kind does only the first half. It builds an image and stops there, producing an artifact other components reference by name, without ever running a container of its own. This distinction matters once a stack has more than one component that needs the same built image, or a build step with requirements (a different build context, a longer build time, build-time secrets) that don't belong bundled into the runtime component that eventually uses the result.

Why Separate Building From Running

The natural first instinct is inline building: an Application component with a dockerCompose.build block that builds and runs in one step. This is the right choice for the common case, one component, one image, no reuse needed elsewhere. It starts to break down in two specific situations. First, when the same image needs to be consumed by more than one runtime component, a Helm chart's values file needing a specific image tag, and a separate KubernetesManifest component needing the identical tag, inline building in either one leaves the other without a clean way to reference the same build. Second, when the build itself is expensive or has different requirements than the runtime, a multi-stage build compiling a large binary that takes several minutes, decoupled from a lightweight runtime component that just needs the resulting binary copied in.

The DockerImage Component, Concretely

YAML
1- kind: DockerImage
2  name: api-image
3  gitRepo: 'https://github.com/org/api.git'
4  gitBranch: '{{ component.gitBranch }}'
5  gitApplicationPath: /
6  context: /
7  dockerfile: Dockerfile
8  target: production
9  args:
10    BUILD_ENV: production
11  secrets:
12    NPM_TOKEN: SECRET['npm-registry-token']

Each field here maps to a real, specific decision. context sets the Docker build context, the directory whose contents are sent to the build, which matters directly for build performance since a larger context takes longer to send even before the build itself starts. dockerfile allows pointing at a Dockerfile that isn't named Dockerfile or doesn't live at the context root, useful for a repository with multiple Dockerfiles for different purposes. target selects a specific stage in a multi-stage Dockerfile to build through, rather than building all the way to the final stage, useful when a different component needs an intermediate stage's contents specifically. args passes standard Docker build arguments. secrets makes a value available during the build without it needing to live in a plain environment variable the Dockerfile might accidentally bake into a layer.

Referencing the Built Image From Another Component

The entire point of a standalone DockerImage component is that other components reference its output through interpolation, rather than each maintaining its own copy of the build logic:

YAML
1components:
2  - kind: DockerImage
3    name: api-image
4    gitRepo: 'https://github.com/org/api.git'
5    context: /
6    dockerfile: Dockerfile
7
8  - kind: Helm
9    name: api-release
10    dependsOn: [api-image]
11    deploy:
12      - |
13        cat << EOF > values.yaml
14        image: {{ components.api-image.image }}
15        replicas: 2
16        EOF
17      - 'helm upgrade --install --namespace {{ env.k8s.namespace }} api-release-{{ env.unique }} . -f values.yaml'

{{ components.api-image.image }} resolves to the full built image reference, repository and tag together, at deploy time. dependsOn: [api-image] is what actually guarantees the build finishes before the Helm component's deploy step runs and tries to reference it; without it, nothing enforces build-before-use ordering, and a race between the two is possible. The interpolation also has narrower forms for when only part of the reference is needed: {{ components.api-image.imageName }} for just the repository path, and {{ components.api-image.imageTag }} for just the tag, useful for a values file or manifest that expects those as separate fields rather than one combined string.

A Multi-Consumer Example

The case where this pattern earns its keep most clearly is one build feeding several distinct runtime components, which happens more often than it first appears: a canary deployment running the same image at reduced replica count alongside the main deployment, or a scheduled batch job using the exact same application image as the main web service but with a different entrypoint.

YAML
1components:
2  - kind: DockerImage
3    name: app-image
4    gitRepo: 'https://github.com/org/app.git'
5    context: /
6
7  - kind: Application
8    name: web
9    dependsOn: [app-image]
10    dockerCompose:
11      image: '{{ components.app-image.image }}'
12    hosts:
13      - hostname: 'web-{{ env.base_domain }}'
14        servicePort: 3000
15
16  - kind: GenericComponent
17    name: nightly-report-job
18    dependsOn: [app-image]
19    runnerImage: '{{ components.app-image.image }}'
20    deploy:
21      - 'node scripts/generate-nightly-report.js'

Both web and nightly-report-job reference the exact same build, guaranteeing they run identical code with no risk of drift between "the web service's version" and "the batch job's version" of the application, which is a real and easy-to-introduce bug when the two are built independently instead of sharing one DockerImage component.

Handling Build Secrets Without Leaking Them Into Layers

The secrets field makes a value available to the build process without it becoming a permanent, inspectable part of the resulting image, but this guarantee depends on the Dockerfile itself using the value correctly, specifically through Docker BuildKit's secret-mounting mechanism rather than a plain ARG or ENV instruction, which would bake the value into a layer regardless of how the platform passed it in.

Dockerfile
1# syntax=docker/dockerfile:1.4
2FROM node:20-alpine AS build
3WORKDIR /app
4COPY package*.json ./
5RUN --mount=type=secret,id=NPM_TOKEN \
6    NPM_TOKEN=$(cat /run/secrets/NPM_TOKEN) npm ci

--mount=type=secret makes the value available only during that specific RUN instruction's execution, never written to a layer, never visible in docker history, and never present in the final image at all. A Dockerfile that instead does ARG NPM_TOKEN followed by ENV NPM_TOKEN=$NPM_TOKEN defeats this protection entirely, since that value becomes a permanent, inspectable part of the built image's layer history regardless of how carefully the component configuration passed it in. Getting this right is a Dockerfile-authoring responsibility that no component-level configuration can enforce on its own.

DockerImage vs CustomDockerImage

The reference schema also includes a CustomDockerImage kind, which exists for cases needing more control over the build process than the standard DockerImage kind's fixed set of fields provides, an unusual multi-step build orchestration, or a build tool other than plain docker build. For the overwhelming majority of build-only use cases, standard DockerImage with context, dockerfile, target, args, and secrets covers what's needed, and reaching for CustomDockerImage is worth doing specifically when a genuine gap in that fixed field set is blocking a real build requirement, not as a default first choice.

When Not to Bother With a Separate Component

For the common single-consumer case, one Application component that builds and runs its own image with no other component needing the result, a standalone DockerImage component adds an extra moving part (an extra dependsOn edge, an extra interpolation reference) with no actual benefit over inline building:

YAML
1- kind: Application
2  name: web
3  dockerCompose:
4    build:
5      context: .
6      dockerfile: Dockerfile

The decision rule that holds up in practice: reach for a separate DockerImage component the moment a second component needs the same built artifact, or the build itself has genuinely different requirements than the component that will run it. Before that point, inline building is simpler and equally correct.

Reusing Build Cache Across Ephemeral Environments

A build that runs from a completely cold cache on every new ephemeral environment adds real time to every single environment's creation, minutes that add up quickly across a team creating many PR environments a day. A DockerImage component benefits from the same layer-caching principles as any Docker build: ordering a Dockerfile so rarely-changing steps (installing dependencies) happen before frequently-changing ones (copying application source) lets the build reuse cached layers across environments whose dependency manifests haven't changed, even though their application code has.

Dockerfile
1FROM node:20-alpine AS build
2WORKDIR /app
3COPY package*.json ./
4RUN npm ci
5COPY . .
6RUN npm run build

Copying package*.json and running npm ci before copying the rest of the source means a PR that only changes application code, not dependencies, reuses the cached dependency-install layer entirely, cutting that PR's build time down to just the steps that actually changed. Getting this ordering wrong (copying everything, then installing) forces a full dependency reinstall on every single build regardless of what actually changed, which is a common and entirely avoidable source of slow ephemeral environment creation.

Debugging a Build That Fails

A DockerImage component's build failures show up the same way any deploy pipeline failure does, through bns pipeline logs, and the same triage discipline described in Debugging Failed Deploy Pipelines Before They Block a Merge applies directly:

Bash
1$ bns pipeline logs --id <PIPELINE_ID> --jobStatus failed --stepStatus failed
2
3[api-image] Step 4/7: RUN npm ci
4[api-image] npm error code EACCES
5[api-image] npm error syscall access

A build failure specific to DockerImage components worth recognizing on sight: a secrets value referenced in the Dockerfile via --mount=type=secret but not actually declared in the component's own secrets field, which fails at the exact RUN instruction trying to read it, with an error that looks like a permissions problem but is actually a missing or misnamed secret declaration one layer up in the component configuration.

For the full interpolation syntax {{ components.NAME.image }} and its variants build on, see Variable Interpolation and Cross-Component Wiring. For how this component kind fits into a complete multi-component stack definition, see Defining Your Full Stack in YAML.

A Note on Registry Choice

Built images need somewhere to live between the build step and whatever component consumes them, which for most stacks means pushing to a container registry the project is already configured to use. Nothing about the DockerImage component kind itself requires a specific registry provider; the registry is a separate, project-level configuration (a connected container registry resource) that the build step pushes to and the consuming component's image reference resolves against. Teams already standardized on a specific registry (a private ECR repository, a GitHub Container Registry namespace) can generally keep using it without the DockerImage component requiring any change to that existing setup, since it builds and pushes using the same registry credentials and destination the project is already configured with.

Checklist

  • A standalone DockerImage component is used only when a build genuinely needs to be shared or decoupled from its runtime consumer, not by default
  • Every component consuming a DockerImage component's output declares dependsOn against it explicitly
  • Build secrets are consumed via --mount=type=secret in the Dockerfile, not ARG/ENV, to avoid leaking into image layers
  • target is set for any multi-stage Dockerfile where a non-final stage is the actual desired build output
  • Multiple components needing identical application code reference one shared DockerImage component, not independent, potentially drifting builds
✦✦

Build once, reference everywhere in the same environment.

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

Frequently Asked Questions

What is the difference between a DockerImage component and an Application component?

An Application component builds (optionally) and runs a container as part of the environment, with its own pod, its own lifecycle, and typically its own hosts entry. A DockerImage component only builds; it produces an image other components reference, and it does not run a container itself.

Why not just build the image inside the component that uses it?

For a single component using its own image, building inline is simpler and often the right choice. A separate DockerImage component earns its place when the same built image is needed by multiple other components, or when the build itself has meaningfully different requirements (a different build context, different secrets, a longer build time) than the components consuming the result.

How does a component reference an image built by a DockerImage component?

Through interpolation: {{ components.NAME.image }} resolves to the full built image reference (repository and tag) at deploy time, so a Helm, KubernetesManifest, or GenericComponent component can pass that value into a values file or a kubectl set image command without hardcoding a tag.

Can build secrets leak into the final image layers?

Only if the Dockerfile itself copies them into a layer rather than using proper build-time secret mounting. The secrets field passes values available during the build without them landing in the image's final layers, when the Dockerfile is written to use them correctly (a multi-stage build, or Docker BuildKit's --mount=type=secret), which is a Dockerfile-authoring discipline the component configuration alone cannot enforce.

Does the target field matter if the Dockerfile has only one stage?

No, target is specifically for multi-stage Dockerfiles, letting the build stop at a named intermediate stage rather than building through to the final one. A single-stage Dockerfile can omit it entirely.