Skip to content

Air-Gapped Deployment

What Is an Air-Gapped Deployment

An air-gapped deployment Plan allows you to package your Helm chart as a self-contained installer that your customers can deploy onto their own Kubernetes clusters. The Plan specification defines deployment requirements, API parameters, lifecycle hooks, and Helm chart configuration.

Note

An installer Plan can include multiple Helm chart resources and multiple container image registry copy resources. Model each Helm release as its own services[] entry, and model each source registry or repository copy path as its own internal image sync service.

Note

The installer assumes that the customer running it has kubectl, helm, and a valid kubeconfig for the target cluster.

Configuring the deployment block

Set the product name and the air-gapped deployment requirements:

name: My Application

deployment:
  requirements:
    k8sVersion: ">=1.30.0"  # optional
  onPremDeployment:
    # AWS account that hosts the installer artifacts
    AwsAccountId: '<your-aws-account-id>'
    AwsBootstrapRoleAccountArn: 'arn:aws:iam::<your-aws-account-id>:role/omnistrate-bootstrap-role'
Field Description
name Display name for your installer product.
requirements.k8sVersion (Optional) Minimum Kubernetes version required on the target cluster.
onPremDeployment.AwsAccountId The AWS account ID that hosts the installer artifacts. This is the account where Omnistrate stores and retrieves installer resources.
onPremDeployment.AwsBootstrapRoleAccountArn The IAM role ARN in the installer artifact hosting account that Omnistrate assumes during installer packaging operations.

Helper user script

You can use the onPremInstallerTools.helperUserScript field to inject common reusable bash functions into the installer artifact. These functions are then available to all action hooks (validate, pre-install, post-install, backup), avoiding duplication across hooks:

  onPremInstallerTools:
    helperUserScript: |
      #!/bin/bash
      log_error() {
        echo "Error: $1" > /tmp/error.log
      }

Or reference an external file:

  onPremInstallerTools:
    helperUserScript: |
      {{ $file:./custom_scripts/helper.sh }}

Defining services

Under the services key, define one or more services. Each service maps to a deployable unit (typically a Helm release).

services:
  - name: MyApp

Configuring API parameters

API parameters are the user-facing configuration inputs shown during deployment. Add them under apiParameters for each service. For more details, see Deployment API Params.

    apiParameters:
      - name: releaseName
        key: releaseName
        type: String
        required: false
        modifiable: false
        export: true
        description: "Helm release name"
        defaultValue: "my-app"

      - name: namespace
        key: namespace
        type: String
        required: false
        modifiable: false
        export: true
        defaultValue: "my-app"
        description: "Kubernetes namespace to deploy into"

      - name: adminPassword
        key: adminPassword
        type: Password
        required: true
        modifiable: false
        export: true
        description: "Admin password for the application"

Parameter field reference

Field Description
name Human-readable display name.
key Internal reference key (used in {{ $var.<key> }} templates).
type Data type: String, Boolean, Password, etc.
required Whether the user must provide a value.
modifiable Whether the value can be changed after initial deployment.
export Whether the value is passed to Helm and hooks.
defaultValue Pre-filled default (user can override).
description Tooltip or help text shown in the UI.
options (Optional) Restrict input to an enumerated list of values.

Tip

Use type: Password for secrets so the value is masked in the UI.

Configuring action hooks

Action hooks run shell commands at specific points in the deployment lifecycle. Add them under actionHooks. For more details, see Action Hooks.

    actionHooks:
      - scope: CLUSTER
        type: VALIDATE
        commandTemplate: "echo 'Running validation checks...'"

      - scope: CLUSTER
        type: PRE_INSTALL
        commandTemplate: "echo 'Preparing environment...'"

      - scope: CLUSTER
        type: POST_INSTALL
        commandTemplate: "echo 'Post-install configuration...'"

      - scope: CLUSTER
        type: BACKUP
        commandTemplate: "echo 'Creating backup...'"
Hook Type When it runs
VALIDATE Before and after installation to verify the cluster meets requirements.
PRE_INSTALL After validation but before Helm install/upgrade.
POST_INSTALL After Helm install/upgrade completes successfully.
BACKUP Before upgrades to snapshot the current state.

For longer scripts, reference external files:

      - scope: CLUSTER
        type: VALIDATE
        commandTemplate: |
          {{ $file:./custom_scripts/validate.sh }}

Configuring the Helm chart

The helmChartConfiguration block tells Omnistrate which Helm chart to deploy and how to pass values:

    helmChartConfiguration:
      chartName: my-app
      chartVersion: 1.0.0
      chartRepoName: my-repo
      chartRepoURL: https://charts.example.com/
      releaseName: "{{ $var.releaseName }}"
      namespace: "{{ $var.namespace }}"
Field Description
chartName Name of the Helm chart.
chartVersion Chart version to install.
chartRepoName Logical name for the Helm repository.
chartRepoURL URL of the Helm chart repository (HTTPS or OCI).
releaseName Helm release name (use {{ $var.releaseName }} to reference a parameter).
namespace Kubernetes namespace (use {{ $var.namespace }} to reference a parameter).

Passing values with chartValues

Map API parameters into your Helm chart's values.yaml structure using the {{ $var.<key> }} template syntax:

      chartValues:
        config:
          adminPassword: "{{ $var.adminPassword }}"
          domain: "{{ $var.dnsEndpoint }}"
        ingress:
          enabled: true
          hosts:
            - host: "{{ $var.dnsEndpoint }}"
              paths:
                - path: /
                  pathType: Prefix

Platform-specific values with layeredChartValues

Use layeredChartValues to conditionally apply different Helm values based on the target platform or other parameter values. For more details, see Layered Chart Values.

      layeredChartValues:
        - scope:
            "{{ $var.onprem_platform }}": "EKS"
          values:
            {{ $file:./chart_value_templates/aws/values.yaml }}

        - scope:
            "{{ $var.onprem_platform }}": "AKS"
          values:
            {{ $file:./chart_value_templates/azure/values.yaml }}

        - scope:
            "{{ $var.onprem_platform }}": "GKE"
          values:
            {{ $file:./chart_value_templates/gcp/values.yaml }}

        - scope:
            "{{ $var.onprem_platform }}": "GENERIC"
          values:
            {{ $file:./chart_value_templates/generic/values.yaml }}

The scope block acts as a conditional — the values are only applied when all conditions match. {{ $var.onprem_platform }} is a built-in system variable set automatically based on the customer's cluster type.

Configuring container image registry copy

If your Helm chart references container images stored in a private registry (for example, Docker Hub with authentication, a private ECR, or any registry requiring credentials), the installer needs to know how to pull those images at build time so they can be copied into the customer's target environment. Without this configuration, the customer's cluster may fail to pull images if it has no direct access to your source registry.

The containerImagesRegistryCopyConfiguration block defines where to pull images from (source registry + credentials) and where to push them to (the customer's private registry). This is essential for:

  • Air-gapped or restricted environments — the target cluster has no internet access
  • Private source registries — your images require authentication to pull
  • Registry locality — customers want images in their own registry for performance, compliance, or security reasons

How it works

The installer uses a two-service pattern: a dedicated image sync service handles the registry copy, and your main application service depends on it. This ensures all images are available in the customer's registry before the Helm chart is installed.

┌─────────────────────┐       ┌─────────────────────┐
│  ImageSync Service  │──────▶│   MyApp Service     │
│  (internal: true)   │       │  (dependsOn:        │
│                     │       │    - ImageSync)      │
│  Copies images from │       │                     │
│  source → target    │       │  Installs Helm chart│
│  registry           │       │  (images now local) │
└─────────────────────┘       └─────────────────────┘

Image discovery with autoDiscoverImagesTag

The installer needs to know which images to copy. Rather than listing every image manually, you can optionally add autoDiscoverImagesTag to your helmChartConfiguration. This tells the installer to look for the matching annotation in the Helm chart's root Chart.yaml metadata and automatically discover all container images referenced by the chart.

In the installer spec, set the tag value:

    helmChartConfiguration:
      chartName: my-app
      chartVersion: 1.0.0
      chartRepoName: my-repo
      chartRepoURL: oci://registry-1.docker.io/my-org
      # ...other fields...
      autoDiscoverImagesTag: "my-org.com/images"

In your Helm chart's Chart.yaml, add the corresponding annotation. Each entry includes a name and an image reference. You can use Helm's {{.Chart.AppVersion}} template to keep image tags in sync with the chart version:

apiVersion: v2
name: my-app
version: 1.0.0
appVersion: 2.5.0
annotations:
  my-org.com/images: |
    - name: app-server
      image: docker.io/my-org/app-server:{{.Chart.AppVersion}}
    - name: app-worker
      image: docker.io/my-org/app-worker:{{.Chart.AppVersion}}
    - name: mongodb
      image: docker.io/my-org/mirror-mongodb:7.0-build-335
    - name: postgresql-repmgr
      image: docker.io/my-org/mirror-postgresql-repmgr:14.15.0-debian-12-r2
    - name: redis
      image: docker.io/my-org/mirror-redis-server:7.4-build-248
    - name: rabbitmq
      image: docker.io/my-org/mirror-rabbitmq:4.0.5-build-175

At build time, the installer reads the Chart.yaml annotations, matches the autoDiscoverImagesTag key, and extracts the listed container images for the registry copy operation. This keeps the image list in sync with your chart — update Chart.yaml when you change images, and the installer picks up the changes automatically.

Warning

Using autoDiscoverImagesTag requires a properly configured image registry copy resource (containerImagesRegistryCopyConfiguration) in your spec. The auto-discovery feature reads image references from the chart metadata and feeds them into the registry copy pipeline — without a configured image sync service, there is nowhere to copy the discovered images. See Defining the image sync service for setup details.

Tip

The autoDiscoverImagesTag field is optional. If you omit it, you can manually specify the images to copy using the images list in containerImagesRegistryCopyConfiguration instead (see the end-to-end example).

Note

If your Helm chart is in a private OCI registry, you also need authProvider to authenticate when pulling the chart itself:

authProvider:
  username: '{{ $secret.REGISTRY_USERNAME }}'
  password: '{{ $secret.REGISTRY_PASSWORD }}'

Pull modes

The pullMode field controls when and how images are transferred:

Mode Behavior Best for
INSTALLER_EMBED Images are downloaded from the source registry at build time and packaged into the installer artifact. At install time, images are pushed from the local artifact to the target registry. No internet access required during installation. Air-gapped environments, large-scale deployments where you want deterministic installs. Requires more disk space in the installer artifact.
RUNTIME_PULL Images are pulled from the source registry and pushed to the target registry at install/upgrade time. The installer environment must have network access to both registries. Connected environments where disk space is a concern, or when you always want the latest image digests.

Defining the image sync service

Create a separate service with internal: true (hidden from end users) that owns the containerImagesRegistryCopyConfiguration:

services:
  - name: ImageSync
    internal: true
    apiParameters:
      - name: Private Image Registry URL
        key: privateRegistryUrl
        description: "Customer's private registry where images will be pushed"
        type: String
        required: true
        export: true
        modifiable: true
      - name: Pull Mode
        key: pullMode
        description: "Image sync strategy"
        type: String
        required: true
        export: true
        modifiable: true
        defaultValue: INSTALLER_EMBED
        options:
          - INSTALLER_EMBED
          - RUNTIME_PULL
    containerImagesRegistryCopyConfiguration:
      pullMode: "{{ $var.pullMode }}"
      pullSource:
        registryURL: "docker.io"
        repositoryName: "my-org"
        credentials:
          username: "{{ $secret.REGISTRY_USERNAME }}"
          password: "{{ $secret.REGISTRY_PASSWORD }}"
      pushTarget:
        registryURL: "{{ $var.privateRegistryUrl }}"
        repositoryName: "my-org"

Configuration reference

Field Description
pullMode INSTALLER_EMBED or RUNTIME_PULL (see Pull modes above).
pullSource.registryURL Source registry to pull images from (e.g., docker.io, ghcr.io).
pullSource.repositoryName Repository or organization name in the source registry (e.g., my-org).
pullSource.credentials (Optional) Credentials for authenticating to the source registry. Use {{ $secret.<name> }} to reference Omnistrate-managed secrets. Required if the source registry is private.
pushTarget.registryURL Customer's target private registry. Typically references an API parameter (e.g., {{ $var.privateRegistryUrl }}).
pushTarget.repositoryName Repository or organization name in the target registry. Usually mirrors the source.

Wiring the main service with dependsOn

Your main application service must declare dependsOn to ensure image sync completes first. Additionally, if the main service needs to reference the same parameters (e.g., privateRegistryUrl, pullMode), use parameterDependencyMap to link them across services rather than duplicating values. For more details on dependencies, see Resource Dependencies.

  - name: MyApp
    dependsOn:
      - ImageSync
    apiParameters:
      - name: Private Image Registry URL
        key: privateRegistryUrl
        description: "Private registry URL"
        type: String
        required: true
        export: true
        modifiable: true
        parameterDependencyMap:
          ImageSync: privateRegistryUrl    # passes this value to ImageSync's privateRegistryUrl
      - name: Pull Mode
        key: pullMode
        type: String
        required: true
        export: true
        defaultValue: INSTALLER_EMBED
        options:
          - INSTALLER_EMBED
          - RUNTIME_PULL
        parameterDependencyMap:
          ImageSync: pullMode              # passes this value to ImageSync's pullMode
    helmChartConfiguration:
      chartName: my-app
      # ...
      autoDiscoverImagesTag: "my-org.com/images"

With parameterDependencyMap, the user only fills in the value once (on the main service), and it is automatically forwarded to the internal ImageSync service. The format is:

parameterDependencyMap:
  <target-service-name>: <target-parameter-key>

Disabling image sync

You can make image sync optional by adding a boolean parameter and using the disable field on the image sync service:

  - name: ImageSync
    internal: true
    disable: "{{ $var.skipImageSync }}"
    apiParameters:
      - name: Skip Image Sync
        key: skipImageSync
        type: Boolean
        required: false
        export: true
        defaultValue: 'false'
        modifiable: false
      # ...other parameters...
    containerImagesRegistryCopyConfiguration:
      # ...

When skipImageSync is set to true, the entire image copy step is skipped. Use parameterDependencyMap on the main service to let the user control this toggle:

  # In the main service's apiParameters:
      - name: Skip Image Sync
        key: skipImageSync
        type: Boolean
        required: false
        export: true
        defaultValue: 'false'
        modifiable: false
        parameterDependencyMap:
          ImageSync: skipImageSync

End-to-end example

Here is a complete two-service setup with image registry copy:

services:
  # 1. Internal image sync service
  - name: DockerIO
    internal: true
    apiParameters:
      - name: Skip Custom Image Registry
        key: skipCustomImageRegistry
        type: Boolean
        required: false
        export: true
        defaultValue: 'true'
        modifiable: false
      - name: Private Image Registry URL
        key: privateRegistryUrl
        type: String
        required: true
        export: true
        modifiable: true
      - name: Pull Mode
        key: pullMode
        type: String
        required: true
        export: true
        modifiable: true
        defaultValue: INSTALLER_EMBED
        options:
          - INSTALLER_EMBED
          - RUNTIME_PULL
    disable: "{{ $var.skipCustomImageRegistry }}"
    containerImagesRegistryCopyConfiguration:
      pullMode: "{{ $var.pullMode }}"
      pullSource:
        registryURL: "docker.io"
        repositoryName: "my-org"
        credentials:
          username: "{{ $secret.DOCKERHUB_USERNAME }}"
          password: "{{ $secret.DOCKERHUB_PASSWORD }}"
      pushTarget:
        registryURL: "{{ $var.privateRegistryUrl }}"
        repositoryName: "my-org"
      images:
        - imageName: "my-image"
          imageTag: "my-tag"

  # 2. Main application service
  - name: MyApp
    dependsOn:
      - DockerIO
    apiParameters:
      - name: Skip Custom Image Registry
        key: skipCustomImageRegistry
        type: Boolean
        required: false
        export: true
        defaultValue: 'false'
        modifiable: false
        parameterDependencyMap:
          DockerIO: skipCustomImageRegistry
      - name: Private Image Registry URL
        key: privateRegistryUrl
        type: String
        required: true
        export: true
        modifiable: true
        parameterDependencyMap:
          DockerIO: privateRegistryUrl
      - name: Pull Mode
        key: pullMode
        type: String
        required: true
        export: true
        defaultValue: INSTALLER_EMBED
        options:
          - INSTALLER_EMBED
          - RUNTIME_PULL
        parameterDependencyMap:
          DockerIO: pullMode
      # ...other app parameters...
    helmChartConfiguration:
      chartName: my-app
      chartVersion: 1.0.0
      chartRepoName: my-org
      chartRepoURL: oci://registry-1.docker.io/my-org
      releaseName: "{{ $var.releaseName }}"
      namespace: "{{ $var.namespace }}"
      autoDiscoverImagesTag: "my-org.com/images"
      authProvider:
        username: '{{ $secret.DOCKERHUB_USERNAME }}'
        password: '{{ $secret.DOCKERHUB_PASSWORD }}'
      # ...chartValues, layeredChartValues, etc.

Advanced: Multiple Helm releases and registries

Complex installers often need to install prerequisite Helm charts before the main application. For example, you may want the installer to sync images, install ingress-nginx, install cert-manager, and then install your application chart. Model this as a graph of services:

  • Use one internal image sync service for each source registry or repository copy path.
  • Use one Helm service for each Helm release.
  • Use dependsOn to order image sync before the Helm release that consumes those images.
  • Use parameterDependencyMap to pass shared inputs, such as privateRegistryUrl, pullMode, and skip flags, from the main application service to internal services.
  • Use disable when an API parameter can decide that a resource should not be included in the installer plan.
  • Use a validation hook with runtime skip when the decision depends on the customer's cluster state, such as detecting that a prerequisite Helm release already exists.

Example service graph

DockerHubImages -----> MainApp
RegistryK8sImages --> IngressNginx --> MainApp
QuayJetstackImages --> CertManager --> MainApp

The image sync services are hidden from the user with internal: true. The prerequisite Helm services can also be internal when they are implementation details of the installer.

The examples below use one prerequisiteAlreadyInstalled flag to keep the snippets compact. In a production spec, use separate flags when prerequisites can be present independently.

Shared parameters

YAML anchors are optional, but they keep multi-service installer specs readable:

x-api-skip-private-registry: &api-skip-private-registry
  name: Skip Private Image Registry
  key: skipPrivateRegistry
  description: Skip copying images into a private registry
  type: Boolean
  required: false
  export: true
  defaultValue: 'false'
  modifiable: false

x-api-private-registry-url: &api-private-registry-url
  name: Private Image Registry URL
  key: privateRegistryUrl
  description: Private registry where installer images are pushed
  type: String
  required: true
  export: true
  modifiable: true

x-api-pull-mode: &api-pull-mode
  name: Pull Mode
  key: pullMode
  description: Image sync strategy
  type: String
  required: true
  export: true
  modifiable: true
  defaultValue: INSTALLER_EMBED
  options:
    - INSTALLER_EMBED
    - RUNTIME_PULL

x-api-prereq-exists: &api-prereq-exists
  name: Prerequisite Already Installed
  key: prerequisiteAlreadyInstalled
  description: Skip prerequisite image sync and Helm install when the prerequisite already exists
  type: Boolean
  required: false
  export: true
  defaultValue: 'false'
  modifiable: true

Multiple image sync services

Create one registry copy service per source registry or repository layout. This lets each registry keep its own pullSource, credentials, image list, and target repository.

services:
  - name: DockerHubImages
    internal: true
    apiParameters:
      - <<: *api-skip-private-registry
      - <<: *api-private-registry-url
      - <<: *api-pull-mode
    disable: "{{ $var.skipPrivateRegistry }}"
    containerImagesRegistryCopyConfiguration:
      pullMode: "{{ $var.pullMode }}"
      pullSource:
        registryURL: "docker.io"
        repositoryName: "my-org"
        credentials:
          username: "{{ $secret.DOCKERHUB_USERNAME }}"
          password: "{{ $secret.DOCKERHUB_PASSWORD }}"
      pushTarget:
        registryURL: "{{ $var.privateRegistryUrl }}"
        repositoryName: "my-org"

  - name: RegistryK8sImages
    internal: true
    apiParameters:
      - <<: *api-prereq-exists
      - <<: *api-skip-private-registry
      - <<: *api-private-registry-url
      - <<: *api-pull-mode
    disable: '{{ $func.if($func.equals($var.skipPrivateRegistry, "true"), "true", $var.prerequisiteAlreadyInstalled) }}'
    containerImagesRegistryCopyConfiguration:
      pullMode: "{{ $var.pullMode }}"
      pullSource:
        registryURL: "registry.k8s.io"
        repositoryName: "ingress-nginx"
      pushTarget:
        registryURL: "{{ $var.privateRegistryUrl }}"
        repositoryName: "ingress-nginx"
      images:
        - imageName: "controller"
          imageTag: "v1.15.1"
        - imageName: "kube-webhook-certgen"
          imageTag: "v1.6.9"

  - name: QuayJetstackImages
    internal: true
    apiParameters:
      - <<: *api-prereq-exists
      - <<: *api-skip-private-registry
      - <<: *api-private-registry-url
      - <<: *api-pull-mode
    disable: '{{ $func.if($func.equals($var.skipPrivateRegistry, "true"), "true", $var.prerequisiteAlreadyInstalled) }}'
    containerImagesRegistryCopyConfiguration:
      pullMode: "{{ $var.pullMode }}"
      pullSource:
        registryURL: "quay.io"
        repositoryName: "jetstack"
      pushTarget:
        registryURL: "{{ $var.privateRegistryUrl }}"
        repositoryName: "jetstack"
      images:
        - imageName: "cert-manager-controller"
          imageTag: "v1.21.0"
        - imageName: "cert-manager-webhook"
          imageTag: "v1.21.0"
        - imageName: "cert-manager-cainjector"
          imageTag: "v1.21.0"
        - imageName: "cert-manager-startupapicheck"
          imageTag: "v1.21.0"

Multiple Helm services

Each Helm release gets its own service and its own helmChartConfiguration. Use dependsOn to make each chart wait for the image sync service it needs.

  - name: IngressNginx
    internal: true
    dependsOn:
      - RegistryK8sImages
    apiParameters:
      - <<: *api-prereq-exists
        parameterDependencyMap:
          RegistryK8sImages: prerequisiteAlreadyInstalled
      - <<: *api-skip-private-registry
        parameterDependencyMap:
          RegistryK8sImages: skipPrivateRegistry
      - <<: *api-private-registry-url
        parameterDependencyMap:
          RegistryK8sImages: privateRegistryUrl
      - <<: *api-pull-mode
        parameterDependencyMap:
          RegistryK8sImages: pullMode
    disable: "{{ $var.prerequisiteAlreadyInstalled }}"
    actionHooks:
      - scope: CLUSTER
        type: VALIDATE
        commandTemplate: |
          export INTERNAL_HELM_PHASE=validate
          {{ $file:./custom_scripts/ingress_nginx_runtime_skip.sh }}
      - scope: CLUSTER
        type: POST_INSTALL
        commandTemplate: |
          export INTERNAL_HELM_PHASE=post-install
          {{ $file:./custom_scripts/ingress_nginx_runtime_skip.sh }}
    helmChartConfiguration:
      chartName: ingress-nginx
      chartVersion: 4.15.1
      chartRepoName: ingress-nginx
      chartRepoURL: https://kubernetes.github.io/ingress-nginx
      releaseName: ingress-nginx
      namespace: ingress-nginx
      runtimeConfiguration:
        wait: true
        waitForJobs: true
      layeredChartValues:
        - scope:
            "{{ $var.skipPrivateRegistry }}": 'true'
          values:
            global:
              image:
                registry: registry.k8s.io
        - scope:
            "{{ $var.skipPrivateRegistry }}": 'false'
          values:
            global:
              image:
                registry: "{{ $var.privateRegistryUrl }}"

  - name: CertManager
    internal: true
    dependsOn:
      - QuayJetstackImages
    apiParameters:
      - <<: *api-prereq-exists
        parameterDependencyMap:
          QuayJetstackImages: prerequisiteAlreadyInstalled
      - <<: *api-skip-private-registry
        parameterDependencyMap:
          QuayJetstackImages: skipPrivateRegistry
      - <<: *api-private-registry-url
        parameterDependencyMap:
          QuayJetstackImages: privateRegistryUrl
      - <<: *api-pull-mode
        parameterDependencyMap:
          QuayJetstackImages: pullMode
    disable: "{{ $var.prerequisiteAlreadyInstalled }}"
    actionHooks:
      - scope: CLUSTER
        type: VALIDATE
        commandTemplate: |
          export INTERNAL_HELM_PHASE=validate
          {{ $file:./custom_scripts/cert_manager_runtime_skip.sh }}
      - scope: CLUSTER
        type: POST_INSTALL
        commandTemplate: |
          export INTERNAL_HELM_PHASE=post-install
          {{ $file:./custom_scripts/cert_manager_runtime_skip.sh }}
    helmChartConfiguration:
      chartName: cert-manager
      chartVersion: v1.21.0
      chartRepoName: jetstack
      chartRepoURL: https://charts.jetstack.io
      releaseName: cert-manager
      namespace: cert-manager
      runtimeConfiguration:
        wait: true
        waitForJobs: true
      layeredChartValues:
        - scope:
            "{{ $var.skipPrivateRegistry }}": 'true'
          values:
            crds:
              enabled: true
            imageRegistry: quay.io
            imageNamespace: jetstack
        - scope:
            "{{ $var.skipPrivateRegistry }}": 'false'
          values:
            crds:
              enabled: true
            imageRegistry: "{{ $var.privateRegistryUrl }}"
            imageNamespace: jetstack

  - name: MainApp
    dependsOn:
      - DockerHubImages
      - IngressNginx
      - CertManager
    apiParameters:
      - <<: *api-prereq-exists
        parameterDependencyMap:
          RegistryK8sImages: prerequisiteAlreadyInstalled
          QuayJetstackImages: prerequisiteAlreadyInstalled
          IngressNginx: prerequisiteAlreadyInstalled
          CertManager: prerequisiteAlreadyInstalled
      - <<: *api-skip-private-registry
        parameterDependencyMap:
          DockerHubImages: skipPrivateRegistry
          RegistryK8sImages: skipPrivateRegistry
          QuayJetstackImages: skipPrivateRegistry
          IngressNginx: skipPrivateRegistry
          CertManager: skipPrivateRegistry
      - <<: *api-private-registry-url
        parameterDependencyMap:
          DockerHubImages: privateRegistryUrl
          RegistryK8sImages: privateRegistryUrl
          QuayJetstackImages: privateRegistryUrl
          IngressNginx: privateRegistryUrl
          CertManager: privateRegistryUrl
      - <<: *api-pull-mode
        parameterDependencyMap:
          DockerHubImages: pullMode
          RegistryK8sImages: pullMode
          QuayJetstackImages: pullMode
          IngressNginx: pullMode
          CertManager: pullMode
    helmChartConfiguration:
      chartName: main-app
      chartVersion: 1.0.0
      chartRepoName: my-org
      chartRepoURL: oci://registry-1.docker.io/my-org
      releaseName: main-app
      namespace: main-app
      autoDiscoverImagesTag: "my-org.com/images"
      layeredChartValues:
        - scope:
            "{{ $var.skipPrivateRegistry }}": 'true'
          values:
            global:
              imageRegistry: docker.io
        - scope:
            "{{ $var.skipPrivateRegistry }}": 'false'
          values:
            global:
              imageRegistry: "{{ $var.privateRegistryUrl }}"

Runtime skip for existing prerequisites

Use disable when a user-provided parameter decides whether a resource should run. Use runtime skip when the installer must inspect the target cluster during validation. This is common for prerequisite charts because a customer may already have an ingress controller, cert-manager, CSI driver, or other platform component installed.

When a validation hook calls skip_resource_deployment, the installer records the skip reason for the current resource and skips its remaining deployment steps during the same run. A retry also uses the persisted skip state.

#!/usr/bin/env bash
set -uo pipefail

skip_current_resource() {
  local reason="$1"
  echo "Skipping resource: ${reason}"

  if declare -F skip_resource_deployment >/dev/null 2>&1; then
    skip_resource_deployment "$reason"
  else
    echo "SKIP: ${reason}"
  fi

  exit 0
}

helm_release_match() {
  local pattern="$1"
  helm list -A --all -o json 2>/dev/null \
    | jq -r --arg p "$pattern" '
        [.[] | select((.chart // "") | test($p))]
        | ((map(select(.status == "deployed")) | first) // first // empty)
        | if type == "object" then "\(.name)|\(.namespace)|\(.chart)|\(.status)" else empty end
      '
}

if [[ "${INTERNAL_HELM_PHASE:-validate}" == "validate" ]]; then
  match="$(helm_release_match "cert-manager")"
  if [[ -n "$match" ]]; then
    skip_current_resource "cert-manager already exists (${match})"
  fi
fi

If the hook detects an unhealthy existing installation, exit non-zero instead of skipping. Runtime skip should only be used when the existing resource is acceptable for the main application.

Building and releasing the installer

Use the Omnistrate CLI to build and release the installer:

omnistrate-ctl build \
  --spec-type ServicePlanSpec \
  --file installer-spec.yaml \
  --product-name "My Application Air-Gapped Installer" \
  --release \
  --release-description "v1.0.0 initial release"

Full Example

The following example shows a complete minimal spec that packages Supabase as an air-gapped installer. It includes a deployment block, API parameters for the release name, namespace, JWT secret, database password, and DNS endpoint, along with lifecycle action hooks and a Helm chart configuration.

The video walkthrough below demonstrates the end-to-end experience of building, releasing, and installing using this exact spec — Watch on YouTube.

Complete Supabase specification
# yaml-language-server: $schema=https://api.omnistrate.cloud/2022-09-01-00/schema/service-spec-schema.json
# yaml-language-server: $schema=https://api.omnistrate.cloud/2022-09-01-00/schema/system-parameters-schema.json

name: Supabase
deployment:
  requirements:
    k8sVersion: ">=1.30.0"  # optional
  onPremDeployment:
    # AWS account that hosts the installer artifacts
    AwsAccountId: '<your-aws-account-id>'
    AwsBootstrapRoleAccountArn: 'arn:aws:iam::<your-aws-account-id>:role/omnistrate-bootstrap-role'
  onPremInstallerTools:
    helperUserScript: |
      #!/bin/bash
      log_error() {
        echo "Error: $1" > /tmp/error.log
      }

services:
  - name: Supabase
    apiParameters:
      - name: releaseName
        key: releaseName
        type: String
        required: false
        modifiable: false
        export: true
        description: "Helm release name"
        defaultValue: "supabase"
      - name: namespace
        key: namespace
        type: String
        required: false
        modifiable: false
        export: true
        defaultValue: "supabase"
        description: "Kubernetes namespace to deploy Supabase into"
      - name: jwtSecret
        key: jwtSecret
        type: Password
        required: false
        modifiable: false
        export: true
        description: "JWT Secret"
      - name: dbPassword
        key: dbPassword
        description: "Database Password"
        type: Password
        modifiable: false
        export: true
        required: false
      - name: dnsEndpoint
        key: dnsEndpoint
        type: String
        required: false
        modifiable: false
        export: true
        description: "DNS Endpoint for Supabase Ingress"
        defaultValue: "example.com"

    actionHooks:
      - scope: CLUSTER
        type: VALIDATE
        commandTemplate: "echo 'Validate hook'"
      - scope: CLUSTER
        type: PRE_INSTALL
        commandTemplate: "echo 'Pre-install hook'"
      - scope: CLUSTER
        type: POST_INSTALL
        commandTemplate: "echo 'Post-install hook'"
      - scope: CLUSTER
        type: BACKUP
        commandTemplate: "echo 'Backup hook'"

    helmChartConfiguration:
      chartName: supabase
      chartVersion: 0.1.3
      chartRepoName: supabase
      chartRepoURL: https://example.github.io/supabase-charts/
      releaseName: "{{ $var.releaseName }}"
      namespace: "{{ $var.namespace }}"
      chartValues:
        secret:
          jwt:
            secret: "{{ $var.jwtSecret }}"
          db:
            password: "{{ $var.dbPassword }}"
        kong:
          ingress:
            enabled: true
            className: "nginx"
            hosts:
              - host: "{{ $var.dnsEndpoint }}"
                paths:
                  - path: /
                    pathType: Prefix

Template syntax reference

Syntax Description
{{ $var.<key> }} References an API parameter by its key.
{{ $file:<path> }} Inlines the contents of a file at build time.
{{ $secret.<name> }} References an Omnistrate-managed secret.
{{ $var.onprem_platform }} Built-in variable: EKS, AKS, GKE, or GENERIC.