Skip to main content

Air-Gapped Installation

This guide covers deploying Plexicus to clusters that have no outbound internet access, can only reach an internal container registry, or operate under strict egress policies. The Plexicus umbrella chart is fully air-gap capable — every external dependency is configurable and every optional integration that phones home can be disabled.

Chart support is mature; this guide is a starter

The umbrella chart fully supports air-gapped deployments via image mirroring and pluggable cert-manager issuers. The authoritative step-by-step mirror procedure ships inside the chart artifact under docs/image-registry.md (extract via helm pull --untar). This page is a high-level orientation for self-hosted customers — it summarises what to mirror, what to override, and points at the bundled material. Examples on this page assume chart 1.2.32 or later.


What Air-Gapped Means Here

In this guide, "air-gapped" refers to any of the following deployment scenarios:

  • The Kubernetes cluster has no outbound internet access at all
  • The cluster can only reach an internal mirror registry, not the public Google Artifact Registry
  • Strict egress policies prevent reaching public OAuth providers, public ACME servers (Let's Encrypt), or external AI APIs

The Plexicus chart is designed to support these environments — every external dependency is configurable, and every optional integration that requires public internet access can be disabled.


High-Level Procedure

  1. Mirror all images to your internal registry.

    The full image set falls into three categories depending on whether global.imageRegistry rewrites the pull path.

    Category A — rewritten by global.imageRegistry (mirror under the same registry prefix)

    ImageConditional?
    europe-west3-docker.pkg.dev/plexicus-registry/platform-prod/fastapi:1.2.36Always on
    .../worker:1.2.36Always on
    .../frontend:1.2.36Always on
    .../analysis-scheduler:1.2.36Always on
    .../codex-remedium:1.2.36codex-remedium.enabled: true
    .../exporter:1.2.36exporter.enabled: true
    .../plexalyzer-code:1.2.36plexalyzer-code.enabled: true
    .../plexalyzer-prov:1.2.36plexalyzer-prov.enabled: true
    .../strix:1.2.36strix.enabled: true
    docker.io/temporalio/admin-tools:1.29.1-tctl-1.18.4-cli-1.5.0Always on — used by the worker-temporal-setup post-install Job; also rewritten by global.imageRegistry. Mirror it under the same registry prefix as the nine service images.

    Category B — NOT rewritten by global.imageRegistry (must be overridden explicitly)

    ImageWhere usedOverride path
    europe-west3-docker.pkg.dev/.../mongo-seed-data:latestMongoDB seed initContainerOverride mongodb.initContainers[0].image in your values overlay (see step 2)
    busybox:1.36Every Plexicus service wait-for-dependencies initContainerNo per-image override key exists. Set global.dependencies.waitEnabled: false to skip the wait loop entirely, or pre-load busybox:1.36 into your mirror
    postgres:15-alpineTemporal admintools and server initContainersOverride the entire temporal.admintools.additionalInitContainers and temporal.server.initContainers blocks in your values overlay
    Bitnami infra images (mongodb, redis, postgresql, minio, os-shell, and sidecars)Bundled subchartsPer-subchart image.registry / image.repository / image.tag overrides. Warning: Bitnami subchart defaults use :latest tags — pin specific tags (e.g., mongodb.image.tag: 8.0.8-debian-12-r4) before mirroring to avoid capturing a moving digest
    Temporal own images (server, ui, admin-tools via temporal subchart values)temporal.enabled: truetemporal.server.image, temporal.admintools.image, temporal.ui.image

    Category C — Temporal's embedded monitoring stack (strongly recommend disabling)

    The temporal-0.73.2.tgz subchart bundles Prometheus and Grafana enabled by default. Disable them in your overlay to avoid pulling 7 additional images:

    temporal:
    prometheus:
    enabled: false
    grafana:
    enabled: false

    If you need Temporal metrics, mirror all 7 images instead: grafana/grafana:11.0.0, quay.io/prometheus/prometheus:v2.53.0, quay.io/prometheus/alertmanager:v0.27.0, quay.io/prometheus/node-exporter:v1.8.1, quay.io/prometheus/pushgateway:v1.8.0, registry.k8s.io/kube-state-metrics/kube-state-metrics:v2.12.0, quay.io/prometheus-operator/prometheus-config-reloader:v0.74.0.

    The bundled-infra overlay (values-bundled-infra.yaml) already sets temporal.prometheus.enabled: false and temporal.grafana.enabled: false.

    Optional — only needed for helm test or Temporal test hooks

    • busybox (no tag, resolves to :latest) — used by every service's helm test pod. No override exists; pre-load into your mirror or skip helm test in air-gapped environments.
    • docker.io/curlimages/curl:7.85.0 — used by Temporal subchart test hooks. Mirror it or note that hook failures do not affect Temporal's runtime operation.

    Full procedure: see docs/image-registry.md inside the chart artifact (extracted via helm pull --untar).

    Bitnami licensing change (August 2025)

    Bitnami moved their official chart images to a paid distribution. Free legacy images live under docker.io/bitnamilegacy/*. If you mirror these for air-gapped use, pull from bitnamilegacy/* and re-tag into your internal registry. Production deployments on amd64 Kubernetes can use these images directly.

  2. Mirror the mongo-seed-data init image separately. The MongoDB seed image (platform-prod/mongo-seed-data) is an init container passed directly to the Bitnami subchart and is not covered by global.imageRegistry. It must be mirrored independently and overridden explicitly in your values overlay:

    mongodb:
    extraVolumes:
    - name: shared-init-data
    emptyDir: {}
    initContainers:
    - name: data-extractor
    image: "registry.internal.example.com/plexicus/mongo-seed-data:latest"
    imagePullPolicy: Always
    securityContext:
    runAsUser: 0
    command: ["sh", "-c",
    "(cp -r /seed-data/. /shared-data/ 2>/dev/null || true) && chown -R 1001:1001 /shared-data/ && chmod -R 755 /shared-data/"]
    volumeMounts:
    - name: shared-init-data
    mountPath: /shared-data

    Replace registry.internal.example.com/plexicus/mongo-seed-data:latest with the path where you mirrored the image from europe-west3-docker.pkg.dev/plexicus-registry/platform-prod/mongo-seed-data:latest. The init container is idempotent and can be re-run safely.

  3. Set global.imageRegistry in your values overlay. This single field redirects the 9 custom Plexicus service images and the temporalio/admin-tools image used by the worker-temporal-setup post-install Job. It does not redirect mongo-seed-data, busybox:1.36, postgres:15-alpine, Bitnami subchart images, or Temporal's own monitoring stack (see step 1 Category B above).

    global:
    imageRegistry: "registry.internal.example.com/plexicus"
    imagePullSecrets:
    - name: my-registry-secret # pull secret for your mirror
    security:
    allowInsecureImages: true # required when mirror is non-Bitnami

    Leave global.imageRegistry empty to pull directly from Plexicus GAR (requires network access and the gar-secret pull secret). Setting it to your mirror removes the runtime dependency on Plexicus's GCP account.

  4. Replace Let's Encrypt with an internal CA. Override global.certManager.clusterIssuer to the name of your in-cluster issuer (for example, internal-ca-issuer). Provision the corresponding ClusterIssuer resource before running helm install.

    global:
    certManager:
    enabled: true
    clusterIssuer: "internal-ca-issuer"
  5. Apply the plexicus-license Secret. The license Secret is cluster-local and makes no external network calls — it is mounted as a file at /etc/plexicus/ inside the fastapi and worker pods. Create it in the plexicus namespace before installing the chart:

    kubectl -n plexicus create secret generic plexicus-license \
    --from-file=license.jwt=/path/to/license.jwt
    # add --from-file=license-key.pem=/path/to/license-key.pem if provided

    In a fully disconnected environment, transfer the license file(s) to the air-gapped host via your normal secure file-transfer process (USB, scp over a bastion, or a pre-provisioned package). A missing Secret surfaces as CreateContainerConfigError; a present-but-invalid or expired license surfaces as CrashLoopBackOff (fastapi/worker call SystemExit at startup) — see the license troubleshooting section of the install guide.

  6. Disable integrations that require public internet. Leave the following keys empty or unset in your values overlay:

    IntegrationValues key(s) to leave empty
    Cloudflare Turnstileglobal.required.turnstile.siteKey / global.required.turnstile.secretKey
    Google OAuthglobal.required.oauth.google.clientId / global.required.oauth.google.clientSecret
    Public OpenAIUse global.required.ai.* to point at a self-hosted or internal Azure OpenAI endpoint instead
    AI SAST scanning needs a reachable BAAI/bge-m3 embedding endpoint

    Since chart 1.2.27, every AI SAST scan indexes the target repo with the BAAI/bge-m3 embedding model before scanning it — this is a hard dependency, not an opt-in feature, so an air-gapped cluster with no path to an embedding endpoint has AI SAST silently impossible, not merely degraded. The public default (DeepInfra, for both chat and embeddings) is outside the air gap. You have two options:

    • Mirror/self-host an embedding-capable model server that serves BAAI/bge-m3 at 1024 dimensions inside your cluster or reachable internal network, and point worker.envs.PLEXICUS_AI_SCAN_EMBEDDING_BASE_URL (plus PLEXICUS_AI_SCAN_EMBEDDING_API_KEY in the plexicus-worker Secret) at it.
    • Enable the bundled AI Gateway (ai-gateway.enabled: true, existingSecret: plexicus-ai-gateway) as a single egress point for all AI traffic, and allow only that one endpoint through your egress policy instead of opening every service to the public internet individually.

    Either way, PLEXICUS_AI_SCAN_EMBEDDING_DIMENSIONS (default 1024) MUST match what your embedding endpoint actually outputs — a mismatch corrupts the index silently instead of erroring. See Self-Hosted Installation → Key catalog for the full credential list.

    Turnstile test tokens are not air-gap safe

    The chart ships with Cloudflare's "always passes" test tokens by default (1x00000000000000000000AA), and the evaluation overlay (values-evaluation.yaml) does not override them. When global.required.turnstile.siteKey is non-empty, the frontend loads the Turnstile widget JavaScript from challenges.cloudflare.com — an outbound call to Cloudflare. For true air-gapped deployments, explicitly blank both keys in your overlay:

    global:
    required:
    turnstile:
    siteKey: ""
    secretKey: ""

    With both keys empty the Turnstile widget is skipped and no outbound call is made.

  7. Optionally disable the dependency wait-loop. If your cluster DNS does not resolve the bundled service names before first boot, set:

    global:
    dependencies:
    waitEnabled: false

    Or override individual hosts:

    global:
    dependencies:
    redis:
    host: "my-redis.internal"
    temporal:
    host: "temporal.internal"
  8. Install with Helm (or ArgoCD). Pass your air-gapped values overlay alongside the standard customer overlay:

    export CHART_VERSION=1.2.32   # use the version you received from Plexicus
    helm install plexicus \
    oci://my-internal-registry.example.com/charts/plexicus \
    --version $CHART_VERSION \
    --namespace plexicus --create-namespace \
    -f values.yaml \
    -f values-override.yaml

Authoritative Reference

For the complete mirror procedure — every image to mirror, exact tags, registry auth wiring, and the mongo-seed-data init container override — pull and extract the chart artifact and read the bundled docs/image-registry.md:

helm pull oci://europe-west3-docker.pkg.dev/plexicus-registry/charts/plexicus \
--version $CHART_VERSION --untar
cat plexicus/docs/image-registry.md

This file is the source of truth, maintained alongside the chart and shipped with the version you installed. For environment-specific questions, contact engineering@plexicus.ai.


Verifying No Phone-Home

After install, verify the cluster makes zero unexpected outbound calls. Suggested checks:

  • NetworkPolicy — two independent layers. Network isolation in Kubernetes operates at two levels, and you may need both:

    1. Per-service chart policies (opt-in): The chart ships NetworkPolicy templates for each Plexicus service, controlled by <service>.networkPolicy.enabled: true. These are disabled by default and add per-pod ingress/egress rules when enabled. Inspect what is currently active with:

      kubectl get networkpolicy -n plexicus
    2. Cluster-level default-deny baseline (operator responsibility): In restricted environments, apply a default-deny NetworkPolicy to the plexicus namespace before enabling per-service allow rules. Without a default-deny baseline, the per-service allow policies are redundant — all traffic is permitted anyway. The chart does not deploy this cluster-level baseline; operators must apply it separately based on their security posture. A minimal example:

      apiVersion: networking.k8s.io/v1
      kind: NetworkPolicy
      metadata:
      name: default-deny-all
      namespace: plexicus
      spec:
      podSelector: {}
      policyTypes:
      - Ingress
      - Egress

      Apply the default-deny first, then enable per-service chart policies so that explicit allow rules take effect.

  • Managed-SaaS integrations — confirm that any env vars referencing services Plexicus operates on the managed SaaS (commercial integrations) are unset in your values. The chart ships with these disabled by default; verify nothing was accidentally set. See the Configuration Reference for the policy.

  • Egress audit — use a transparent egress proxy or DNS sink to capture any outbound DNS queries during a 1-hour soak after install.


Need Help?

For environment-specific guidance — custom CA chains, FIPS clusters, GovCloud, or air-gapped ArgoCD setups — contact engineering@plexicus.ai with your environment description.