clrnd is a command-line tool for deploying services to Google Cloud Run.
It takes a service name and a manifest file as arguments and provides eleven subcommands:
verify, render, diff, deploy, init, status, wait, revisions, rollback, delete,
and refresh.
Download a binary for your platform from the
releases page — linux, macOS and Windows, on amd64
and arm64. Each release ships a checksums.txt, a keyless cosign signature over it
(checksums.txt.bundle), an SBOM per archive, and a build provenance attestation.
# Verify the checksums were signed by this repository's release workflow
cosign verify-blob checksums.txt \
--bundle checksums.txt.bundle \
--certificate-identity-regexp '^https://github.com/masasuzu/clrnd/\.github/workflows/release\.yml@refs/tags/' \
--certificate-oidc-issuer https://token.actions.githubusercontent.com
# Then check the archive against them
shasum -a 256 -c checksums.txt --ignore-missing# example: macOS arm64
tar xzf clrnd_<version>_darwin_arm64.tar.gz
install clrnd /usr/local/bin/
clrnd --versionWith a Go toolchain (Go 1.26.7 or newer, matching go.mod):
go install github.com/masasuzu/clrnd@latestOr build from source:
git clone https://github.com/masasuzu/clrnd.git
cd clrnd
go build -o clrnd .clrnd completion bash|zsh|fish|powershell prints a shell completion script (cobra's built-in);
clrnd completion <shell> --help explains where your shell wants it installed.
clrnd uses Application Default Credentials (ADC)
to access the Cloud Run Admin API. Authenticate once with:
gcloud auth application-default login| Command | Permissions |
|---|---|
render |
none — it never contacts the API |
verify |
none for the local checks. The API existence checks additionally use iam.serviceAccounts.get, secretmanager.secrets.get, and artifactregistry.tags.get / artifactregistry.dockerimages.get |
status, wait, init |
run.services.get |
revisions |
run.services.get, run.revisions.list, plus run.revisions.delete for --prune |
diff |
run.services.get and run.services.update — plus run.services.create for a service that does not exist yet. See below |
deploy |
run.services.get, run.services.update, and run.services.create for a service that does not exist yet |
rollback |
run.services.get, run.revisions.list, run.services.update |
refresh |
run.services.get, run.services.update |
delete |
run.services.get, run.services.delete |
roles/run.viewer covers the read-only commands and roles/run.developer the rest — including
revisions --prune, which is the one part of revisions that writes. Deploying a
service that runs as a service account also needs iam.serviceAccounts.actAs on that service
account (roles/iam.serviceAccountUser) — a Cloud Run requirement, not a clrnd one.
diff needs write permission by default. It asks Cloud Run to fill in the fields it defaults,
and the only way to get those is a dryRun=all write, which is checked against
run.services.update even though it changes nothing. For a service that does not exist yet the
dry run has to be a create (a replace would 404), so that case needs run.services.create
instead — the same permission deploy would need to create it for real. Pass
--no-server-defaults to make no write-shaped call at all: the comparison then stays within
roles/run.viewer, at the cost of a diff that shows Cloud Run's defaults as differences on a
hand-written manifest.
--format json prints one object instead of the human-readable stream, so a CI job can decide for
itself what to treat as fatal:
clrnd verify --format json | jq -e '.unchecked | length == 0'{
"service": "my-service",
"manifest": "service.yaml",
"ok": true,
"unchecked": ["secret \"api-token\": googleapi: Error 403: permission denied"]
}ok is false only for things clrnd is sure about — a failed local check (errors) or a
confirmed 404 (missing) — and the command's exit code matches it. Checks that could not be
decided land in unchecked and leave ok true, which is the same split the text output makes
between a warning: and a failure.
None of the permissions verify uses for its existence checks are required — a missing one does
not fail the command. It
cannot tell "the secret is absent" from "I am not allowed to look", so it prints a warning: on
stderr and succeeds. Only a confirmed 404 fails the command.
To avoid repeating arguments and flags, put them in a config file and pass it with -c /
--config. If --config is omitted, clrnd looks for clrnd.yml then clrnd.yaml in the current
directory.
# clrnd.yml
project: my-project
region: asia-northeast1
service: my-svc # optional; overridable by the positional argument
manifest: manifest.yaml # optional; overridable by the positional argument
tfstate:
- location: gs://my-tf-state/app/default.tfstate # default state (name omitted): {{ tfstate "..." }}
- name: network_ # prefixed state: {{ network_tfstate "..." }}
location: gs://my-tf-state/network/default.tfstateRelative paths in the config (manifest, and local tfstate locations) are resolved relative to
the config file's directory, so the config works from any working directory. Paths passed as CLI
arguments stay relative to the current directory.
With the service and manifest in the config, commands need no positional arguments:
clrnd deploy -c clrnd.yml # uses service + manifest from the config
clrnd deploy other-svc -c clrnd.yml # override just the service (positional args fill service, then manifest)Resolution order (highest first), matching gcloud:
| Setting | Order |
|---|---|
| project | --project → $CLOUDSDK_CORE_PROJECT / $GOOGLE_CLOUD_PROJECT → config project |
| region | --region → $CLOUDSDK_RUN_REGION / $GOOGLE_CLOUD_REGION → config region |
| service | positional [service] → config service |
| manifest | positional [manifest] → config manifest |
| tfstate | --tfstate (if any given, replaces config) → config tfstate |
The config is parsed strictly: an unknown key is an error, not a warning. A misspelled
regoin: fails loudly instead of silently falling back to the environment. A --config you pass
explicitly must exist (init is the exception — it is the command that creates it); an
auto-detected clrnd.yml that is absent is simply an empty config.
Manifests are rendered as Go templates before they are parsed,
so you can fill placeholders from Terraform state outputs (or any resource attribute) and from
environment variables, using the same notation as ecspresso.
This applies to verify, render, diff, and deploy.
The manifest is always rendered as a template, even with no
--tfstateconfigured. A manifest that needs a literal{{— a container argument for another templating system, say — will fail to parse. Write it as{{ "{{" }}to get one through.
spec:
template:
spec:
serviceAccountName: '{{ tfstate "output.run_service_account" }}'
containers:
- image: '{{ must_env "IMAGE" }}'
env:
- name: DB_HOST
value: '{{ tfstate "google_sql_database_instance.main.private_ip_address" }}'
- name: LOG_LEVEL
value: '{{ env "LOG_LEVEL" "info" }}'Provide the state location with --tfstate (repeatable). A state can be a local path or a remote
URL (gs://, s3://, …); it is only read when a placeholder actually references it.
# Single (default) state
clrnd deploy my-svc manifest.yaml --project p --region r \
--tfstate gs://my-bucket/prod/terraform.tfstate
# Multiple states: the <prefix> in --tfstate <prefix>=<location> becomes the
# {{ <prefix>tfstate "<addr>" }} function name (ecspresso's func_prefix).
clrnd deploy my-svc manifest.yaml --project p --region r \
--tfstate gs://my-bucket/app/terraform.tfstate \
--tfstate network_=gs://my-bucket/network/terraform.tfstate
# -> {{ tfstate "..." }} for the app state, {{ network_tfstate "..." }} for the network stateTemplate functions:
| Function | Description |
|---|---|
{{ tfstate "<addr>" }} |
Look up <addr> in the default state (the --tfstate given without a name). |
{{ tfstatef "<format>" args... }} |
printf-format the address first, then look it up (e.g. {{ tfstatef "aws_subnet.%s.id" .az }}). |
{{ <prefix>tfstate "<addr>" }} |
Look up <addr> in the state --tfstate <prefix>=<location> (the prefix becomes the function name). |
{{ <prefix>tfstatef "<format>" args... }} |
printf variant for a prefixed state. |
{{ env "<VAR>" "<default>" }} |
Value of environment variable <VAR>, or <default> if it is unset or empty (the default is optional). |
{{ must_env "<VAR>" }} |
Value of environment variable <VAR>; errors if it is not defined. |
{{ <value> | json_escape }} |
Escape the value for use inside a JSON string (quotes, newlines, backslashes); the surrounding " are yours to write. |
json_escape matters wherever a manifest carries JSON as a string — an annotation, or an
env[].value holding a config blob:
metadata:
annotations:
example.com/config: >-
{"token": "{{ must_env "TOKEN" | json_escape }}"}Without it, a value containing " or a newline produces broken JSON that Cloud Run accepts as an
opaque string and the application then fails to parse.
The block scalar (>-) is deliberate. json_escape escapes the value for JSON, which is not
the same set of characters YAML needs: inside '...' a value containing ' would still break the
document, and inside "..." YAML would consume the very backslashes json_escape just added. A
block scalar takes the line as written, so both quoting styles stay out of the way.
The address may be quoted with "..." or backticks `...` — both are Go template string
literals. Use backticks (or ', which is rewritten to ") when the address itself contains double
quotes, e.g. {{ tfstate `aws_s3_bucket.main["id"]` }}. A <prefix> must be a valid Go
identifier (letters, digits, _; not starting with a digit).
For a Terraform GCS backend, the state object lives at gs://<bucket>/<prefix>/<workspace>.tfstate:
terraform {
backend "gcs" {
bucket = "my-tf-state"
prefix = "cloudrun/prod"
}
}The default workspace stores it at gs://my-tf-state/cloudrun/prod/default.tfstate — that path is
the --tfstate URL:
gcloud auth application-default login # GCS is read via ADC, same as the API access
clrnd deploy my-svc manifest.yaml \
--project my-project --region asia-northeast1 \
--tfstate gs://my-tf-state/cloudrun/prod/default.tfstateReading the state needs storage.objects.get (e.g. roles/storage.objectViewer) on the bucket.
If you use a non-default workspace, the object is <prefix>/<workspace>.tfstate; confirm the exact
path with gcloud storage ls gs://my-tf-state/cloudrun/prod/.
Treat the manifest, the config file, and the Terraform state you point at as executable input —
the same trust level you give a Makefile. Rendering one is not a read-only operation. Do not
run clrnd against a manifest, config, or state that someone outside your trust boundary can
write, and be careful with a CI job that renders a manifest from a fork's pull request.
Three specific things to know:
- A manifest can read any environment variable. It is a Go template, so
{{ env "GITHUB_TOKEN" }}works anywhere in the file. In CI that means the job's secrets can end up in the rendered output (the job log) or in the deployed container's environment. - A Terraform state can redirect where clrnd connects.
tfstate-lookup, the library behind{{ tfstate }}, follows thebackendrecorded inside the state document. Whoever can write that file can therefore make clrnd send your$TFE_TOKENto a host of their choosing, or make an authenticated request to a bucket they control. This is how the library works (ecspresso has the same property), not a bug in clrnd — but it means an untrusted state file is an untrusted input. Note that the manifest cannot choose a state location: only--tfstateand the config file do that, and a manifest can only reference a prefix that is already registered. - Files clrnd writes can hold those same values.
render -oandinitwrite mode0600, and replace an existing file through a temporary file in the same directory, so a failed or interrupted write leaves the previous content in place instead of a truncated file. Both of those are Unix properties: on Windowsos.RenameisMoveFileEx, which the OS does not guarantee to be an atomic replacement, and file modes are reduced to the read-only bit, so0600does not keep the file from other users there. On Windows, put those outputs where the filesystem's own permissions protect them. - Diffs contain plaintext values.
sanitizeMapstrips server-managed fields, not secrets, so aenv[].valueshows up indiffanddeployoutput as written — andclrnd deploy --auto-approvein CI leaves it in the job log. Reference secrets withsecretKeyRefor a secret volume rather than putting them invalue:;verifyunderstands both and checks that they exist.
clrnd deploys the Cloud Run service definition — what a Knative-style Service YAML can
express. These live next to it and are deliberately out of scope:
- IAM policy, including public access. clrnd never calls
GetIamPolicy/SetIamPolicy. A service made public withgcloud run deploy --allow-unauthenticatedcarries anallUsersbinding that is not part of the manifest, sodiffwill never show it andinitwill not capture it. A serviceclrnd deploycreates is private; andclrnd deletefollowed by a redeploy silently loses the public setting. Manage access withgcloud run services add-iam-policy-bindingor Terraform. - Cloud Run jobs. Only services (
kind: Service) are supported.verifyrejects a job manifest —kind must be "Service", got "Job"— but it says nothing about jobs being unsupported, so this is the place that says it. - Domain mappings.
deleteremoves the service without mentioning any mapping pointed at it. - Traffic tags as a first-class concept. Tags in the manifest are applied like any other field,
and
rollback/trafficpreserve existing tags at 0%, but there is no command to add or move one. Traffic percentages are managed — seetraffic.
Two smaller edges worth knowing: a mistyped --region becomes a DNS failure rather than "unknown
region", because the region goes straight into the API endpoint; and the diff is a plain unified
diff with three lines of context and no pager, so a large service produces a large diff.
clrnd [command]
| Command | Description |
|---|---|
verify |
Verify a manifest. |
render |
Render a manifest with templates expanded. |
diff |
Show the diff between an existing service and a manifest. |
deploy |
Deploy a manifest to Cloud Run. |
init |
Initialize a project from an existing service. |
status |
Show the current status of a service. |
wait |
Wait until a service is ready. |
revisions |
List the revisions of a service. |
rollback |
Send traffic back to an earlier revision. |
traffic |
Change how traffic is split between revisions. |
delete |
Delete a service. |
refresh |
Roll out a new revision without changing the definition. |
Run clrnd [command] --help for details on a specific command, and clrnd --version for the
installed version.
All commands that take a <service> and <manifest> expect the service name to match the
manifest's metadata.name. A typical workflow is init → edit → render → verify → diff →
deploy.
clrnd does not manage revision names. init omits spec.template.metadata.name from the manifest
it writes, and diff/deploy ignore the name Cloud Run reports for the live revision, so Cloud Run
generates a fresh revision name on every deploy.
refresh is the one exception: it sets a name deliberately, because that is the only way to force
a new revision. See refresh.
You may still pin a name yourself. If you do, it shows up in diff like any other field, and
verify warns you: Cloud Run cannot recreate an existing revision, so the next deploy that changes
the template will be rejected. Pinning is only safe for a one-shot deploy.
--project and --region may be omitted when the corresponding environment variable is set
(gcloud-compatible): project falls back to $CLOUDSDK_CORE_PROJECT then $GOOGLE_CLOUD_PROJECT,
region to $CLOUDSDK_RUN_REGION then $GOOGLE_CLOUD_REGION. An explicit flag always wins.
Validate that a manifest is a well-formed Cloud Run service definition and contains the fields
required to deploy. The schema check is local: it does not access the API and needs no
credentials, so it is safe to run in CI. A valid manifest produces no output on stdout; problems
are reported to stderr with a non-zero exit code. Advisory warning: lines (see
Revision names) also go to stderr and do not fail the command.
When --project / --region are resolvable (flag, env, or config) and --local-only is not set,
verify additionally checks via the API that the resources the manifest references actually exist:
| Resource | Checked with | Notes |
|---|---|---|
spec.template.spec.serviceAccountName |
IAM serviceAccounts.get |
looked up across projects, since Cloud Run allows a service account from another project |
secretKeyRef and secret volumes |
Secret Manager secrets.get |
cross-project aliases in run.googleapis.com/secrets are resolved |
| the secret version each reference points at | Secret Manager secrets.versions.get |
the version comes from key, or from a /versions/<v> suffix on the name, or latest as Cloud Run assumes; a DISABLED or DESTROYED version fails the same way a missing one does, and the lookup is skipped when the secret itself is missing |
run.googleapis.com/vpc-access-connector |
Serverless VPC Access connectors.get |
a bare connector name is resolved against the target project and region; a fully qualified name is used as written |
run.googleapis.com/cloudsql-instances |
Cloud SQL Admin instances.get |
each <project>:<region>:<instance>; the project comes from the connection name, so a cross-project (or domain-scoped example.com:project) instance works |
containers[].image |
Artifact Registry | only *-docker.pkg.dev images; the location and project come from the image reference, so a cross-project image works |
Both a tag and a digest are handled (repo/app:v1 and repo/app@sha256:…); with neither, latest
is assumed, matching what Cloud Run would pull.
Images on other registries are not checked, and nothing is printed about them. gcr.io has no
equivalent API, and Docker Hub and the rest are out of reach entirely. Warning on every one of them
would mean a warning: line on every run for anyone using Docker Hub, which is how warnings stop
being read.
Unlike ecspresso's verify this remote check is opt-in by availability — when no project/region is
set it is skipped and verify stays a fully offline lint.
The remote check only fails verify when a resource is confirmed missing (the API returns
not-found). When it cannot reach the API to decide — no credentials, the API is disabled, or the
caller lacks read permission — it prints a warning: to stderr and does not fail, so an
ambient project/region in CI never turns a passing offline lint red.
clrnd verify <service> <manifest> [--project <PROJECT>] [--region <REGION>] [--local-only] [--tfstate <location>]| Flag | Description |
|---|---|
--project |
GCP project ID. Enables the API existence checks (env/config fallback). |
--region |
Cloud Run region. Enables the API existence checks (env/config fallback). |
--local-only |
Skip the API existence checks; validate the manifest locally only. |
--image |
Override a container image, the same way deploy does (the existence check then looks at the overridden image). |
--format |
Output format: text (default) or json. |
--tfstate |
Terraform state for {{ tfstate }} placeholders (see Templating). |
# Local schema check only (no credentials needed)
clrnd verify my-service service.yaml --local-only
# Also confirm the referenced service account, secrets, and image exist
clrnd verify my-service service.yaml --project my-project --region asia-northeast1Render the manifest as a Go template ({{ tfstate }}, {{ env }}, …) and print the expanded
result without parsing or validating it. This is handy for debugging template output. It does not
access the Cloud Run API and needs no --project / --region. It checks no service name, so it
takes only the manifest.
clrnd render <manifest> [--tfstate <location>] [--output <FILE>]| Flag | Description |
|---|---|
--tfstate |
Terraform state for {{ tfstate }} placeholders (see Templating). |
-o, --output |
Output file. Writes to stdout if not set. Written with mode 0600 through a temporary file, so a failed write does not destroy what was there and an existing file's mode is tightened (see Trust boundary for the Windows caveat). It may not be the manifest being rendered. |
clrnd render service.yaml --tfstate gs://my-tf-state/prod/default.tfstateFetch the live definition of the service from Cloud Run and show a unified diff against the given
manifest file. Both sides are normalized (read-only fields removed) before comparison, so a
manifest produced by init compares cleanly. Nothing is printed when there is no difference.
diff also works before the service exists: everything shows up as an addition, the same way
deploy would create it.
A hand-written minimal manifest would otherwise be a different story: Cloud Run defaults a lot of
fields, and those would show up as a difference forever. diff therefore asks Cloud Run to resolve
them first (via a dry run) so the comparison converges.
This means
diffneeds permission to update the service (or to create it, when the service does not exist yet), because a dry run is a write-shaped call. If you rundiffwith read-only credentials, pass--no-server-defaultsto compare against the manifest as written — with that flag no dry run is performed at all.
clrnd diff <service> <manifest> --project <PROJECT> --region <REGION>| Flag | Description |
|---|---|
--project |
GCP project ID. Required unless $CLOUDSDK_CORE_PROJECT / $GOOGLE_CLOUD_PROJECT or project: in the config file is set. |
--region |
Cloud Run region, e.g. asia-northeast1. Required unless $CLOUDSDK_RUN_REGION / $GOOGLE_CLOUD_REGION or region: in the config file is set. |
--tfstate |
Terraform state for {{ tfstate }} placeholders: <location> or <name>=<location> (repeatable). See Templating. |
--image |
Override a container image, the same way deploy does, so the diff matches what would be applied. |
--no-server-defaults |
Compare against the manifest as written, without resolving Cloud Run's defaults (read-only credentials are enough). |
--exit-code |
Exit with 2 when there is a difference. Use this for drift checks in CI — see Exit codes. |
clrnd diff my-service service.yaml --project my-project --region asia-northeast1Show the diff against the live service, ask for confirmation, then apply the manifest to Cloud Run — creating the service if it does not exist or replacing it otherwise. The manifest is validated locally before the request is sent. When there is no difference, nothing is applied.
After applying, deploy waits until the new revision is serving and exits non-zero if the
rollout fails. When there is nothing to apply it still checks that the service is currently healthy,
so re-running after a failed rollout does not report success. Cloud Run accepts the request before the revision starts, so without this a broken
revision would still exit 0 and CI would treat the deploy as successful. Pass --no-wait to return
as soon as the request is accepted.
Like verify, deploy warns on stderr when the manifest pins spec.template.metadata.name — the
next deploy that changes the template will be rejected by Cloud Run. deploy repeats the warning
because a CI job that only runs deploy would otherwise see nothing but the API error when that
happens. It is a warning, not a failure: pinning is legitimate for a one-shot deploy.
Every change is a compare-and-swap. clrnd sends the metadata.resourceVersion it computed the
diff against, so if the service changed in between — a colleague's gcloud run deploy, a Terraform
apply, another CI job — the write is rejected instead of silently overwriting them:
Error: service "my-service" changed after the diff was computed; re-run to compare against the
current state: googleapi: Error 409: Conflict for resource 'my-service': version '...' was
specified but current version is '...'., aborted
Re-run the command: the second attempt diffs against the new state, so you see what the other change
did before deciding to apply on top of it. This applies to deploy, rollback, and refresh alike.
clrnd deploy <service> <manifest> --project <PROJECT> --region <REGION> [--auto-approve] [--dry-run]| Flag | Description |
|---|---|
--project |
GCP project ID. Required unless $CLOUDSDK_CORE_PROJECT / $GOOGLE_CLOUD_PROJECT or project: in the config file is set. |
--region |
Cloud Run region, e.g. asia-northeast1. Required unless $CLOUDSDK_RUN_REGION / $GOOGLE_CLOUD_REGION or region: in the config file is set. |
--tfstate |
Terraform state for {{ tfstate }} placeholders: <location> or <name>=<location> (repeatable). See Templating. |
--image |
Override a container image: <image>, or <container>=<image> when the manifest has more than one container (repeatable). |
--auto-approve |
Apply without the interactive confirmation prompt. Use this in CI/CD. |
--dry-run |
Validate the request server-side without applying any changes (no prompt). |
--no-server-defaults |
Show the diff against the manifest as written, without resolving Cloud Run's defaults. |
--no-traffic |
Create the revision without sending traffic to it; the current split is kept. |
--no-wait |
Return as soon as the request is accepted, without waiting for the rollout. |
--interval |
How long to wait between rollout polls (default 2s; it backs off up to 15s). |
--timeout |
How long to wait for the rollout to finish (default 10m). |
--image covers the usual CI case: a manifest committed to the repository, deployed with the
tag that was just built.
clrnd deploy --image "$REGISTRY/app:$GITHUB_SHA"
clrnd deploy --image app=$REGISTRY/app:$SHA --image proxy=$REGISTRY/proxy:$SHA # with a sidecarThe container name may be omitted only when the manifest defines exactly one container; with more,
clrnd refuses rather than guessing which one you meant. verify and diff take the same flag, so
the whole verify → diff → deploy sequence looks at the same image. The manifest stays the
source of truth for everything else — --image exists because the image tag is the one field that
legitimately changes on every deploy. {{ must_env "IMAGE" }} in the manifest remains an
alternative, and render deliberately has no --image: it prints the template expansion as-is,
without parsing it.
--no-traffic is how you deploy before deciding to serve it. Without it, a service whose
manifest says nothing about spec.traffic sends everything to the new revision the moment it is
ready. With it, the split you are serving now is pinned to those revisions by name, so the new
revision starts at 0% and you move traffic over afterwards:
clrnd deploy --no-traffic
clrnd traffic --to-latest --percent 10 # canary
clrnd traffic --to-latest # all of itIt needs an existing service (there is nothing to keep traffic on otherwise), and it replaces any
spec.traffic written in the manifest — clrnd says so on stderr when the manifest has one.
Cloud Run fills in many fields on its own, so a hand-written minimal manifest would otherwise keep showing them as a difference. The diff therefore asks Cloud Run to resolve those first (via a dry run) and compares against the result. What gets applied is always your manifest — the resolved values are used only for the comparison.
That dry run is a write-shaped call, so it needs permission to update the service. Pass
--no-server-defaults to compare against the manifest as written instead.
The diff is printed to stdout; the confirmation prompt is on stderr. Without --auto-approve, a
non-interactive run (no TTY, e.g. a pipeline) refuses to apply and exits with an error — pass
--auto-approve there.
# Interactive: shows the diff, asks "Apply these changes? [y/N]"
clrnd deploy my-service service.yaml --project my-project --region asia-northeast1
# CI/CD: skip the prompt
clrnd deploy my-service service.yaml --project my-project --region asia-northeast1 --auto-approve
# Validate against the server without changing anything
clrnd deploy my-service service.yaml --project my-project --region asia-northeast1 --dry-runDelete a Cloud Run service. This cannot be undone: the service, all of its revisions, and its URL go away.
What is about to be deleted is printed to stderr and confirmed first. The project and region are always shown, because the realistic accident is deleting the right service name in the wrong project.
About to delete:
service: my-service
project: my-project
region: asia-northeast1
url: https://my-service-xxxx.a.run.app
Delete service "my-service"? This cannot be undone. [y/N]:
clrnd delete <service> --project <PROJECT> --region <REGION>
clrnd delete my-service --auto-approve # CI/CD: skip the prompt
clrnd delete my-service --dry-run # validate the request, delete nothingWithout --auto-approve, a non-interactive run (no TTY, e.g. a pipeline) refuses to delete and
exits with an error. A service that does not exist fails before any prompt.
Cloud Run deletes asynchronously — the request is accepted while the service is still readable for
a little longer — so delete waits until it is actually gone. Pass --no-wait to return as soon
as the request is accepted, or --timeout to change how long it waits (default 10m).
| Flag | Description |
|---|---|
--project |
GCP project ID. Required unless $CLOUDSDK_CORE_PROJECT / $GOOGLE_CLOUD_PROJECT or project: in the config file is set. |
--region |
Cloud Run region. Required unless $CLOUDSDK_RUN_REGION / $GOOGLE_CLOUD_REGION or region: in the config file is set. |
--auto-approve |
Delete without the interactive confirmation prompt. Use this in CI/CD. |
--dry-run |
Validate the request server-side without deleting anything (no prompt). |
--no-wait |
Return as soon as the request is accepted, without waiting for the service to disappear. |
--interval |
How long to wait between polls while the deletion completes (default 2s). |
--timeout |
How long to wait for the service to disappear (default 10m). |
Initialize a project from an existing Cloud Run service. init fetches the service and scaffolds
two files: the manifest (Knative-style YAML, with server-managed read-only fields such as status,
metadata.uid, resourceVersion, the cloud.googleapis.com/location label, and the
run.googleapis.com/client-name / client-version annotations stripped so it is deployable) and a clrnd.yml holding the
project, region, service, and manifest path. After init the other commands run with no
positional arguments. Existing files are not overwritten unless --force is given.
The config is written to --config when you pass it (it does not have to exist yet — init is what
creates it), otherwise to clrnd.yml in the current directory. The manifest: it records is
relative to the config file, so clrnd init my-service -c infra/clrnd.yml keeps working from any
directory (the directory has to exist — clrnd writes files, it never creates directories). Both files are written with mode 0600, since a live service definition can contain
plaintext environment variables. --force replaces them through a temporary file, so an
interrupted or failing write leaves the previous content in place rather than a truncated file, and
an existing file's mode is tightened to 0600 rather than kept as it was. See
Trust boundary for what of that holds on Windows.
For backward compatibility load is kept as an alias for init (it now scaffolds files rather than
printing to stdout).
clrnd init <service> --project <PROJECT> --region <REGION> [--output <FILE>] [--force] [-c <FILE>]Flags:
| Flag | Description |
|---|---|
--project |
GCP project ID. Required unless $CLOUDSDK_CORE_PROJECT / $GOOGLE_CLOUD_PROJECT or project: in the config file is set. |
--region |
Cloud Run region, e.g. asia-northeast1. Required unless $CLOUDSDK_RUN_REGION / $GOOGLE_CLOUD_REGION or region: in the config file is set. |
-o, --output |
Manifest file to write (default manifest.yaml). |
--force |
Overwrite existing files. |
-c, --config |
Config file to write (default clrnd.yml). Need not exist yet. |
Examples:
# Scaffold clrnd.yml + manifest.yaml from a live service
clrnd init my-service --project my-project --region asia-northeast1
# Then everything runs from the config alone
clrnd diff
clrnd deploy
# Scaffold into a subdirectory; the recorded manifest path stays correct.
# clrnd writes files but never creates directories, so make it first.
mkdir -p infra
clrnd init my-service -c infra/clrnd.yml -o infra/manifest.yamlFetch a service from Cloud Run and print its current state: the Ready condition, the latest ready
and created revisions, the observed generation, the traffic split, the URL, and every status
condition. Read-only — nothing is modified.
clrnd status <service> --project <PROJECT> --region <REGION>Service: my-svc
URL: https://my-svc-xxxx.a.run.app
Ready: True
Latest ready: my-svc-00007-abc
Latest created: my-svc-00007-abc
Generation: 7 (observed 7)
Traffic:
100% my-svc-00007-abc
Conditions:
Ready True
ConfigurationsReady True
RoutesReady True
When the service is not ready, the reason is shown next to Ready and the message on its own line:
Ready: False (RevisionFailed)
Message: Revision my-svc-00008-def is not ready and cannot serve traffic.
--format json prints the same information as JSON, for scripting:
clrnd status --format json | jq -r '.conditions[] | select(.type == "Ready") | .status'service may be omitted when set in the config file.
| Flag | Description |
|---|---|
--project |
GCP project ID. Required unless $CLOUDSDK_CORE_PROJECT / $GOOGLE_CLOUD_PROJECT or project: in the config file is set. |
--region |
Cloud Run region. Required unless $CLOUDSDK_RUN_REGION / $GOOGLE_CLOUD_REGION or region: in the config file is set. |
--format |
Output format: text (default) or json. |
List the revisions of a service, newest first, with the share of traffic each one currently receives. Read-only.
clrnd revisions <service> --project <PROJECT> --region <REGION>REVISION READY TRAFFIC TAGS CREATED IMAGE
my-svc-00008-ghi Unknown (Deploying) 10% canary 2026-08-23T11:00:00Z gcr.io/p/i:v3
my-svc-00007-abc True 90% - 2026-08-22T10:00:00Z gcr.io/p/i:v2
my-svc-00006-def False (RevisionFailed) 0% - 2026-08-21T09:00:00Z gcr.io/p/i:v1
IMAGE lists every container of the revision, comma-separated, in the order the manifest
declares them — a service with a sidecar shows both images. In JSON the same values are the
images array; image is still there and holds the first container, so an existing
jq '.[].image' keeps working.
--format json prints the same list as JSON:
clrnd revisions --format json | jq -r '.[] | select(.percent > 0) | .name'| Flag | Description |
|---|---|
--project |
GCP project ID. Required unless $CLOUDSDK_CORE_PROJECT / $GOOGLE_CLOUD_PROJECT or project: in the config file is set. |
--region |
Cloud Run region. Required unless $CLOUDSDK_RUN_REGION / $GOOGLE_CLOUD_REGION or region: in the config file is set. |
--format |
Output format: text (default) or json. |
--prune |
Delete the revisions older than the newest --keep. |
--keep |
How many of the newest revisions to keep when pruning (default 10). |
--auto-approve |
Prune without the interactive confirmation prompt. |
--dry-run |
With --prune, only show what would be deleted. |
Cloud Run never deletes a revision on its own, and a service can only hold so many, so
--prune is the way to clear out old ones:
clrnd revisions --prune --keep 20 --dry-run # see what would go
clrnd revisions --prune --keep 20A revision is never deleted while it is serving traffic, named in spec.traffic, or carrying a
tag, however old it is — deleting the first would take the service down, and the last would
remove a tag URL. spec.traffic is consulted as well as the live split because the status side
does not show a share until a rollout settles; when it says latestRevision: true — which names no
revision at all — the newest created and newest ready revisions are protected too, so a prune that
runs mid-rollout cannot delete the revision that is about to serve.
--keep counts from the newest revision down, protected or not, so --keep 20 means "everything
older than the 20 newest is a candidate". Anything protected in that older range stays, which is
why the number left behind can be larger than --keep.
What is about to go is printed first (as data on stdout, so --format json works here too — an
empty array when there is nothing to do), and the confirmation follows the same rule as delete:
without a terminal, clrnd refuses unless --auto-approve is given. --keep, --dry-run and
--auto-approve are rejected without --prune rather than quietly ignored.
Revisions are ordered newest-first by creationTimestamp, falling back to the revision name when a
timestamp cannot be parsed. That fallback is a plain descending string comparison: Cloud Run's own
numbering (-00007-abc) sorts correctly under it, but a hand-chosen --revision-suffix may not —
v2 comes out ahead of v10, however much later v10 was created.
Re-apply the live definition of a service so that a new revision is created, without changing anything about it. Useful when the image tag still points somewhere new, or to restart the containers.
refresh never reads a local manifest — it redeploys what is currently running.
clrnd refresh <service> --project <PROJECT> --region <REGION>
clrnd refresh --revision-suffix rebuild-42 --auto-approveCloud Run only creates a revision when spec.template changes, so refresh gives the new revision
an explicit name: <service>-r<UTC timestamp>, or <service>-<--revision-suffix>. This is the one
place clrnd sets a revision name (see Revision names); the next deploy that
actually changes something drops it again, and diff ignores it in the meantime. A deploy
with no difference applies nothing, so the name stays until there is a real change to write.
The diff is shown and confirmed the same way deploy does, and the rollout is waited for unless
--no-wait is given.
refresh refuses two cases rather than succeeding without effect: when the generated name matches
the revision the service already points at (run it again a second later, or pass a different
--revision-suffix), and when traffic is pinned to specific revisions — the state a rollback
leaves behind, where a new revision would be created but would serve nothing.
| Flag | Description |
|---|---|
--project |
GCP project ID. Required unless $CLOUDSDK_CORE_PROJECT / $GOOGLE_CLOUD_PROJECT or project: in the config file is set. |
--region |
Cloud Run region. Required unless $CLOUDSDK_RUN_REGION / $GOOGLE_CLOUD_REGION or region: in the config file is set. |
--revision-suffix |
Name the new revision <service>-<suffix> instead of <service>-r<UTC timestamp>. |
--auto-approve |
Apply without the interactive confirmation prompt. Use this in CI/CD. |
--dry-run |
Validate the request server-side without applying any changes (no prompt). |
--no-wait |
Return as soon as the request is accepted, without waiting for the rollout. |
--interval |
How long to wait between rollout polls (default 2s; it backs off up to 15s). |
--timeout |
How long to wait for the rollout to finish (default 10m). |
Send all traffic back to an earlier revision. Without --revision, the revision just before the
newest one currently serving traffic is chosen — so during a canary (new revision at 10%, stable at
90%) a rollback lands on the stable revision, not two generations back.
Only the traffic split changes: spec.template is untouched, so no new revision is created.
Traffic tags are kept (pinned at 0%) so a rollback does not remove tag URLs.
clrnd rollback <service> --project <PROJECT> --region <REGION>
clrnd rollback --revision my-service-00006-def --auto-approveThe diff is shown and confirmed the same way deploy does, and the rollout is waited for unless
--no-wait is given.
| Flag | Description |
|---|---|
--project |
GCP project ID. Required unless $CLOUDSDK_CORE_PROJECT / $GOOGLE_CLOUD_PROJECT or project: in the config file is set. |
--region |
Cloud Run region. Required unless $CLOUDSDK_RUN_REGION / $GOOGLE_CLOUD_REGION or region: in the config file is set. |
--revision |
Revision to send traffic to (default: the one before the revision currently serving). |
--auto-approve |
Apply without the interactive confirmation prompt. Use this in CI/CD. |
--dry-run |
Validate the request server-side without applying any changes (no prompt). |
--no-wait |
Return as soon as the request is accepted, without waiting for the rollout. |
--interval |
How long to wait between rollout polls (default 2s; it backs off up to 15s). |
--timeout |
How long to wait for the rollout to finish (default 10m). |
A --revision that is not Ready is a warning on stderr, not an error: the rollback goes ahead.
Sending traffic to a revision that failed is sometimes what you want (to reproduce a failure), and
Cloud Run is the authority on whether it can serve.
Change how traffic is split between revisions. Only spec.traffic changes: no new revision is
created, and traffic tags are kept (pinned at 0%), the same way rollback treats them.
# canary: 10% to a revision, the rest stays where it is
clrnd traffic <service> --to <revision> --percent 10
# all of it, then back to following the newest revision
clrnd traffic <service> --to <revision>
clrnd traffic <service> --to-latest--percent below 100 leaves the remainder on the revision currently serving the most traffic,
which is the canary shape (stable 90% / new 10%). If that revision is the one you are sending
traffic to — it already carries production — there is nothing to split it against, and clrnd says
so rather than promoting the runner-up: doing that mid-canary would drop the stable revision to
your --percent and hand the rest to an old one.
--to-latest stops pinning the split to a revision name: traffic follows whatever revision is
newest, now and after the next deploy. This is how you undo a rollback — while traffic is pinned,
refresh refuses to run (a new revision would serve nothing) and only a deploy that changes
something would move it.
| Flag | Description |
|---|---|
--project |
GCP project ID. Required unless $CLOUDSDK_CORE_PROJECT / $GOOGLE_CLOUD_PROJECT or project: in the config file is set. |
--region |
Cloud Run region. Required unless $CLOUDSDK_RUN_REGION / $GOOGLE_CLOUD_REGION or region: in the config file is set. |
--to |
Revision to send traffic to. |
--to-latest |
Send traffic to the latest revision, and keep following it. |
--percent |
Share of the traffic to send (1-100, default 100). |
--auto-approve |
Apply without the interactive confirmation prompt. Use this in CI/CD. |
--dry-run |
Validate the request server-side without applying any changes (no prompt). |
--no-wait |
Return as soon as the request is accepted, without waiting for the rollout. |
--interval |
How long to wait between rollout polls (default 2s; it backs off up to 15s). |
--timeout |
How long to wait for the rollout to finish (default 10m). |
Poll a service until its Ready condition becomes True. If it becomes False, wait fails
immediately instead of burning the timeout. Progress goes to stderr; nothing is written to stdout.
Ctrl-C stops the wait.
clrnd wait <service> --project <PROJECT> --region <REGION>
clrnd wait --timeout 5m --interval 5s| Flag | Description |
|---|---|
--project |
GCP project ID. Required unless $CLOUDSDK_CORE_PROJECT / $GOOGLE_CLOUD_PROJECT or project: in the config file is set. |
--region |
Cloud Run region. Required unless $CLOUDSDK_RUN_REGION / $GOOGLE_CLOUD_REGION or region: in the config file is set. |
--timeout |
How long to wait before giving up (default 10m). |
--interval |
How long to wait between polls (default 2s). The interval backs off up to 15s; a value you set is never shrunk below that cap. |
A failed poll is not a failed rollout: a transient error is reported and retried until the timeout,
because a single 503 would otherwise turn an already-applied deploy into a red CI run. Retried means
408, 429, 5xx, and errors with no HTTP status (a dropped connection, a DNS failure). Errors
that will not heal are returned at once instead of holding the job for the whole timeout: 400,
401, 403, and 404 (a service that does not exist will not appear). A 403 that reports a rate
limit is the exception — quota clears on its own, so it is retried.
| Code | Meaning |
|---|---|
0 |
Success. Also what diff returns when it found differences, unless --exit-code is given, and what any command returns when you answer n at a confirmation prompt. |
1 |
Something went wrong. The message is on stderr. |
2 |
diff --exit-code only: the command succeeded and there is a difference. |
The split follows terraform plan -detailed-exitcode, so a drift check reads naturally:
clrnd diff --exit-code
case $? in
0) echo "in sync" ;;
2) echo "drift detected"; exit 1 ;;
*) echo "diff failed"; exit 1 ;;
esacWithout --exit-code, diff exits 0 whether or not it printed anything — so a CI step that
just runs clrnd diff will always pass.
Bug reports and feature requests go to issues; what changed in each version is on the releases page.
Before opening a pull request:
go build ./... && go vet ./... && gofmt -l . && go test -race ./...
go run github.com/golangci/golangci-lint/v2/cmd/golangci-lint@v2.13.1 run ./...CI pins the same golangci-lint version, so this is the check it runs — not a different one that happens to be the latest release that day.
CI also checks the things that are not Go code. To reproduce those locally:
go mod tidy -diff # go.mod / go.sum are tidy
# the shell scripts (bash -n only checks its first file argument, hence the loop)
for f in test/e2e/run.sh .github/scripts/*.sh; do bash -n "$f"; done
shellcheck test/e2e/run.sh .github/scripts/*.sh
./.github/scripts/check-tool-pins.sh # the pins below agree
go run github.com/rhysd/actionlint/cmd/actionlint@v1.7.12 # .github/workflows/*.yml
go run github.com/goreleaser/goreleaser/v2@v2.17.1 check # .goreleaser.yaml
go run github.com/goreleaser/goreleaser/v2@v2.17.1 build --snapshot --clean # every release targetCI uses ShellCheck v0.11.0 (installed from a pinned, checksummed release); any recent version is close enough locally.
Those tool versions are pinned by hand: Dependabot updates the action SHAs and go.mod, but not a
version passed to an action, a go run tool@version, or the ShellCheck release CI downloads. When
you bump one, bump it everywhere it is written — .github/scripts/check-tool-pins.sh (run in CI)
fails when the copies disagree, which is what kept CI on latest while this file said v2.6.2.
Releases are cut from main only: the release workflow refuses a tag whose commit is not an
ancestor of main, and runs the same checks as CI before it builds anything.
Anything that touches the Cloud Run API should also be run through the end-to-end test in test/e2e, which creates and deletes a real service. It is opt-in, cannot run in CI, and needs a project you are happy to create Cloud Run services in — see test/e2e/README.md.
Released under the MIT License.