Documentation

Air-gapped

How to run the Helm chart (deploy/helm/guard-spec) on a cluster that cannot reach ghcr.io, Docker Hub or Hugging Face: mirror the images with their signatures and attestations, mirror the models, verify everything offline, and point the chart at the mirrors. The same steps work for a cluster that may only pull from an internal registry.

Status: reference, not yet verified by the maintainers on a disconnected cluster. The cosign commands below were run against the 0.1.0 images; the chart values are checked by CI (helm lint, kubeconform) and the kind e2e. Reports are welcome, see docs/deploy/README.md.

docs/models.md covers the models alone, and docs/deploy/on-premise.md the compose path.

1. What to move

What Source Used by
Gateway image ghcr.io/guard-spec/guard-spec-gateway:<version> gateway pods; also the migrations hook (it copies /app/migrations) and the models.pull init container
or gateway-full ghcr.io/guard-spec/guard-spec-gateway-full:<version> gateway with the L2/L3 models inside: no model mirror needed
Control plane image ghcr.io/guard-spec/guard-spec-control-plane:<version> control plane pods
golang-migrate migrate/migrate:v4.20.1@sha256:76cc2074cb6642631f34a898ced71e6aeaa6b1a4d78c4daa743275a22e0c5be7 migrations.enabled
Postgres client postgres:17.11-alpine@sha256:266d3c257594e195b309ae9d1805e54c788f1d7b375f56bf6c367aac6c917e7b dbRoles.enabled
Models the files of models/manifest.yaml (about 250 MB for the recommended L2 + L3) models.pull
Chart deploy/helm/guard-spec of the release tag (also inside every release archive) helm install
Sigstore trusted root trusted_root.json offline signature checks

The Guard Spec images are signed with cosign (keyless, GitHub Actions OIDC) and carry an SPDX SBOM and SLSA provenance as BuildKit attestations inside the image index (SECURITY.md). The two third-party images are not ours: pin them by digest, as above.

2. On the connected side

V=0.1.1
ID='^https://github.com/guard-spec/guard-spec/'
ISSUER=https://token.actions.githubusercontent.com

# The trusted root (Fulcio CA, Rekor and CT log keys) for offline checks.
# cosign keeps the copy it fetched over TUF here after any online verify:
cosign verify ghcr.io/guard-spec/guard-spec-gateway:$V \
  --certificate-identity-regexp "$ID" --certificate-oidc-issuer "$ISSUER" >/dev/null
cp ~/.sigstore/root/tuf-repo-cdn.sigstore.dev/targets/trusted_root.json .

# Each image with its signature and attestations, as an OCI layout on disk.
for img in guard-spec-gateway guard-spec-control-plane; do   # + guard-spec-gateway-full
  cosign save --dir "out/$img" "ghcr.io/guard-spec/$img:$V"
done
# Third-party images: every platform, digest unchanged.
skopeo copy --all docker://migrate/migrate:v4.20.1@sha256:76cc2074cb6642631f34a898ced71e6aeaa6b1a4d78c4daa743275a22e0c5be7 oci:out/migrate:v4.20.1
skopeo copy --all docker://postgres:17.11-alpine@sha256:266d3c257594e195b309ae9d1805e54c788f1d7b375f56bf6c367aac6c917e7b oci:out/postgres:17.11-alpine

# Models: the recommended versions, every file checked against the manifest
# (the release archive's models tool has the release's manifest built in;
# it also holds the chart, under deploy/).
./models pull -dir out/models
./models verify -dir out/models

cosign save writes the image index as it is, with its per-platform manifests, the BuildKit SBOM and provenance attestation manifests, and the cosign signature. skopeo copy --all (or oras cp -r, crane copy) keeps digests; docker pull + docker push keeps only one platform and changes the index digest.

Carry out/, trusted_root.json and the release archive (or helm package deploy/helm/guard-spec) across.

3. Verify offline, then load into the registry

No network is needed: the signature bundles carry the Rekor inclusion proof, and trusted_root.json replaces the TUF lookup.

for img in guard-spec-gateway guard-spec-control-plane; do
  cosign verify --local-image "out/$img" --trusted-root trusted_root.json \
    --certificate-identity-regexp "$ID" --certificate-oidc-issuer "$ISSUER"
done

REG=registry.internal/guard-spec
for img in guard-spec-gateway guard-spec-control-plane; do
  cosign load --dir "out/$img" "$REG/$img:$V"
  # Again from the registry: the signature travelled with the image.
  cosign verify "$REG/$img:$V" --trusted-root trusted_root.json \
    --certificate-identity-regexp "$ID" --certificate-oidc-issuer "$ISSUER"
done
skopeo copy --all oci:out/migrate:v4.20.1 docker://$REG/migrate:v4.20.1
skopeo copy --all oci:out/postgres:17.11-alpine docker://$REG/postgres:17.11-alpine

# The SBOM and provenance stay readable from the mirror:
docker buildx imagetools inspect "$REG/guard-spec-gateway:$V" --format '{{json .Provenance}}' | head

The release archives are checked the same way: cosign verify-blob checksums.txt --bundle checksums.txt.sigstore.json --trusted-root trusted_root.json --certificate-identity-regexp "$ID" --certificate-oidc-issuer "$ISSUER", then sha256sum --ignore-missing -c checksums.txt.

To enforce signatures at admission, give the cluster's policy engine (Sigstore policy-controller, Kyverno verifyImages) the same identity, issuer and trusted root.

4. Mirror the models

models pull reads <mirror>/<name>/<version>/<file>, which is the layout of out/models (models/cache). Serve that directory from any static HTTP server inside the network (nginx, an S3, GCS or Azure Blob bucket, Artifactory generic repository). The mirror does not need to be trusted: every file is checked against the SHA-256 in the image's own manifest.yaml, and a mismatch is refused.

aws s3 sync out/models s3://models-internal/guard-spec/   # for example

Alternatively use guard-spec-gateway-full and skip the model mirror.

5. Point the chart at the mirrors

# values-airgapped.yaml
imagePullSecrets:
  - name: registry-internal
gateway:
  image:
    repository: registry.internal/guard-spec/guard-spec-gateway
    tag: "0.1.1"          # or "0.1.1@sha256:<index digest>" to pin the digest
controlPlane:
  image:
    repository: registry.internal/guard-spec/guard-spec-control-plane
    tag: "0.1.1"
migrations:
  enabled: true
  image:
    repository: registry.internal/guard-spec/migrate
    tag: v4.20.1
    # Same digest as Docker Hub when copied with skopeo --all; else empty it.
    digest: sha256:76cc2074cb6642631f34a898ced71e6aeaa6b1a4d78c4daa743275a22e0c5be7
dbRoles:
  enabled: true
  image:
    repository: registry.internal/guard-spec/postgres
    tag: 17.11-alpine
models:
  persistence:
    enabled: false       # each pod pulls into its own emptyDir; or keep a claim
  pull:
    enabled: true
    required: true       # a model that cannot be pulled stops the pod
    mirror: https://models.internal/guard-spec
networkPolicy:
  enabled: true
  egress:
    restrict: true
    gateway:
      - to: [{ipBlock: {cidr: 10.20.0.0/16}}]   # the models mirror and the LLM endpoint
        ports: [{port: 443, protocol: TCP}]

With gateway-full instead: gateway.image.repository=.../guard-spec-gateway-full, models.persistence.enabled=false and no models.pull.

Then, as in the README ("Kubernetes"):

helm install gs ./guard-spec-0.2.0.tgz -n guard-spec -f values-airgapped.yaml
kubectl -n guard-spec exec deploy/gs-guard-spec-gateway -c gateway -- ./doctor

doctor (in the images released after 0.1.1) checks the database and migration version, the model files against the manifest's SHA-256, which detection layers will load, the upstream and the security settings; exit code 1 means a check failed.

6. Secrets from an external store (External Secrets Operator)

The chart reads every secret from existingSecret (default guard-spec-secrets) and never stores one in values. With the External Secrets Operator that Secret is created and refreshed from a store such as HashiCorp Vault, which usually runs inside the disconnected network. The keys are those of values.yaml:

apiVersion: external-secrets.io/v1
kind: SecretStore
metadata:
  name: vault
  namespace: guard-spec
spec:
  provider:
    vault:
      server: https://vault.internal:8200
      path: secret
      version: v2
      caProvider: {type: ConfigMap, name: internal-ca, key: ca.crt}
      auth:
        kubernetes:
          mountPath: kubernetes
          role: guard-spec
          serviceAccountRef: {name: guard-spec-eso}
---
apiVersion: external-secrets.io/v1
kind: ExternalSecret
metadata:
  name: guard-spec-secrets
  namespace: guard-spec
spec:
  refreshInterval: 1h
  secretStoreRef: {kind: SecretStore, name: vault}
  target:
    name: guard-spec-secrets        # = existingSecret
    creationPolicy: Owner
  data:
    - {secretKey: POSTGRES_PASSWORD,      remoteRef: {key: guard-spec/db, property: owner_password}}
    - {secretKey: GUARDSPEC_APP_PASSWORD, remoteRef: {key: guard-spec/db, property: app_password}}
    - {secretKey: JWT_SECRET,             remoteRef: {key: guard-spec/control-plane, property: jwt_secret}}
    - {secretKey: MFA_ENC_KEY,            remoteRef: {key: guard-spec/control-plane, property: mfa_enc_key}}
    - {secretKey: ADMIN_PASSWORD,         remoteRef: {key: guard-spec/control-plane, property: admin_password}}
---
# Upstream keys for the gateway: gateway.extraEnvFrom: [{secretRef: {name: guard-spec-upstream}}]
apiVersion: external-secrets.io/v1
kind: ExternalSecret
metadata:
  name: guard-spec-upstream
  namespace: guard-spec
spec:
  refreshInterval: 1h
  secretStoreRef: {kind: SecretStore, name: vault}
  target: {name: guard-spec-upstream}
  data:
    - {secretKey: UPSTREAM_LLM_KEY, remoteRef: {key: guard-spec/upstream, property: api_key}}

Notes:

Source: docs/airgapped.md @ ca69dad29f02