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:
| Pack | What it gives you |
|---|---|
webchat-bot | The bot flow — the deterministic graph that decides what to say. |
messaging-webchat-gui | A browser chat SPA served publicly at /v1/web/webchat/default/. No login, CORS on, talks DirectLine. |
messaging-telegram | A 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 yourPATH. - ~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
| Identity | Who it is | What 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.
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.
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.
./state.deployer slot to the Cloud Run handler.secrets slot, and write any from_env secrets into the dev-store.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.
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.
Name your project
Two variables. Every command after this one reads them.
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
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:
cargo binstall greentic-deployer-dev
--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:
cargo binstall --root /tmp/gtc-dev greentic-deployer-dev
# → /tmp/gtc-dev/bin/greentic-deployer-dev
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.
gcloud config set project $PROJECT
gcloud auth application-default login
Check it without deploying anything:
gcloud config get-value project
gcloud auth application-default print-access-token >/dev/null && echo "ADC OK"
Enable four APIs once per project
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— sogcloud logging readworks when a boot fails.
Enabling an API costs nothing, and they stay enabled after cleanup.
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.
gcloud iam service-accounts create gtc-local-runtime \
--display-name "Greentic runtime for env local" \
--project $PROJECT
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.
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.
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:
"project": "gen-lang-client-0845272684",
"region": "europe-west1",
Why each key is there
access_mode: "public"— grantsallUsers→roles/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:developtag, 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.
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.
"secrets": [
{ "path": "default/_/messaging-telegram/telegram_bot_token",
"from_env": "TELEGRAM_BOT_TOKEN" }
],
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
pathis<tenant>/<team>/<pack>/<name>._is the default team — a literaldefaultteam is rejected. - If the variable is unset on a mutating run,
env uptreats it as a missing input and refuses. On a TTY it prompts instead, masked.
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.
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.
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.--answersis a global flag, so it goes beforeenv up, not after.--yesskips the confirmation prompt.
Real output, about 46 seconds:
[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:
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
curl -s -o /dev/null -w '%{http_code}\n' "$URL/readyz"
curl -s "$URL/status" | python3 -m json.tool
{
"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.
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.
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
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:
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:
# 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.
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.
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
gcloud run services list --project $PROJECT --region $REGION
gcloud secrets list --project $PROJECT
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
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:
| Fate | Thing | Cost |
|---|---|---|
| Removed | Cloud Run service gtc-svc-… | — |
| Removed | Secret gtc-local-environment + its IAM binding | — |
| Removed | The allUsers invoker binding | — |
| Removed | Local ./state/local | — |
| Left behind | Service account gtc-local-runtime — zero roles | $0 |
| Left behind | Four enabled APIs | $0 while unused |
| Left behind | Cloud Logging entries | free tier, ~30 days |
| Never existed | An 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
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 check | On a build that cannot deploy Cloud Run |
|---|---|
op env --help | still lists up — it is the generic verb |
op env up --dry-run | green six-step plan, kind → …gcp-cloudrun |
op env apply --yes | exit 0, changed: 6, verify.failures: [] |
op env doctor | unknown_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:
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
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:
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.