Cloud Run Remote Docker Build and Deploy

End-to-end setup for GitHub tag-triggered remote Docker builds on Google Cloud Build and deployment to Cloud Run.

This runbook documents the current Clapilot Cloud Run deployment pipeline for operators who deploy to Google Cloud. Note that Cloud Run is an optional deployment target; the default self-hosted path is the Docker/GHCR image flow described in Operations.

  • trigger: manual workflow_dispatch runs of the main and PR-testing deploy workflows (automatic per-PR previews are the Docker previews in .github/workflows/ci.yml, not Cloud Run deploys)
  • CI runner: GitHub Actions self-hosted
  • image build: Google Cloud Build (remote, amd64)
  • image registry: Artifact Registry (DOCKER)
  • runtime: paired Cloud Run services for web and clapilot-agent
  • database: Cloud SQL for PostgreSQL (clapilot-pg)

HTML export:

Current target state (as of 2026-03-03)

  • project: clapilot-488415
  • region: europe-west1
  • Cloud Run service: clapilot-git
  • Cloud Run URL: https://clapilot-git-bl3odasxra-ew.a.run.app
  • Artifact Registry repo: clapilot
  • Cloud SQL instance: clapilot-pg (POSTGRES_16, db-custom-1-3840)
  • Cloud Run port: 3000
  • Cloud Run limits: cpu=2, memory=4Gi

Pipeline architecture

  1. Tag or choose the git ref to deploy.
  2. Start GitHub Actions workflow .github/workflows/deploy-cloud-run-main.yml manually (workflow_dispatch, optional source_ref input; defaults to the branch the workflow runs on).
  3. Workflow authenticates to GCP via Workload Identity Federation (OIDC), no static JSON key.
  4. Workflow runs gcloud builds submit and stores image in Artifact Registry with:
    • immutable tag: commit SHA
    • release tag: Git tag (for example 0.0.14)
  5. Workflow deploys the web Cloud Run service with env vars from CLOUD_RUN_ENV_VARS.
  6. Workflow deploys a second Cloud Run service from the same image with CLAPILOT_SERVICE_MODE=agent.
  7. Workflow rewires CLAPILOT_INTERNAL_BASE_URL and CLAPILOT_AGENT_BASE_URL to the discovered service URLs.
  8. Cloud Run starts both containers and applies startup/migration logic.

One-time Google Cloud setup

Run these with a principal that has enough org/project permissions:

gcloud services enable \
  run.googleapis.com \
  cloudbuild.googleapis.com \
  artifactregistry.googleapis.com \
  iam.googleapis.com \
  iamcredentials.googleapis.com \
  sts.googleapis.com \
  sqladmin.googleapis.com \
  --project clapilot-488415

Create Artifact Registry repository (if missing):

gcloud artifacts repositories create clapilot \
  --repository-format=docker \
  --location=europe-west1 \
  --project=clapilot-488415

Create Cloud SQL PostgreSQL instance and database:

gcloud sql instances create clapilot-pg \
  --database-version=POSTGRES_16 \
  --region=europe-west1 \
  --tier=db-custom-1-3840 \
  --project=clapilot-488415

gcloud sql databases create taxoffice \
  --instance=clapilot-pg \
  --project=clapilot-488415

Grant Cloud Run runtime service account Cloud SQL access:

gcloud projects add-iam-policy-binding clapilot-488415 \
  --member="serviceAccount:[email protected]" \
  --role="roles/cloudsql.client"

GitHub configuration

Repository Variables

  • GCP_PROJECT_ID=clapilot-488415
  • GCP_REGION=europe-west1
  • GCP_ARTIFACT_REPO=clapilot
  • CLOUD_RUN_SERVICE=clapilot-git
  • CLOUD_RUN_AGENT_SERVICE=clapilot-git-agent (optional, defaults to <CLOUD_RUN_SERVICE>-agent)
  • CLOUD_RUN_FEATURE_SERVICE=clapilot-feature (automatic PR testing deploy target)
  • CLOUD_RUN_FEATURE_AGENT_SERVICE=clapilot-feature-agent (optional, defaults to <CLOUD_RUN_FEATURE_SERVICE>-agent)
  • optional:
    • CLOUD_RUN_CLOUDSQL_INSTANCES=clapilot-488415:europe-west1:clapilot-pg
    • CLOUD_RUN_PORT=3000
    • CLOUD_RUN_MEMORY=4Gi
    • CLOUD_RUN_CPU=2
    • CLOUD_RUN_RUNTIME_SERVICE_ACCOUNT=<service-account-email>
    • CLOUD_BUILD_SOURCE_BUCKET=<bucket-name>
    • CLOUD_BUILD_EXECUTOR_SERVICE_ACCOUNT=<sa-email-or-full-resource-name>

Repository Secrets

  • GCP_WORKLOAD_IDENTITY_PROVIDER
  • GCP_SERVICE_ACCOUNT
  • CLOUD_RUN_ENV_VARS (multiline KEY=VALUE payload)
  • optional for separate feature config: CLOUD_RUN_ENV_VARS_FEATURE

Recommended keys inside CLOUD_RUN_ENV_VARS for module/skill availability:

  • OPENCLAW_BUNDLED_MODULES_DIR=/app/bundled-modules
  • OPENCLAW_BUNDLED_SKILLS_DIR=/app/workspace-seed/skills
  • MODULE_HUB_URL=https://hub.clapilot.com
  • SKILL_HUB_URL=https://hub.clapilot.com
  • optional for publish: MODULE_HUB_SECRET, SKILL_HUB_SECRET

Use .env.cloudrun as the local source and sync it:

gh secret set CLOUD_RUN_ENV_VARS < .env.cloudrun

Template file:

  • .env.cloudrun.example

Do not hardcode CLAPILOT_AGENT_BASE_URL or CLAPILOT_INTERNAL_BASE_URL in the static secret payload unless you intentionally want to override the workflow wiring. The deploy workflows discover the current Cloud Run URLs and inject those values automatically so web and agent can call each other.

If your DATABASE_URL uses the Cloud SQL Unix socket form (/cloudsql/...), set CLOUD_RUN_CLOUDSQL_INSTANCES as a repository variable. The workflows pass that attachment to both the web and agent services so the new agent service can reach the same database.

Cloud SQL connection in Cloud Run

Cloud Run cannot resolve Docker Compose hostnames like postgres.

Use Unix socket form in DATABASE_URL:

DATABASE_URL=postgresql://postgres:<PASSWORD>@/taxoffice?host=/cloudsql/clapilot-488415:europe-west1:clapilot-pg

Attach Cloud SQL instance to Cloud Run manually:

gcloud run services update clapilot-git \
  --region=europe-west1 \
  --project=clapilot-488415 \
  --add-cloudsql-instances=clapilot-488415:europe-west1:clapilot-pg

Release flow

Tag and push:

git tag -a 0.0.14 -m "release 0.0.14"
git push origin 0.0.14

Watch workflow:

gh run list --workflow deploy-cloud-run-main.yml --limit 5
gh run view --log <run-id>

PR testing deploy flow

Workflow:

  • .github/workflows/deploy-cloud-run-feature-pr.yml
  • .github/workflows/ci.yml also runs persistent Docker PR previews for same-repo browser checks.

Trigger:

  1. The workflow is manual-only (workflow_dispatch): open Actions, select Deploy Cloud Run (PR testing), and run it with:
    • pr_number (required)
    • source_ref (optional override; defaults to the PR head SHA)
    • feature_service (optional override; defaults to CLOUD_RUN_FEATURE_SERVICE)
  2. Automatic per-PR testing environments are the persistent Docker previews from .github/workflows/ci.yml (see below), which never deploy to Cloud Run. Fork PRs get neither, so repository secrets are not exposed to untrusted code.

Behavior:

  • checks out the PR head SHA (unless source_ref override is provided for manual runs)
  • builds image in Cloud Build
  • deploys the web Cloud Run service first
  • deploys a separate agent Cloud Run service from the same image with CLAPILOT_SERVICE_MODE=agent
  • rewires CLAPILOT_INTERNAL_BASE_URL on the agent to the web URL and CLAPILOT_AGENT_BASE_URL on the web service to the agent URL
  • comments both deployed URLs back on the PR

Persistent Docker PR previews

The PR live browser check in .github/workflows/ci.yml keeps a same-repo PR Docker Compose stack running on the self-hosted runner until the PR is closed. Each PR uses a stable Compose project name:

clapilot-pr-<PR_NUMBER>

For PR open, reopen, synchronize, and ready-for-review events, CI:

  • creates a fresh Cloudflare tunnel with the clapilot-pr-<PR_NUMBER> prefix
  • maps https://pr<PR_NUMBER>.<CLOUDFLARE_PREVIEW_DOMAIN> to http://clapilot:3000 inside the Compose network
  • starts the existing cloudflared Compose service with the returned tunnel token
  • runs the live browser/e2e check against the public preview URL
  • leaves the Docker project running after the job, even when the e2e check fails
  • updates the PR comment with the preview URL, e2e result, and artifacts

For PR close events, CI stops the matching Docker project and deletes the Cloudflare DNS record plus all tunnels with the clapilot-pr-<PR_NUMBER> prefix. CI concurrency groups pull-request runs by PR number instead of by github.ref, so the merge push to main cannot cancel the close-event cleanup run. Update runs create a fresh run-scoped tunnel with that prefix and move DNS to it, which prevents stale Cloudflare connectors from serving an older PR container.

.github/workflows/pr-preview-sweeper.yml is the safety net. It runs hourly and can also be started manually from GitHub Actions. The sweeper fans out across every label in CLAPILOT_PREVIEW_RUNNER_LABELS, discovers local Docker Compose projects named clapilot-pr-<PR_NUMBER>, checks the matching GitHub PR state, and cleans Docker plus Cloudflare when the PR is no longer open. Manual dispatch can also target a single PR number on its assigned runner, with an optional runner_label override for previews created before the label list changed, plus an optional dry-run mode.

Required GitHub configuration:

  • secret: CLOUDFLARE_API_TOKEN
  • variable: CLOUDFLARE_ACCOUNT_ID
  • variable: CLOUDFLARE_ZONE_ID
  • variable: CLOUDFLARE_PREVIEW_DOMAIN=clapilot.com
  • variable: CLAPILOT_PREVIEW_RUNNER_LABELS=<comma-separated runner labels>
  • optional variable: CLAPILOT_PREVIEW_CLEANUP_RUNNER_LABELS=<comma-separated runner labels>

Use a scoped Cloudflare API token rather than a global API key. The token needs account-level Cloudflare Tunnel edit access, zone read access, and DNS edit access for the clapilot.com zone.

Each preview-capable self-hosted runner should have a unique runner label, for example clapilot-preview-1 or Clapilot-Mac-Mini-2. Set CLAPILOT_PREVIEW_RUNNER_LABELS to all labels that should receive new or updated preview deployments. CI deterministically selects one label from the PR number, so different PRs distribute across the configured preview runners.

Cleanup uses CLAPILOT_PREVIEW_CLEANUP_RUNNER_LABELS when set, otherwise it falls back to CLAPILOT_PREVIEW_RUNNER_LABELS. During runner-pool migrations, keep old labels in CLAPILOT_PREVIEW_CLEANUP_RUNNER_LABELS until their old previews are gone. Close-event cleanup fans out across the cleanup pool so a stale Docker project is removed even when the deployment pool changed after the PR preview was created.

Fork PRs do not receive Docker previews or Cloudflare tunnels because repository secrets are not exposed to untrusted PR code.

Manual cleanup for PR 305 on the preview runner:

bash scripts/ci/cleanup-pr-preview.sh pr 305

If a preview runner is removed, close or manually clean up any open PR previews assigned to that host before deleting its label from CLAPILOT_PREVIEW_RUNNER_LABELS.

Troubleshooting findings from this rollout

SymptomRoot causeResolution
invalid image name .../Clapilot/...Artifact path segment had uppercase letters from console-connected source build namingUse all-lowercase Artifact Registry path/repository naming.
Cloud Run deploy failed with manifest ... must support amd64/linuxARM-built multi-arch manifest from local/self-hosted runner pathBuild remotely in Cloud Build (amd64), deploy that image.
qemu ... Segmentation fault during next buildCross-arch emulation instability in local buildx pathAvoid emulated local build path for release images.
ENOSPC during build (corepack cache mkdir)Self-hosted runner disk pressureMove builds to remote Cloud Build and clean local Docker cache periodically.
Cloud Build failed on --mount=type=cacheCloud Build default docker builder path did not support Dockerfile BuildKit mount optionsRemoved BuildKit-only --mount flags from Dockerfile build steps.
Cloud Run failed startup on port checks (8080 then 3000)App did not become healthy before timeout due memory pressure, not just port mismatchExplicitly set Cloud Run memory/CPU and investigate startup logs.
Cloud Run startup failed with memory exceeded (512MiB, later 2048MiB)Runtime memory too low for startup plus workspace seedingIncreased Cloud Run memory to 4Gi.
Login/admin seed not working despite ADMIN_EMAIL and ADMIN_PASSWORD setDB connection pointed to postgres host (invalid in Cloud Run) so migrations/seeding failedSwitched to Cloud SQL socket DATABASE_URL and attached instance.
iam.serviceaccounts.actAs denied on deployDeployer principal lacked service-account impersonation permissions for runtime SAGrant roles/iam.serviceAccountUser on target runtime SA or deploy without overriding SA.
Permission denied for all log views in deploy diagnosticsDeployer principal missing logging read permissionsGrant roles/logging.viewer (or broader logging read role).
cloudbuild-src bucket permission errors (storage.objects.get)Missing Storage permissions on source/log bucket for acting principal/build SAGrant appropriate bucket/project storage roles to build/deployer principal.
policy contains bindings with conditions while adding IAM bindingProject IAM policy uses conditional bindingsAdd binding with explicit --condition in command, or use Console flow with condition fields.
ReadTimeout uploading large source archiveNetwork/transient timeout while sending >200 MiB source tarballRetry logic plus larger timeout; reduce uploaded context via .gcloudignore where possible.

Operational commands

Describe current Cloud Run service:

gcloud run services describe clapilot-git \
  --region=europe-west1 \
  --project=clapilot-488415

Read recent Cloud Run revision logs:

gcloud logging read \
  'resource.type="cloud_run_revision" AND resource.labels.service_name="clapilot-git"' \
  --project=clapilot-488415 \
  --limit=120 \
  --format='value(timestamp,severity,textPayload,jsonPayload.message)'

Security notes

  • Do not store production secrets in git.
  • Keep only placeholders in tracked files (.env.cloudrun.example).
  • Rotate any secret that was ever pasted in CI logs, chat, or screenshots.
  • Prefer Workload Identity Federation over static service account keys.

Known future extension

Per-PR testing deploys use a dedicated workflow that deploys paired web and agent Cloud Run services from the same image, while main deploys stay on the separate manual workflow.