Greentic · Deployment Guide
Author the environment, reconcile it onto the cluster, and let a worker pod pull its bundle and serve HTTP — on OpenShift (OCP/OKD), where one default policy stands between you and a running pod. Two end-to-end walkthroughs: a plain Kubernetes deploy, and one backed by HashiCorp Vault.
Before you startThe OpenShift-specific steps below (the SCC grant, Routes, the Vault setup) follow standard OpenShift behavior but should be validated on your own cluster — when a pod won't schedule, oc get events and oc describe tell you exactly which admission rule fired. Everything uses installed binaries (gtc-dev install --release nextgen-deployer); nothing is built from source.
The deploy model is the same on any cluster. You author an environment with the deployer op verbs, then op env reconcile server-side-applies the rendered objects, and a worker pod boots greentic-start start --env, pulls its .gtbundle, digest-verifies it, and serves HTTP on :8080.
reconcile pushes objects; the worker pod self-pulls and serves.The deployer renders every worker/router pod with a fixed security context. UID 65532 is the distroless nonroot user:
// pod { "runAsNonRoot": true, "runAsUser": 65532, "runAsGroup": 65532, "fsGroup": 65532, "seccompProfile": { "type": "RuntimeDefault" } } // container { "allowPrivilegeEscalation": false, "readOnlyRootFilesystem": true, "capabilities": { "drop": ["ALL"] } }
This is a hardened, restricted-friendly profile — almost exactly what OpenShift wants. But OpenShift's default restricted-v2 SCC uses runAsUser: MustRunAsRange and fsGroup: MustRunAs, both pinned to the namespace-allocated UID range. A pod that explicitly demands UID/group 65532 falls outside that range, so it is rejected at admission and no pod is ever created.
nonroot-v2 keeps non-root, drop-ALL-caps, no-priv-esc, RuntimeDefault seccomp — it only relaxes the UID-range constraint, so the rendered 65532 is accepted as-is and survives every re-reconcile.The fix, in one lineoc adm policy add-scc-to-user nonroot-v2 -z <sa> -n gtc-<env> — granted to the ServiceAccount the pods run as (the project default SA for a plain deploy, or gtc-worker on the Vault path).
| # | Delta | Mandatory? | What you do |
|---|---|---|---|
| 1 | SCC — pods demand UID 65532; restricted-v2 forbids it | Yes | oc adm policy add-scc-to-user nonroot-v2 -z <sa> |
| 2 | External exposure — only a ClusterIP:8080 Service is rendered, no Ingress | To reach it | OpenShift Route (oc create route edge) — gives the HTTPS Telegram needs, no cert wrangling |
| 3 | Image pull — no imagePullSecrets / no imagePullPolicy rendered | Private reg / multi-node | Link pull secret to SA: oc secrets link <sa> <secret> --for=pull; pin runtime_image to a @sha256: digest |
| 4 | Project + RBAC — reconcile creates a Namespace, uses the default SA | Recommended | Pre-create with oc new-project; reconcile with a context that can create/patch the Namespace |
| 5 | Vault IPC_LOCK (Vault path only) — a dev-mode Vault asks for a forbidden capability | Vault path only | Drop the cap + VAULT_DISABLE_MLOCK=true, or the official Vault Helm chart with global.openshift=true |
NetworkPolicies are enforcedOpenShift's CNI (OVN-Kubernetes) enforces NetworkPolicies. The deployer's deny-by-default posture (gtc-default-deny + gtc-allow-router/workers + *-egress) therefore applies as written — and any co-located out-of-band component (such as an in-namespace Vault) needs an explicit ingress allow. Demo 2 ships exactly that policy.
| Tool / artifact | Why | Notes |
|---|---|---|
oc, logged in (oc login) | talks to OpenShift (superset of kubectl) | your token must create/patch a Namespace + namespaced objects + NetworkPolicies, and have the scc admin verb (cluster-admin) for the grant |
gtc-dev launcher + nextgen-deployer release | provides op env apply / env reconcile and the runtime-start binaries | installed below — no source build |
greentic-start-distroless:develop (2026-06-18+) | the container image the pods boot (pulled by the cluster, separate from the host CLI) | the stable tag predates the serve boot and never reaches Ready. Pin to a digest on OpenShift |
cluster egress to ghcr.io (or a mirror) | the pod pulls the runtime image and the .gtbundle at boot | disconnected clusters: mirror the image + host the bundle on a reachable registry |
Install the toolchain once (this is the only tooling install — everything afterward runs through it):
# the dev-channel launcher (skip if you already run gtc-dev) cargo binstall -y gtc-dev # installs the pinned deployer + operator + runtime-start companion binaries gtc-dev install --release nextgen-deployer # the op verbs are now available through the launcher: gtc-dev op --help
nextgen-deployer is a named release on the dev channel; the launcher resolves gtc-dev op … to the installed operator/deployer companions. Pin the container image separately (it is what the pods boot, not a host binary).
A worker pod that boots, pulls a bundle over OCI, and serves a Webchat console plus an optional Telegram endpoint. Secrets ride in via the built-in dev-store (rendered as a K8s Secret). This is the fastest path to a running worker.
export ENVID=local # env id 'local' -> project 'gtc-local' export PROJECT=gtc-local # = gtc-<ENVID> export STORE=$HOME/.greentic/environments export IMG='ghcr.io/greenticai/greentic-start-distroless:develop' # pin @sha256:… in prod
The project name is always gtc-<environment.id>.
oc new-project "$PROJECT" # creates the project; you're its admin # worker/router pods run as the project 'default' SA; grant nonroot-v2 # so the rendered UID 65532 is accepted, unmodified. oc adm policy add-scc-to-user nonroot-v2 -z default -n "$PROJECT"
Skipped it? No pods appear, and:oc -n "$PROJECT" get events --field-selector reason=FailedCreate → “…security context constraint: runAsUser … must be in the ranges …”
Write openshift.env.json:
{
"schema": "greentic.env-manifest.v1",
"environment": { "id": "local", "name": "openshift", "gui_enabled": true },
"trust_root": "bootstrap",
"packs": [
{ "slot": "deployer", "kind": "greentic.deployer.k8s@1.0.0",
"pack_ref": "builtin", "answers_ref": "deployer-answers.json" },
{ "slot": "secrets", "kind": "greentic.secrets.dev-store@1.0.0", "pack_ref": "builtin" }
],
"bundles": [
{ "bundle_id": "webchat-bot",
"bundle_source_uri": "oci://ghcr.io/greenticai/greentic-demo-bundles/webchat-bot:v1",
"bundle_digest": "sha256:4f560749ec709e75b6063cdeccab15ed5074c2e60bc5f772c2d3b7d4bd992363",
"route_binding": { "hosts": [], "path_prefixes": ["/"],
"tenant_selector": { "tenant": "tenant-default", "team": "default" } } }
],
"secrets": [
{ "path": "tenant-default/_/messaging-telegram/telegram_bot_token",
"from_env": "TELEGRAM_BOT_TOKEN" }
],
"messaging_endpoints": [
{ "name": "webchat-bot", "provider_type": "messaging.telegram.bot", "links": ["webchat-bot"] }
]
}and deployer-answers.json — note tunnel: off (you expose via a Route) and the pinned digest:
{
"runtime_image": "ghcr.io/greenticai/greentic-start-distroless@sha256:<digest>",
"tunnel": "off",
"router_replicas": 2
}Apply it (the bot token is passed inline, never written to a file; omit it for a webchat-only deploy):
env TELEGRAM_BOT_TOKEN=<your-bot-token> \ gtc-dev op --store-root "$STORE" --answers ./openshift.env.json env apply --yes
One env apply stands in for env create + two env-packs add + trust-root bootstrap + bundles add + revisions stage/warm + traffic set + the endpoint + the secret.
gtc-dev op --store-root "$STORE" env reconcile "$ENVID" # the worker Deployment is named gtc-worker-<ulid>; find it by label: export WORKER=$(oc -n "$PROJECT" get deploy -l app.kubernetes.io/component=worker \ -o jsonpath='{.items[0].metadata.name}') oc -n "$PROJECT" rollout status deploy/gtc-router --timeout=180s oc -n "$PROJECT" rollout status "deploy/$WORKER" --timeout=240s # confirm a REAL revision activated (not probes-only): oc -n "$PROJECT" logs "deploy/$WORKER" | grep -E "revision\(s\) for env|probes only" # SUCCESS: "... serving 1 revision(s) for env `local` ..."
Pod Pending / no pod → re-check step 2 (SCC). CrashLoopBackOff on pull → check egress to ghcr and the digest pin.
# edge-terminated TLS Route -> the worker Service on 8080 oc -n "$PROJECT" create route edge webchat-bot \ --service="$WORKER" --port=8080 --insecure-policy=Redirect oc -n "$PROJECT" get route webchat-bot -o jsonpath='{.spec.host}{"\n"}' # -> webchat-bot-gtc-local.apps.<your-cluster-domain>
Webhook ordering (chicken-and-egg)The worker auto-registers the Telegram webhook against environment.public_base_url at boot, but the Route host isn't known until the Route exists. Two-step: (1) reconcile + create the Route, read the host; (2) set "public_base_url": "https://<route-host>" on environment in the manifest, re-env apply, re-env reconcile. The worker restarts and registers against the stable URL.
# /chat and /workers/invoke are loopback-trusted — reach them via port-forward oc -n "$PROJECT" port-forward "deploy/$WORKER" 8080:8080 & sleep 2 curl -s http://127.0.0.1:8080/healthz; echo # 200 curl -s http://127.0.0.1:8080/status | jq . # {"revisions_active":1,"bundles_active":1,...} # open http://localhost:8080/chat in a browser for the Webchat console kill %1
oc delete project "$PROJECT" # tears down everything reconciled rm -rf "$STORE/$ENVID" # drop the host-side env store (optional)
The same worker, but it resolves its secret:// references from HashiCorp Vault under its Kubernetes ServiceAccount identity — so no secret values are written into the cluster (no K8s Secret object). The env is tenant-owned, which the runtime requires when serving a tenant deployment from a Vault-backed env.
export ENVID=vault-demo export PROJECT=gtc-vault-demo # = gtc-<ENVID>; Vault lives here too export TENANT=tenant-default export STORE=$HOME/.greentic/environments export IMG='ghcr.io/greenticai/greentic-start-distroless:develop' oc new-project "$PROJECT" oc -n "$PROJECT" create serviceaccount gtc-worker oc -n "$PROJECT" create serviceaccount vault # the worker runs as gtc-worker on the Vault path; Vault dev-mode runs as 'vault'. # grant nonroot-v2 to BOTH so neither is rejected by restricted-v2. oc adm policy add-scc-to-user nonroot-v2 -z gtc-worker -n "$PROJECT" oc adm policy add-scc-to-user nonroot-v2 -z vault -n "$PROJECT"
Dev mode is in-memory, auto-unsealed, root token root — for the round-trip only, not production. The OpenShift edits vs a vanilla manifest: no IPC_LOCK capability, and VAULT_DISABLE_MLOCK=true.
apiVersion: rbac.authorization.k8s.io/v1 kind: ClusterRoleBinding metadata: { name: gtc-vault-demo-auth-delegator } roleRef: { apiGroup: rbac.authorization.k8s.io, kind: ClusterRole, name: system:auth-delegator } subjects: [{ kind: ServiceAccount, name: vault, namespace: gtc-vault-demo }] --- apiVersion: apps/v1 kind: Deployment metadata: { name: vault, namespace: gtc-vault-demo, labels: { app: vault } } spec: replicas: 1 selector: { matchLabels: { app: vault } } template: metadata: { labels: { app: vault } } spec: serviceAccountName: vault containers: - name: vault image: hashicorp/vault:1.17 args: ["server","-dev","-dev-listen-address=0.0.0.0:8200","-dev-root-token-id=root"] env: - { name: VAULT_DISABLE_MLOCK, value: "true" } # no IPC_LOCK under restricted SCC ports: [{ containerPort: 8200 }] readinessProbe: httpGet: { path: /v1/sys/health, port: 8200 } initialDelaySeconds: 3 periodSeconds: 5 --- apiVersion: v1 kind: Service metadata: { name: vault, namespace: gtc-vault-demo } spec: selector: { app: vault } ports: [{ port: 8200, targetPort: 8200 }] --- # the deployer's gtc-default-deny blocks ingress to ALL pods (incl. Vault). # open Vault:8200 to worker pods so login + KV read aren't dropped. apiVersion: networking.k8s.io/v1 kind: NetworkPolicy metadata: { name: allow-vault-ingress-from-workers, namespace: gtc-vault-demo } spec: podSelector: { matchLabels: { app: vault } } policyTypes: [Ingress] ingress: - from: [{ podSelector: { matchLabels: { app.kubernetes.io/component: worker } } }] ports: [{ protocol: TCP, port: 8200 }]
oc apply -f vault.yaml oc -n "$PROJECT" rollout status deploy/vault --timeout=120s
Run inside the Vault pod so it uses its own SA token + CA as the TokenReview reviewer. Teaches Vault to trust the gtc-worker SA and hand it a read-only token scoped to this env + tenant.
oc -n "$PROJECT" exec -i deploy/vault -- \ env VAULT_ADDR=http://127.0.0.1:8200 VAULT_TOKEN=root sh -s <<'EOF' set -e # dev-mode already mounts secret/ as KV v2; enable transit + k8s auth vault secrets enable -path=transit transit 2>/dev/null || true vault write -f transit/keys/greentic vault auth enable kubernetes 2>/dev/null || true vault write auth/kubernetes/config kubernetes_host=https://kubernetes.default.svc # read-only policy: KV reads for this env+tenant + transit-decrypt only vault policy write gtc-worker-ro - <<'POL' path "secret/data/greentic/vault-demo/tenant-default/*" { capabilities = ["read"] } path "secret/metadata/greentic/vault-demo/tenant-default/*" { capabilities = ["read","list"] } path "transit/decrypt/greentic" { capabilities = ["update"] } POL # bind the gtc-worker SA (in this namespace) -> role -> policy vault write auth/kubernetes/role/gtc-worker \ bound_service_account_names=gtc-worker \ bound_service_account_namespaces=gtc-vault-demo \ policies=gtc-worker-ro ttl=1h EOF
vault.env.json — the secrets slot is Vault, and tenant_org_id makes the env tenant-owned (a bare env fails the scope guard for a tenant deployment):
{
"schema": "greentic.env-manifest.v1",
"environment": { "id": "vault-demo", "name": "vault-demo", "tenant_org_id": "tenant-default" },
"trust_root": "bootstrap",
"packs": [
{ "slot": "secrets", "kind": "greentic.secrets.vault@0.1.0",
"pack_ref": "builtin", "answers_ref": "secrets-vault-answers.json" },
{ "slot": "deployer", "kind": "greentic.deployer.k8s@1.0.0",
"pack_ref": "builtin", "answers_ref": "deployer-answers.json" }
],
"bundles": [
{ "bundle_id": "webchat-bot", "customer_id": "demo-customer",
"bundle_source_uri": "oci://ghcr.io/greenticai/greentic-demo-bundles/webchat-bot:v1",
"bundle_digest": "sha256:4f560749ec709e75b6063cdeccab15ed5074c2e60bc5f772c2d3b7d4bd992363",
"route_binding": { "hosts": [], "path_prefixes": ["/"],
"tenant_selector": { "tenant": "tenant-default", "team": "default" } } }
]
}secrets-vault-answers.json (addr is Vault as seen from inside the cluster; role matches the bootstrap):
{ "addr": "http://vault.gtc-vault-demo.svc:8200", "role": "gtc-worker" }Reuse deployer-answers.json from Demo 1 (the tunnel: off + digest one). Then:
gtc-dev op --store-root "$STORE" --answers ./vault.env.json env apply --yes
Seeding goes through Greentic's secrets-put path — which transit-encrypts the value — not a raw vault kv put. Port-forward Vault for the admin write:
oc -n "$PROJECT" port-forward svc/vault 8200:8200 &
sleep 2
cat > seed.json <<'JSON'
{ "environment_id":"vault-demo",
"path":"tenant-default/_/messaging-telegram/telegram_bot_token",
"value":"<your-bot-token>" }
JSON
VAULT_ADDR=http://127.0.0.1:8200 VAULT_TOKEN=root \
gtc-dev op --store-root "$STORE" --answers ./seed.json secrets put
kill %1 # stop the admin port-forward; the worker reads Vault under its own SAA kubectl get/vault kv get on this path shows ciphertext, never the plaintext. Any per-endpoint webhook secret is seeded the same way under the env-owner tenant.
gtc-dev op --store-root "$STORE" env reconcile "$ENVID"
export WORKER=$(oc -n "$PROJECT" get deploy -l app.kubernetes.io/component=worker \
-o jsonpath='{.items[0].metadata.name}')
oc -n "$PROJECT" rollout status "deploy/$WORKER" --timeout=240s# A. there must be NO gtc-dev-secrets Secret oc -n "$PROJECT" get secret | grep gtc-dev-secrets && echo "UNEXPECTED" || echo "absent ✓" # B. the worker carries the Vault identity, not a Secret mount oc -n "$PROJECT" get deploy "$WORKER" -o jsonpath='{.spec.template.spec.serviceAccountName}{"\n"}' # -> gtc-worker oc -n "$PROJECT" set env deploy/"$WORKER" --list | grep -E 'GREENTIC_SECRETS_BACKEND|VAULT_ADDR|VAULT_K8S_ROLE' # -> GREENTIC_SECRETS_BACKEND=vault, VAULT_ADDR=…, VAULT_K8S_ROLE=gtc-worker # C. the bundle pulled + activated oc -n "$PROJECT" port-forward "deploy/$WORKER" 8080:8080 & sleep 2; curl -s http://127.0.0.1:8080/status | jq '.revisions_active'; kill %1 # D. (optional) Vault audit shows the worker's authenticated reads oc -n "$PROJECT" exec deploy/vault -- env VAULT_ADDR=http://127.0.0.1:8200 VAULT_TOKEN=root \ vault audit enable file file_path=stdout 2>/dev/null || true oc -n "$PROJECT" logs deploy/vault --since=10m | grep -E 'kubernetes/login|transit/decrypt|"read"'
oc delete clusterrolebinding gtc-vault-demo-auth-delegator oc delete project "$PROJECT" rm -rf "$STORE/$ENVID"
For production, don't hand-roll Vault — use the official HashiCorp Vault Helm chart with global.openshift=true, or point addr at an external Vault outside the cluster.
imagePullPolicy unset → non-digest tags default to IfNotPresent; a warm node can serve a stale layer. Pin runtime_image to a @sha256: digest.imagePullSecrets not rendered. For a private registry, oc secrets link <sa> <secret> --for=pull — OpenShift honors SA-linked pull secrets without the Deployment declaring them.oc whoami). For unattended/CI reconcile, use a bound SA token with namespaced admin in gtc-<env>.