greentic · cloud run demo
shell

Verified live · 2026-07-20 · europe-west1

A digital worker that costs nothing while idle.

One command puts a Greentic worker on Google Cloud Run — public, on a real https://….run.app URL, answering in a browser chat window and on Telegram. When nobody is talking to it, it scales to zero and bills no compute. Everything below is copy-pasteable verbatim, in your shell, on any machine.

Deploy to live URL
46 s
Cloud resources created
2
Idle compute bill
$0
Commands to type
9

What you are actually deploying

Greentic is a deterministic runtime for digital workers. You describe an environment in one JSON file, and a deployer turns that file into running infrastructure. This demo uses the gcp-cloudrun deployer, so the infrastructure is a single Cloud Run service.

The worker itself is a bundle — a signed, content-addressed archive pulled from public GHCR at container boot. This one carries three packs:

PackWhat it gives you
webchat-botThe bot flow — the deterministic graph that decides what to say.
messaging-webchat-guiA browser chat SPA served publicly at /v1/web/webchat/default/. No login, CORS on, talks DirectLine.
messaging-telegramA Telegram provider. Optional — it only activates if you seed a bot token.

Nothing about the worker is built on your machine and nothing is pushed to your project. The container image and the bundle are both pulled from public GHCR, pinned by digest. Your Google project ends up holding exactly two objects.

What you need first

  • A GCP project with billing enabled (the free tier covers this demo, but Cloud Run requires billing to be on).
  • gcloud, installed and authenticated.
  • A Rust toolchain with cargo-binstall — or any way to get one binary onto your PATH.
  • ~5 minutes. Steps 1–4 are once per project; after that the deploy is a single command.

How a Google Cloud Run deployment actually operates

Cloud Run is worth understanding before you deploy to it, because half the surprises in this demo come from the platform rather than from Greentic. Four ideas carry everything.

A service is a name; a revision is the thing that runs

You create a service — a stable name and a stable URL. Every deploy creates a new immutable revision: a container image plus its environment, its CPU and memory, its identity. Traffic is then split across revisions by percentage, which is why blue/green and canary come free — a 50/50 split is one API call, and rolling back is pointing 100% at the previous revision.

Greentic names the service gtc-svc-{deployment_ulid}, so the deployment ID is legible in the console.

Scaling is request-driven, and zero is a legal number

Cloud Run starts containers when requests arrive and stops them when they stop. With min_instances = 0 (the default here), an idle service runs no containers at all and bills no CPU and no memory. You pay for request-time compute, rounded to 100 ms, and nothing else.

The cost is a cold start: the first request after an idle period waits for the container to boot, pull its config, and load its bundle. Setting min_instances = 1 removes the cold start and reintroduces a permanent bill — which defeats the point of being here.

Configuration arrives as a secret, not as environment variables

The container does not get its environment as a pile of env vars. Greentic writes a single seed — the whole environment document — into Secret Manager as gtc-{env_id}-environment, and mounts it into the revision. The container reads the seed at boot, discovers which bundle to run, and pulls it.

That indirection is what makes the deploy small: the bundle reference, the routes, and the provider secrets all ride inside one document, and the only thing the revision needs is permission to read it.

Two identities, and neither one is the other

IdentityWho it isWhat it needs
Deployer You — ambient Application Default Credentials, written by gcloud auth application-default login. Permission to create services and secrets. On a personal project you are owner, so this is already true.
Runtime The service account the container runs as: gtc-{env_id}-runtime. Zero project-level roles. Its one permission — read the seed — is granted on the secret itself, at deploy time.

That is the security posture in one line: resource-scoped, not project-scoped. Verified after a real run, the runtime service account holds no project IAM bindings at all.

Where the URL comes from

You do not ask Cloud Run for a URL — it assigns one and returns it on the deploy response. There is no second API call to discover it. That matters later: the runtime learns its own public address from the first request that reaches it, which is how the Telegram webhook registers itself with no manual step.

Google answers some requests for you

Cloud Run's frontend swallows /healthz and returns Google's own branded 404 — the request never reaches your container. /health, /livez, /readyz and /status all arrive normally. Probe /readyz, never /healthz, or you will spend an hour debugging a healthy worker.

What the one command does

env up prints a six-step plan and then executes it. Each step is idempotent, so re-running the command is safe — the plan comes back all no-op and you get the same URL.

1 · local
ensure-environment — create the local environment record under ./state.
2 · local
update-host-config — resolve the host settings for the target.
3 · local
bootstrap-trust-root — establish the signing trust root for this environment.
4 · local
update-pack-binding — bind the deployer slot to the Cloud Run handler.
5 · local
update-pack-binding — bind the secrets slot, and write any from_env secrets into the dev-store.
6 · google
deploy-bundle — stage the seed into Secret Manager, create the service and its first revision, grant the runtime SA read on that secret, grant allUsers invoker, warm it, and return the URL.

Only the last step touches Google. Everything before it is local state, which is why --dry-run works with no credentials at all.

Then, inside the container

The revision boots, reads its seed from the mounted secret, resolves bundle_source_uri — an OCI reference, pinned by digest — pulls the bundle from public GHCR, loads its packs, and starts serving. /status reports bundles_active: 1 once all of that has happened.

The commands, start to finish

Pick your shell at the top of the page and every block below switches. Set your project once in step 0 and there is nothing left to hand-edit anywhere after it.

fish vs bash — only two real differences

Modern fish takes $(…), &&, || and even VAR=value cmd prefixes. What differs: fish has no heredocs (use a quoted multi-line string), and fish rejects bare = assignment (use set). Everything else on this page is byte-identical in both.

0setup

Name your project

Two variables. Every command after this one reads them.

fishbash / zsh
set -x PROJECT your-gcp-project-id
set -x REGION  europe-west1

mkdir -p ~/cloudrun-demo; cd ~/cloudrun-demo
export PROJECT=your-gcp-project-id
export REGION=europe-west1

mkdir -p ~/cloudrun-demo; cd ~/cloudrun-demo
1once

Install a deployer that can do Cloud Run once per machine

The Cloud Run deployer is a Cargo feature, deploy-gcp-cloudrun. It is a default feature — but it only exists on the develop lane, so a stable gtc has no Cloud Run deployer compiled in at all. On develop, binary crates publish under a sibling -dev name:

both shellsidentical in fish and bash
cargo binstall greentic-deployer-dev
A stale copy looks exactly like a fresh one

--version prints greentic-deployer 1.2.0-dev.0 for every nightly — the build identity lives only in the crates.io version, not in the binary. So an old greentic-deployer-dev on your PATH is indistinguishable from a current one. If anything below fails strangely, re-run the binstall first.

To keep it out of your ~/.cargo/bin, install it somewhere disposable and point the demo at it:

both shells
cargo binstall --root /tmp/gtc-dev greentic-deployer-dev
# → /tmp/gtc-dev/bin/greentic-deployer-dev
2once

Point gcloud at your project and log in once per machine

The deployer takes no --key-file. It runs as the ambient Application Default Credentials chain — which is a different credential store from gcloud auth login. You need the ADC one.

both shells
gcloud config set project $PROJECT
gcloud auth application-default login

Check it without deploying anything:

both shells
gcloud config get-value project
gcloud auth application-default print-access-token >/dev/null && echo "ADC OK"
3once

Enable four APIs once per project

both shells
gcloud services enable \
  run.googleapis.com \
  secretmanager.googleapis.com \
  artifactregistry.googleapis.com \
  logging.googleapis.com \
  --project $PROJECT
  • run — create the service and its revisions.
  • secretmanager — the seed the container boots from.
  • artifactregistry — needed for the API surface even though this demo creates no repo; the image and bundle both come from public GHCR.
  • logging — so gcloud logging read works when a boot fails.

Enabling an API costs nothing, and they stay enabled after cleanup.

4once

Create the runtime service account once per project

This is the identity the container runs as — not the identity that deploys. Naming is gtc-{env_id}-runtime, so environment local gives you gtc-local-runtime.

both shells
gcloud iam service-accounts create gtc-local-runtime \
  --display-name "Greentic runtime for env local" \
  --project $PROJECT
Grant it nothing

It needs no project-level role. The one permission it requires — reading the seed — is bound on the secret itself at deploy time. After a real run, get-iam-policy filtered to this member returns empty.

5manifest

Write the one file that describes the environment

env up consumes a greentic.env-manifest.v1 document — not a bag of answers. Note the absent cluster block: Cloud Run has no cluster, so the deployer's entire configuration is three keys plus a pinned image.

The two variable slots are written as @@…@@ markers and substituted on the way out, which keeps the JSON byte-identical in both shells.

fishbash / zshfish has no heredocs — quoted string instead
echo '{
  "schema": "greentic.env-manifest.v1",
  "environment": { "id": "local", "name": "cloudrun-byhand" },
  "trust_root": "bootstrap",
  "packs": [
    { "slot": "deployer",
      "kind": "greentic.deployer.gcp-cloudrun@1.0.0",
      "pack_ref": "builtin",
      "answers": {
        "project": "@@PROJECT@@",
        "region": "@@REGION@@",
        "access_mode": "public",
        "runtime_image_digest": "sha256:c122d86143293afec4389dbb57e4a8e9510849a0dd548207acd3892d63582920"
      } },
    { "slot": "secrets", "kind": "greentic.secrets.dev-store@1.0.0", "pack_ref": "builtin" }
  ],
  "bundles": [
    { "bundle_id": "cloudrun-byhand",
      "bundle_source_uri": "oci://ghcr.io/greenticai/greentic-demo-bundles/webchat-bot:webchat-tg-v1",
      "bundle_digest": "sha256:7608e322abf305172e20f7bab0607a36c0b0cc09c1d6869c7e5ba7ebfc094c47",
      "route_binding": { "path_prefixes": ["/"] } }
  ]
}' | sed -e "s|@@PROJECT@@|$PROJECT|" -e "s|@@REGION@@|$REGION|" > cloudrun.env.json

# the check — must print your real project, not the markers
python3 -m json.tool cloudrun.env.json | grep -E '"(project|region)"'
cat > cloudrun.env.json <<EOF
{
  "schema": "greentic.env-manifest.v1",
  "environment": { "id": "local", "name": "cloudrun-byhand" },
  "trust_root": "bootstrap",
  "packs": [
    { "slot": "deployer",
      "kind": "greentic.deployer.gcp-cloudrun@1.0.0",
      "pack_ref": "builtin",
      "answers": {
        "project": "$PROJECT",
        "region": "$REGION",
        "access_mode": "public",
        "runtime_image_digest": "sha256:c122d86143293afec4389dbb57e4a8e9510849a0dd548207acd3892d63582920"
      } },
    { "slot": "secrets", "kind": "greentic.secrets.dev-store@1.0.0", "pack_ref": "builtin" }
  ],
  "bundles": [
    { "bundle_id": "cloudrun-byhand",
      "bundle_source_uri": "oci://ghcr.io/greenticai/greentic-demo-bundles/webchat-bot:webchat-tg-v1",
      "bundle_digest": "sha256:7608e322abf305172e20f7bab0607a36c0b0cc09c1d6869c7e5ba7ebfc094c47",
      "route_binding": { "path_prefixes": ["/"] } }
  ]
}
EOF

# the check — must print your real project, not \$PROJECT
python3 -m json.tool cloudrun.env.json | grep -E '"(project|region)"'

It should print your real values:

expected output
        "project": "gen-lang-client-0845272684",
        "region": "europe-west1",

Why each key is there

  • access_mode: "public" — grants allUsersroles/run.invoker, so the URL works without a token. Use "authenticated" and callers need an identity token.
  • runtime_image_digest — pins the container image. Load-bearing: leave it out and the deployer uses the moving :develop tag, which Cloud Run caches for about an hour and which can resolve to a build too old to serve the webchat SPA or self-register the Telegram webhook.
  • bundle_source_uri — must be an OCI URI, not a local path. Cloud Run pulls the bundle over the network at container boot; a path on your laptop is meaningless to it.
  • bundle_digest — pins the content, so the boot is reproducible even though the tag can move.
  • route_binding.path_prefixes: ["/"] — this bundle serves everything.
optional

Telegram — declare the token in the manifest

Skip this to run webchat-only. To wire up the Telegram provider that ships in the bundle, add a top-level secrets array — a sibling of packs and bundles — and export the token. The manifest names the environment variable; the value never enters the file.

cloudrun.env.jsonadd alongside packs / bundles
  "secrets": [
    { "path": "default/_/messaging-telegram/telegram_bot_token",
      "from_env": "TELEGRAM_BOT_TOKEN" }
  ],
fishbash / zshget the token from @BotFather
set -x TELEGRAM_BOT_TOKEN 123456:your-token-here
export TELEGRAM_BOT_TOKEN=123456:your-token-here

That is the whole integration. env up resolves the variable at apply time, writes it to the per-environment dev-store, and stages it into the Cloud Run seed — all inside the single command in step 6. No separate secrets put, no temp file holding a token.

  • The path is <tenant>/<team>/<pack>/<name>. _ is the default team — a literal default team is rejected.
  • If the variable is unset on a mutating run, env up treats it as a missing input and refuses. On a TTY it prompts instead, masked.
Rotate anything you paste

A bot token is a bearer credential. If one has ever been in a chat window, a log, a screenshot, or a shell history file, rotate it in @BotFather. Also note that env destroy cannot clear the Telegram-side webhook — that lives on Telegram's servers, not in GCP.

6deploy

The one command

Progress lines go to stderr and the JSON envelope to stdout, so redirecting stdout to a file still lets you watch the plan scroll past.

fishbash / zsh
greentic-deployer-dev op --store-root ./state \
  --answers cloudrun.env.json env up --yes > up.json

set -x URL (python3 -c "import json;print(json.load(open('up.json'))['result']['endpoint_url'])")
set -x SVC (python3 -c "import json;print(json.load(open('up.json'))['result']['warmed'][0])")
echo "$URL  ($SVC)"
greentic-deployer-dev op --store-root ./state \
  --answers cloudrun.env.json env up --yes > up.json

export URL=$(python3 -c "import json;print(json.load(open('up.json'))['result']['endpoint_url'])")
export SVC=$(python3 -c "import json;print(json.load(open('up.json'))['result']['warmed'][0])")
echo "$URL  ($SVC)"
  • --store-root ./state — where the environment lives locally. Omit it and it defaults to ~/.greentic/environments; keeping it local makes the demo self-contained and disposable.
  • --answers is a global flag, so it goes before env up, not after.
  • --yes skips the confirmation prompt.

Real output, about 46 seconds:

observed
[1/6] ensure-environment     local            create…
[2/6] update-host-config     local            create…
[3/6] bootstrap-trust-root   local            create…
[4/6] update-pack-binding    deployer         update…
[5/6] update-pack-binding    secrets          update…
[6/6] deploy-bundle          cloudrun-byhand  create…
{"noun":"env","op":"up","result":{
  "applied_splits":1,
  "endpoint_url":"https://gtc-svc-01kxr5arxfhjay9krstpnrp99t-…-ew.a.run.app",
  "environment_id":"local",
  "kind":"greentic.deployer.gcp-cloudrun@1.0.0",
  "warmed":["gtc-svc-01kxr5arxfhjay9krstpnrp99t"]}}

If you lose the shell, both values are recoverable without redeploying:

fishbash / zsh
set -x SVC (gcloud run services list --project $PROJECT --region $REGION \
  --format='value(metadata.name)')
set -x URL (gcloud run services describe $SVC --project $PROJECT --region $REGION \
  --format='value(status.url)')
export SVC=$(gcloud run services list --project $PROJECT --region $REGION \
  --format='value(metadata.name)')
export URL=$(gcloud run services describe $SVC --project $PROJECT --region $REGION \
  --format='value(status.url)')

“Up” and “working” are different claims

both shells
curl -s -o /dev/null -w '%{http_code}\n' "$URL/readyz"
curl -s "$URL/status" | python3 -m json.tool
this is the real check
{
    "schema": "greentic.status.v1",
    "env_id": "local",
    "bundles_active": 1,
    "deployments_routed": 1,
    "revisions_active": 1
}

/readyz is a static route. It returns 200 ok from a runtime that pulled no bundle and loaded nothing at all. bundles_active is non-zero only once the seed parsed, the bundle pulled from GHCR, and its packs loaded.

The failure this demo exists to catch

bundles_active: 0 next to a green /readyz is the signature of a broken bundle reference. The service is up; the worker is not there. A liveness probe will never tell you.

Who answered — you or Google?

Worth knowing when a path 404s unexpectedly. A response with no server header came from Google's frontend; one carrying server: Google Frontend genuinely reached your container.

both shells
curl -sD- -o /dev/null "$URL/healthz" | grep -iE '^(HTTP|server)'
# HTTP/2 404                 ← no server header: Google answered, not you

curl -sD- -o /dev/null "$URL/nope" | grep -iE '^(HTTP|server)'
# HTTP/2 405
# server: Google Frontend    ← reached the container, which 405'd

When it is broken, read the container's own logs

both shellsdouble quotes — the filter interpolates $SVC
gcloud logging read \
  "resource.type=\"cloud_run_revision\" AND resource.labels.service_name=\"$SVC\"" \
  --project $PROJECT --limit 50 --freshness 1h

Talking to the worker

In a browser

The chat SPA is a public static route — no login, CORS on — backed by DirectLine underneath. Open it:

both shells
echo "$URL/v1/web/webchat/default/"

# headless check: 200 + text/html. A 405 means the image is too old.
curl -s -o /dev/null -w '%{http_code}\n' "$URL/v1/web/webchat/default/"

Under the hood, POST …/token mints a session JWT, POST …/v3/directline/conversations opens a conversation, and the bot answers with an Adaptive Card. All of it works from pack presence alone — no messaging endpoint has to be registered anywhere.

On Telegram — the webhook registers itself

If you seeded a token in step 5½, you do not run setWebhook. On Cloud Run the runtime derives its own public URL from the first inbound request's Host header — pinned to this service's own <name>-*.run.app address, so a forged Host cannot hijack the registration — and calls the provider's setup_webhook for you.

The curl "$URL/status" you already ran was that first request. So the webhook is registering as you read this. Confirm it:

both shells
# token check + the bot's @username
curl -s "https://api.telegram.org/bot$TELEGRAM_BOT_TOKEN/getMe" | python3 -m json.tool

# poll until the runtime has registered it (a few seconds)
curl -s "https://api.telegram.org/bot$TELEGRAM_BOT_TOKEN/getWebhookInfo" | python3 -m json.tool
#   "url": "https://<name>-….run.app/webhook/telegram"   ← set by the runtime

Then message the bot. It replies through the deployment.

Fallback on an older image

Self-registration needs the runtime image pinned in step 5. On an older build the webhook URL stays empty — then, and only then, register it by hand.

both shellsonly if auto-registration did not happen
curl -s "https://api.telegram.org/bot$TELEGRAM_BOT_TOKEN/setWebhook" \
  --data-urlencode "url=$URL/webhook/telegram" -d drop_pending_updates=true

What exists, what it costs, what survives teardown

both shells
gcloud run services list --project $PROJECT --region $REGION
gcloud secrets list --project $PROJECT
the entire cloud footprint
NAME
gtc-svc-01kxr5arxfhjay9krstpnrp99t   ← the Service
NAME
gtc-local-environment                ← the seed

Two objects. Leave it fifteen minutes without traffic and the container count drops to zero — visible in the console under Metrics → Container instance count. No compute is billed at zero instances.

Tear it down

both shells
greentic-deployer-dev op --store-root ./state env destroy local --confirm

Note the ordering: cloud resources are torn down before local state is removed. If teardown fails, the environment stays intact so you can retry, rather than orphaning cloud resources you can no longer name.

Does it clean up everything? Honestly, no

env destroy removes everything it created per run. It does not remove the one-time setup, and it was never meant to. Measured immediately after a real destroy:

FateThingCost
RemovedCloud Run service gtc-svc-…
RemovedSecret gtc-local-environment + its IAM binding
RemovedThe allUsers invoker binding
RemovedLocal ./state/local
Left behindService account gtc-local-runtime — zero roles$0
Left behindFour enabled APIs$0 while unused
Left behindCloud Logging entriesfree tier, ~30 days
Never existedAn Artifact Registry repo$0

No Artifact Registry repo is ever created, which is why standing storage cost is zero — the image and the bundle are both pulled from public GHCR. The deployer never creates one; its ar_repo answer only points at a repo you provisioned yourself.

So the honest claim is not “it costs nothing.” It is: zero Cloud Run compute while idle, plus a short enumerated list of standing charges — in practice the seed secret's active versions, a few cents a month, and nothing at all after a destroy.

To remove even the leftovers

both shells
gcloud iam service-accounts delete \
  gtc-local-runtime@$PROJECT.iam.gserviceaccount.com --project $PROJECT --quiet
gcloud services disable run.googleapis.com secretmanager.googleapis.com \
  --project $PROJECT --force

Or delete the project. The only way to be certain a cloud project costs nothing is for it not to exist.

Four ways this bites you

All four were found by running the demo, not by reading the code. They are the reason the script probes rather than assumes.

A build that cannot deploy Cloud Run plans a perfect deploy anyway

Run this against a stable 1.1.16, which has no Cloud Run deployer compiled in at all, and it does not say so:

What you'd checkOn a build that cannot deploy Cloud Run
op env --helpstill lists up — it is the generic verb
op env up --dry-rungreen six-step plan, kind → …gcp-cloudrun
op env apply --yesexit 0, changed: 6, verify.failures: []
op env doctorunknown_kinds: ["greentic.deployer.gcp-cloudrun@1.0.0"]

Three of the four happily plan and bind a deployer kind the binary cannot execute. Only doctor resolves the binding against the handler registry, so it is the only one that tells the truth. Probe with it, on a throwaway store, before you trust anything:

fishbash / zshmakes no cloud calls
set S (mktemp -d)
greentic-deployer-dev op --store-root $S --answers cloudrun.env.json env apply --yes >/dev/null
greentic-deployer-dev op --store-root $S env doctor local | python3 -m json.tool | grep -A1 unknown_kinds
rm -rf $S
#   "unknown_kinds": [],                                       ← good
#   "unknown_kinds": ["greentic.deployer.gcp-cloudrun@1.0.0"]  ← no Cloud Run in this build
S=$(mktemp -d)
greentic-deployer-dev op --store-root $S --answers cloudrun.env.json env apply --yes >/dev/null
greentic-deployer-dev op --store-root $S env doctor local | python3 -m json.tool | grep -A1 unknown_kinds
rm -rf $S
#   "unknown_kinds": [],                                       ← good
#   "unknown_kinds": ["greentic.deployer.gcp-cloudrun@1.0.0"]  ← no Cloud Run in this build
An absent NO is not a YES

The probe's first cut asked only “does unknown_kinds contain gcp-cloudrun?” — and passed a binary that has no op env up at all: doctor never ran, the answer was empty, and empty is not a match. It now demands a parseable report whose bound_slots includes deployer and whose unknown_kinds excludes cloudrun, and refuses on anything else.

Tag caching hands you yesterday's runtime

Cloud Run caches image tags for roughly an hour. Deploy against a moving :develop tag and you can get a build old enough that the webchat route 405s or the Telegram webhook never registers — with no error anywhere to explain it. Pin runtime_image_digest. To find a newer one:

both shells
gh api /orgs/greenticai/packages/container/greentic-start-distroless/versions \
  --jq '.[] | select(.metadata.container.tags[]?=="develop") | .name'

The pack matters as much as the image

Webchat GUI packs older than 0.5.10 did not declare the render_plan / encode / send_payload egress operations, so the bot's reply was silently rejected by the runner's declared-ops allowlist. The chat loaded and simply never answered. The bundle pinned here is past that.

gtc op is not the same binary

gtc op … delegates to greentic-operator — a different crate on its own release cadence. It inherits the deployer's default features, so Cloud Run will ride along automatically once a stable release carries it; but today's operator does not have it. That is why every command on this page names greentic-deployer-dev explicitly.