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_dispatchruns 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
- Tag or choose the git ref to deploy.
- Start GitHub Actions workflow
.github/workflows/deploy-cloud-run-main.ymlmanually (workflow_dispatch, optionalsource_refinput; defaults to the branch the workflow runs on). - Workflow authenticates to GCP via Workload Identity Federation (OIDC), no static JSON key.
- Workflow runs
gcloud builds submitand stores image in Artifact Registry with:- immutable tag: commit SHA
- release tag: Git tag (for example
0.0.14)
- Workflow deploys the web Cloud Run service with env vars from
CLOUD_RUN_ENV_VARS. - Workflow deploys a second Cloud Run service from the same image with
CLAPILOT_SERVICE_MODE=agent. - Workflow rewires
CLAPILOT_INTERNAL_BASE_URLandCLAPILOT_AGENT_BASE_URLto the discovered service URLs. - 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-488415GCP_REGION=europe-west1GCP_ARTIFACT_REPO=clapilotCLOUD_RUN_SERVICE=clapilot-gitCLOUD_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-pgCLOUD_RUN_PORT=3000CLOUD_RUN_MEMORY=4GiCLOUD_RUN_CPU=2CLOUD_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_PROVIDERGCP_SERVICE_ACCOUNTCLOUD_RUN_ENV_VARS(multilineKEY=VALUEpayload)- 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-modulesOPENCLAW_BUNDLED_SKILLS_DIR=/app/workspace-seed/skillsMODULE_HUB_URL=https://hub.clapilot.comSKILL_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.ymlalso runs persistent Docker PR previews for same-repo browser checks.
Trigger:
- 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 toCLOUD_RUN_FEATURE_SERVICE)
- 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_refoverride 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_URLon the agent to the web URL andCLAPILOT_AGENT_BASE_URLon 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>tohttp://clapilot:3000inside the Compose network - starts the existing
cloudflaredCompose 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
| Symptom | Root cause | Resolution |
|---|---|---|
invalid image name .../Clapilot/... | Artifact path segment had uppercase letters from console-connected source build naming | Use all-lowercase Artifact Registry path/repository naming. |
Cloud Run deploy failed with manifest ... must support amd64/linux | ARM-built multi-arch manifest from local/self-hosted runner path | Build remotely in Cloud Build (amd64), deploy that image. |
qemu ... Segmentation fault during next build | Cross-arch emulation instability in local buildx path | Avoid emulated local build path for release images. |
ENOSPC during build (corepack cache mkdir) | Self-hosted runner disk pressure | Move builds to remote Cloud Build and clean local Docker cache periodically. |
Cloud Build failed on --mount=type=cache | Cloud Build default docker builder path did not support Dockerfile BuildKit mount options | Removed 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 mismatch | Explicitly 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 seeding | Increased Cloud Run memory to 4Gi. |
Login/admin seed not working despite ADMIN_EMAIL and ADMIN_PASSWORD set | DB connection pointed to postgres host (invalid in Cloud Run) so migrations/seeding failed | Switched to Cloud SQL socket DATABASE_URL and attached instance. |
iam.serviceaccounts.actAs denied on deploy | Deployer principal lacked service-account impersonation permissions for runtime SA | Grant roles/iam.serviceAccountUser on target runtime SA or deploy without overriding SA. |
Permission denied for all log views in deploy diagnostics | Deployer principal missing logging read permissions | Grant 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 SA | Grant appropriate bucket/project storage roles to build/deployer principal. |
policy contains bindings with conditions while adding IAM binding | Project IAM policy uses conditional bindings | Add binding with explicit --condition in command, or use Console flow with condition fields. |
ReadTimeout uploading large source archive | Network/transient timeout while sending >200 MiB source tarball | Retry 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.
