Greentic AI · greentic-deployer · env-pack K8s path

Greentic on Kubernetes:
deployment guide & reproducible demo

How a Greentic digital worker is deployed to, and served from, a Kubernetes cluster using the environment-pack model of greentic-deployer (the op env … CLI surface) — the mental model, the rendered objects, the two-command flow, the demo from my_demos/k8s-deploy-demo, its config files, and how to reproduce it end-to-end on your own PC.

env-pack greentic.deployer.k8s@1.0.0 runtime greentic-start-distroless:develop demo webchat + Telegram cluster kind / EKS / GKE / AKS / k3s

01Mental model — two independent axes

The single most important thing to internalize.

"The cluster and the environment id are two independent axes." They are frequently conflated; they are not the same thing. One decides which cluster the objects land in; the other decides the logical environment name that drives the namespace and the store partition.

Axis A · Cluster locality
Which Kubernetes cluster the objects land in (kind, EKS, GKE, AKS, k3s, on-prem). Set by the kubeconfig context (kubeconfig_context answer, or the current context). The deployer is cluster-agnostic — nothing is kind-specific.
Axis B · Environment id
The logical environment name (local, prod, staging), which drives the namespace (gtc-<id>) and the store partition. Set by environment.id in the manifest, or op env create <id>.

A local store is single-operator: its authorization boundary is OS filesystem ownership, so named environments are first-class — you may keep the id local while targeting a remote production cluster, or give it a real name like prod. A shared, multi-operator control plane (an operator-store server with RBAC) is a separate, still-future effort.

The store vs. the cluster

The deployer separates authoring (writes the store) from applying (writes the cluster):

Author → the STORE
desired state · local FS · per-env partition
op env apply --answers op env create / op deploy op env-packs add …
<store-root>/<env-id>/…
Apply → the CLUSTER
live state · kube API server
op env reconcile <id> op env apply-revision
namespace gtc-<env-id>
No --reconcile flag on apply — by design.

op env apply / op deploy / op env-packs add mutate the store (the desired state) and never touch the cluster. op env reconcile <id> renders the desired state to manifests and pushes them onto the live cluster (and prunes workers for revisions no longer present). The two steps are deliberately separate so authoring is offline / dry-runnable and applying is an explicit, auditable act.

02What gets rendered onto the cluster

op env reconcile <id> renders a fixed set of objects into namespace gtc-<env-id>. From a webchat+Telegram env this is 15 objects.

KindCountName(s) / purpose
Namespace1gtc-<env-id> — see namespace derivation below.
Deployment2gtc-router (ingress routing, HA — ≥2 replicas) + one gtc-worker-<revision> per active revision (runs the bundle).
Service2ClusterIP on port 8080, one for the router and one per worker.
ConfigMap2gtc-runtime-config (projected runtime config) + gtc-env-store (environment.json).
Secret1gtc-dev-secrets — base64 of the operator dev-store, rendered only when the env binds a secrets pack. optional: true.
NetworkPolicy5Default-deny + scoped allow rules (e.g. gtc-allow-worker-egress, rendered when a routed revision has a bundle_source_uri).
PodDisruptionBudget1Keeps the router available during voluntary disruptions.

Key facts about the rendered topology

Namespace derivation

namespace_for_env(env_id) (src/env_packs/k8s/manifests.rs):

03Prerequisites

What you need before the two-command flow.

install / refresh the deployer toolchain

gtc-dev install --release nextgen-deployer
# add --force to overwrite an already-installed toolchain

Build from source instead? Use default features — the k8s-client feature is default-on and required by reconcile:

cargo build -p greentic-deployer --bin greentic-deployer
# binary at target/debug/greentic-deployer; invoke `… op …`
# a stale --no-default-features build fails reconcile with:
#   conflict: this build was compiled without the k8s-client feature
CLI shape

<deployer> op [GLOBAL FLAGS] <noun> <verb> [ARGS]. The global flags that matter come before the noun: --store-root <DIR> (the local FS store / desired state), --answers <PATH> (the env-manifest for env apply, or a verb's answer payload), and --store-url <URL> (target a remote operator-store server instead of the local FS store).

04The demo — my_demos/k8s-deploy-demo

The fastest path: 2 JSON files, 2 commands (plus one-time cluster bring-up). Brings up Webchat and Telegram — the K8s analog of the local setup --answers … && start --cloudflared on two-liner.

bundle webchat-bot (OCI-pulled) secrets dev-store bridge public URL cloudflared quick-tunnel webhook self-registering

What collapsed into two files

FileReplaces these op calls
k8s.env.json
(op env apply)
env create + 2× env-packs add + trust-root bootstrap + bundles add + revisions stage + revisions warm + traffic set + messaging endpoint add + endpoint link-bundle + secrets put
deployer-answers.json the deployer pack's runtime_image + tunnel answers

Why Telegram is hands-off

Three things the runtime does for you on K8s — the same way local does:

1
Secrets reach the pod.
The deployer renders the env dev-store as a K8s Secret and an init container stages it into the worker's HOME, so the bot token + the auto-provisioned webhook secret resolve in-pod. (Was the manual kubectl create secret + a bridge patch.)
2
The worker gets a public URL.
tunnel: cloudflared boots the worker with --cloudflared on; greentic-start spawns the cloudflared baked into the runtime image and discovers a *.trycloudflare.com URL. (Was a manual host-side cloudflared tunnel.)
3
The webhook registers itself.
With a public URL, greentic-start walks the served provider routes at boot and calls Telegram setWebhook with the webhook secret op env apply already minted. (Was a manual setWebhook + endpoint-id-keyed secret juggling.)
Two irreducible K8s commands

op env reconcile is always separate — apply writes the store, reconcile writes the cluster. And in the demo's all-in-one manifest, op deploy is folded in because apply stages the OCI bundle pullable; the only hard split that remains is store → cluster.

05Reproduce the demo on your PC

A complete, copy-paste fish walk-through against a local kind cluster. One token-bearing command; everything else is store + cluster plumbing.

Token redacted on this public page

The setWebhook / deleteWebhook calls and the apply step are token-bearing. The live bot token has been replaced with <YOUR_BOT_TOKEN> below — never paste a real Telegram bot token into a published document. Pass it inline (env VAR=val cmd) at apply time so it reaches the process on any shell and is never written to a file.

walk-through · fish

# ── config (paths are fixed) ───────────────────────────────────────────────
set -gx STORE /tmp/gtc-k8s-demo/.greentic/environments

# ── one-time only: create the cluster (skip if `kind get clusters` shows gtc-demo)
kind create cluster --name gtc-demo
kind export kubeconfig --name gtc-demo

# ── refresh the runtime image (kind won't re-pull a mutable :develop tag itself)
docker pull ghcr.io/greenticai/greentic-start-distroless:develop
kind load docker-image ghcr.io/greenticai/greentic-start-distroless:develop --name gtc-demo

# ── 1. author the env (THE one token-bearing command — inline your bot token) ─
env TELEGRAM_DEMO_BOT_TOKEN=<YOUR_BOT_TOKEN> \
    gtc-dev op --store-root $STORE --answers /home/vampik/greenticai/my_demos/k8s-deploy-demo/declarative/k8s.env.json env apply --yes

# ── 2. reconcile onto the live cluster ──────────────────────────────────────
gtc-dev op --store-root $STORE env reconcile local

# ── wait for the worker, then port-forward the webchat admin listener ────────
set WORKER (kubectl -n gtc-local get deploy -o name | string match '*worker*')
kubectl -n gtc-local rollout status $WORKER --timeout=240s
kubectl -n gtc-local port-forward $WORKER 8081:8081
#   → open http://localhost:8081/chat   (webchat — works alongside the tunnel)
#   → Telegram: just message the bot (worker auto-registered the webhook at boot)
Which port? It depends on the tunnel.

With the cloudflared tunnel up (the demo default), the main port 8080 serves provider webhooks only; the /chat console moves to the loopback-only admin listener at 8081 (main port + 1) — hence port-forward 8081:8081. The boot banner prints the exact admin port. With no tunnel, the console stays on 8080 and you'd port-forward 8080:8080.

Clean up

teardown + Telegram hygiene · fish

# stop any running port-forward (Ctrl+C in its terminal, or:)
pkill -f 'port-forward.*gtc-worker'

# nuke everything (cluster + node image cache)
kind delete cluster --name gtc-demo
rm -rf /tmp/gtc-k8s-demo

# Telegram hygiene — unpoint the bot from the now-dead tunnel (token-bearing, yours)
curl -s "https://api.telegram.org/bot<YOUR_BOT_TOKEN>/deleteWebhook"

# ── clean slate: drop any prior env from cluster + store ────────────────────
kubectl delete ns gtc-local --ignore-not-found --wait
rm -rf $STORE; mkdir -p $STORE
Deploying to a real cluster instead of kind?

Same two-command flow — only the cluster changes. Set kubeconfig_context in deployer-answers.json (or omit for the current context) and either set environment.public_base_url to your HTTPS Ingress host (production-shaped, leave tunnel off) or keep tunnel: cloudflared for a zero-infra demo URL. Pin runtime_image to a digest. The repo's real-cluster/ directory carries a ready-to-edit manifest pair.

06The demo's config files

Two files under declarative/. Secret values never appear here — only a from_env pointer that's read at apply time.

the env-manifest · declarative/k8s.env.json

{
  "schema": "greentic.env-manifest.v1",
  "environment": {
    "id": "local",
    "name": "k8s",
    "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_DEMO_BOT_TOKEN"
    }
  ],
  "messaging_endpoints": [
    {
      "name": "webchat-bot",
      "provider_type": "messaging.telegram.bot",
      "links": ["webchat-bot"]
    }
  ]
}

the deployer pack's answers · declarative/deployer-answers.json

{
  "runtime_image": "ghcr.io/greenticai/greentic-start-distroless:develop",
  "tunnel": "cloudflared"
}

Field-by-field

FieldMeaning
environment.idlocal → namespace gtc-local. Only local is auto-bootstrapped by apply; other ids need op env create <id> first.
environment.gui_enabledServe the loopback-trusted /chat webchat console.
trust_root"bootstrap" mints the env trust-root inline (no separate trust-root bootstrap call).
packs[].slotA deployer slot bound to greentic.deployer.k8s@1.0.0 (+ answers_ref), and a secrets slot bound to greentic.secrets.dev-store@1.0.0 for pod secrets.
bundles[]Declared by bundle_source_uri (the oci:// ref the worker pulls) + a bundle_digest integrity pin — no local bundle_path on the apply host. route_binding selects host/path-prefix + tenant_selector.
secrets[]{ path, from_env } — the bot-token value is read from $TELEGRAM_DEMO_BOT_TOKEN at apply; the value never goes in the manifest. The URI segment messaging-telegram is fixed (not the endpoint name).
messaging_endpoints[]provider_type: "messaging.telegram.bot"; links references a bundle_id.
runtime_imageImage for router + worker pods. Pin to a digest in production.
tunnelcloudflared → the worker spawns a quick tunnel and self-registers the webhook (single-revision only).

07Secrets — the dev-store bridge

The K8s model does not yet integrate a real secrets backend (AWS SM / Vault / native secretKeyRef). When an env binds greentic.secrets.dev-store@1.0.0:

1
Apply writes the dev-store.
op env apply (or op secrets put) writes secret values into the operator's local dev-store at <store>/<env>/.greentic/dev/.dev.secrets.env (AES-256-GCM per secret).
2
Reconcile renders a K8s Secret.
The deployer base64-encodes that dev-store file and renders it as the gtc-dev-secrets Secret.
3
An init container stages it into HOME.
A stage-dev-secrets busybox init container copies the file into the worker's writable HOME at $HOME/.greentic/environments/<id>/.greentic/dev/.dev.secrets.env — the path greentic-start's DevStore resolves. It must be writable: the DevStore opens the file write+create with flock on every read, so a read-only Secret mount would fail.
4
A hash annotation rolls pods on change.
The worker pod template carries a greentic.ai/dev-store-hash annotation derived from the secret bytes, so re-reconciling after a secret change rolls the worker pods (otherwise the init container only copies once at pod start).
Portability note

The dev-store master key is SHA256($GREENTIC_DEV_MASTER_KEY), defaulting to SHA256("") when unset on both host and pod. With the default (unset), the .dev.secrets.env file is fully portable — decryptable in-pod with no extra key material. This is the Phase-E gap: it works, but it is not a production secrets backend.

08Reaching the worker

Two surfaces, two trust postures.

SurfaceHowWhy
Telegram
public
Ingress/LB → worker Service :8080 over HTTPS (option A), or the cloudflared tunnel (option B). The worker auto-registers the webhook at boot. Provider webhooks self-authenticate (secret-token header).
Webchat /chat
private
kubectl port-forward to the worker. /chat and /workers/invoke are loopback-trusted and intentionally not served through the public edge.

The loopback-trust rule

The revision server trusts a caller only when (peer is loopback) AND (no public tunnel fronts this listener):

Deliberate security posture: cloudflared forwards from loopback, so public tunnel traffic would otherwise read as loopback and bypass the /workers/invoke gate. Routing the console to an untunneled, loopback-scoped admin listener closes that without exposing anything new to the network.

09Configuration reference

Every key, sourced from src/env_packs/k8s/.

Deployer answers — greentic.deployer.k8s@1.0.0 (answers_ref)

A flat JSON object keyed by wizard question id. All keys optional; unknown keys are rejected (fail closed on version skew).

KeyTypeDefaultEffect
kubeconfig_contextstringcurrent contextWhich kubeconfig context reconcile targets. Client-targeting only — not a manifest knob.
namespacestring (RFC 1123)gtc-<env-id>Override the namespace every object lands in.
runtime_imagestringgreentic-start-distroless:developContainer image for router + worker pods. Pin to a digest in production.
router_replicasint2Router replica count. Must be ≥ 2 (HA).
tunnel"off" | "cloudflared"offWorker public-exposure mode. cloudflared → worker spawns a quick tunnel (single-revision only).
oci_insecure_registriesstring[][]Registry authorities the worker/router may pull bundles from over plain HTTP. Rendered as GREENTIC_OCI_INSECURE_REGISTRIES. Empty → HTTPS only.

Env-manifest — greentic.env-manifest.v1 (K8s-relevant fields)

FieldNotes
environment.idDrives the namespace (gtc-<id>) and the store partition. Only local is auto-bootstrapped by apply.
environment.gui_enabledServe the /chat console (loopback-trusted).
environment.public_base_urlHTTPS URL for webhook auto-registration (option A). Omit when using a tunnel (the tunnel URL wins).
trust_root"bootstrap" to mint the env trust-root.
packs[]slotdeployer / secrets / … (lowercase). For K8s: a deployer slot (+ answers_ref) and a secrets slot if you need pod secrets.
bundles[]Declare the bundle by bundle_source_uri + bundle_digest — no local bundle_path needed. route_binding selects host/path-prefix + tenant_selector. Non-local envs require customer_id.
secrets[]{ path, from_env } — values come from from_env (read at apply) or paste; secret values never go in the manifest.
messaging_endpoints[]{ name, provider_type, links }. The URI segment for the bot-token secret is fixed messaging-telegram (not the endpoint name).

10Known gaps & production caveats

All verified in source. None silently broken — each is a deliberate current limitation with a workaround.

no ingress

No managed Ingress/LoadBalancer. The deployer renders only ClusterIP Services on :8080. External exposure is BYO-Ingress (§8 option A) or the ephemeral cloudflared tunnel (option B).

phase-e

Secrets use the dev-store bridge. The bot token is base64'd from the operator's local dev-store into a K8s Secret. It works but is not AWS SM / Vault / native secretKeyRef.

no pull secrets

No imagePullSecrets. A private runtime image or private bundle registry is not yet supported by the rendered manifests. The demo image and bundle are public ghcr.

stale layer

No imagePullPolicy. Non-digest tags default to IfNotPresent; pin a digest for deterministic pulls.

single-revision

Tunnel is single-revision. Each worker pod spawns its own cloudflared tunnel, so a traffic split registers N competing webhooks. For multi-revision / production use BYO-Ingress with a stable public_base_url.

single-operator

Named envs are first-class but single-operator. The local store authorizes any env id under filesystem ownership (local-owner policy). A shared, multi-operator control plane (--store-url server with RBAC + CAS) exists in scaffold form but remote stage/warm verbs are not yet wired end-to-end.

Reconcile authenticates as your ambient kubeconfig identity.

11Troubleshooting

Symptom → cause → fix.

SymptomCauseFix
conflict: this build was compiled without the k8s-client featureA deployer binary built --no-default-features.Rebuild with default features: cargo build -p greentic-deployer --bin greentic-deployer.
Worker CrashLoopBackOff / no revision servedBundle digest mismatch, or registry not reachable from the pod.Check the pod has internet egress; verify bundle_digest matches the oci:// artifact. The worker fails closed on mismatch.
Telegram setWebhook fails to resolve hostFresh *.trycloudflare.com not yet globally resolvable, or local DNS can't resolve it.Wait ~15–30s; verify via a public resolver (dig +short @1.1.1.1 <host>), then getWebhookInfo.
port-forward returns 502 after a rolloutport-forward binds one pod; a rollout replaces it.Restart the port-forward.
/chat returns 405/403 over the tunnelLoopback-trust posture: the tunneled main port serves webhooks only.Port-forward the admin listener (8081, main+1). The boot banner prints the port.
Webhook registration not visible in kubectl logsgreentic-start logs registration via OTLP / system.log, not pod stdout.Confirm with Telegram getWebhookInfo, not kubectl logs.
Secret change didn't take effectInit container copies the dev-store once at pod start.Re-reconcile — the greentic.ai/dev-store-hash annotation rolls the pods on a data change.

12Glossary

TermMeaning
env-packA pluggable capability bound to an environment slot (deployer, secrets, …). The K8s deployer is greentic.deployer.k8s@1.0.0.
storeThe local FS (or remote) record of desired state, partitioned per env id. Written by apply/deploy, read by reconcile.
reconcileRender the store's desired state to manifests and push them onto the live cluster (and prune stale workers).
revisionA staged, integrity-pinned snapshot of a bundle (pack_list, config_digest, bundle_digest, …). The worker pulls and serves it.
route_bindingHow a bundle's traffic is selected — host(s), path prefix(es), and tenant_selector.
dev-store bridgeThe mechanism that gets operator secrets into the pod via a rendered Secret + init container (the Phase-E placeholder for a real backend).
loopback-trustThe rule gating /chat + /workers/invoke: trusted only when the peer is loopback AND no tunnel fronts the listener.
admin listenerA loopback-only listener (main port + 1) that serves the console while the main port is tunneled.