title: GCP server deploy (Artifact Registry and Cloud Run)
feature: infra type: ops status: current updated: 2026-05-29
GCP server deploy (Artifact Registry and Cloud Run)
> Runbook for shipping the Oter Ktor server to Google Cloud (Artifact Registry + Cloud Run + Cloud SQL) from GitHub Actions using Workload Identity Federation, including required variables and common failure modes.
Overview
The server image is built from [server/Dockerfile](../../server/Dockerfile) at the repository root and pushed to Artifact Registry. The same workflow deploys the new image to Cloud Run with the Cloud SQL Unix socket connector, environment variables for non-secrets, and Secret Manager bindings for database and JWT material. Application configuration for the database is described in [Config.kt](../../server/src/main/kotlin/com/esteban/ruano/utils/Config.kt); the process listens on the **PORT** environment variable (Cloud Run sets this automatically).
The VM-based path ([infra/deploy.sh](../../infra/deploy.sh), [infra/docker-compose.yml](../../infra/docker-compose.yml), nginx) remains available for legacy or hybrid hosting but is not the target for production API or web traffic once Cloud Run is live (see gcp-web-cloud-run.md for the web app).
The diagram shows the production server path: CI builds and pushes the image, then deploys Cloud Run, which connects to Cloud SQL through the configured instance attachment.
Problem
You need to configure or troubleshoot automated server releases to GCP, or understand what broke after changing credentials, IAM, or repository settings.
Prerequisites
- GCP project with billing enabled.
- APIs enabled: Artifact Registry, Cloud Run, Cloud SQL Admin, IAM Credentials, Secret Manager (and Service Usage as needed).
- Artifact Registry Docker repository (for example oter in region us-east1).
- Cloud SQL PostgreSQL instance and database; note the instance connection name project:region:instance.
- Cloud Storage bucket for receipts, study icons, and blog images (e.g. oter-storage-prod in us-east1); the runtime SA holds roles/storage.objectAdmin on it. The server authenticates via Application Default Credentials — no JSON key and no Secret Manager entry are needed for storage.
- Secrets in Secret Manager for at least DB_PASSWORD and JWT_SECRET (and optionally OPENAI_API_KEY), using the secret ids you will reference from GitHub variables GCP_SM_*.
- GitHub repository secrets: GCP_WIF_PROVIDER, GCP_WIF_SERVICE_ACCOUNT (Workload Identity Federation).
- GitHub repository variables: see Resolution table below.
Diagnosis
Use the tree to decide whether to fix federation and registry access first, then image coordinates, then Cloud Run revision health, Secret Manager, or the Cloud SQL attachment.
Resolution
1. Runtime service account
Create a service account used by the Cloud Run revision (for example oter-server-runtime@PROJECT.iam.gserviceaccount.com). Grant at least:
- Cloud SQL Client on the project (or instance).
- Secret Manager Secret Accessor on the secrets you mount (DB_PASSWORD, JWT_SECRET, optional OPENAI_API_KEY).
- Storage Object Admin (roles/storage.objectAdmin) on the GCS bucket named in GCS_BUCKET_NAME. Bind it at the bucket scope, not project-wide:
gcloud storage buckets add-iam-policy-binding gs://oter-storage-prod \
--member='serviceAccount:oter-server-runtime@oter-490318.iam.gserviceaccount.com' \
--role=roles/storage.objectAdmin --project=oter-490318Store its email in repository variable **GCP_RUNTIME_SERVICE_ACCOUNT**.
2. Deployer service account (GitHub Actions)
Create a separate service account for CI (for example github-deployer@PROJECT.iam.gserviceaccount.com). Grant at least:
- Artifact Registry Writer (or roles/artifactregistry.writer) on the Docker repository.
- Cloud Run Admin (or a custom role allowing run.services.update / run.revisions.create).
- Service Account User on **GCP_RUNTIME_SERVICE_ACCOUNT** so deploy can attach the runtime identity.
Configure Workload Identity Federation: GitHub OIDC pool and provider bound to this repository (and optionally branches or environments), allowing the deployer SA to be impersonated. Put the full provider resource name in secret **GCP_WIF_PROVIDER** and the deployer SA email in **GCP_WIF_SERVICE_ACCOUNT**.
3. Where this lives in GitHub (Secrets vs Variables)
Both are under the same settings page, different tabs:
1. Open the GitHub repository (not your profile). 2. Settings → Secrets and variables → Actions. 3. Use: - Secrets — encrypted values; masked in logs; for anything sensitive (WIF identifiers you treat as sensitive, or you can keep only the SA email as a secret). - Variables — plain configuration; visible to anyone with repo settings access; safe for project id, region, service names, and GCP Secret Manager secret ids (the name of the secret in GCP, not the password or JWT string).
The workflow is written so Workload Identity uses Actions secrets (GCP_WIF_PROVIDER, GCP_WIF_SERVICE_ACCOUNT). Everything in the table below uses **vars.* in the workflow**, which means Actions → Variables (repository variables). If you prefer to hide even the GCP secret resource names, you can duplicate those entries under Secrets instead and change the workflow from vars.GCP_SM_* to secrets.GCP_SM_* (same names).
Organization-level: you can also define organization secrets/variables and grant this repo access; the UI is Organization settings → Secrets and variables → Actions.
4. Repository variables (GitHub)
| Variable | Example / note |
|---|---|
GCP_PROJECT_ID | oter-490318 |
GCP_REGION | us-east1 |
GCP_AR_REPOSITORY | Docker repo id in Artifact Registry |
CLOUD_RUN_SERVICE | Cloud Run service name |
CLOUD_SQL_INSTANCE | project:region:instance (e.g. oter-490318:us-east1:oter-db). |
GCP_RUNTIME_SERVICE_ACCOUNT | e.g. oter-server-runtime@oter-490318.iam.gserviceaccount.com |
DB_NAME | oter_prod |
DB_USER | postgres |
GCS_BUCKET_NAME | oter-storage-prod (bucket the runtime SA holds storage.objectAdmin on; auth is via ADC, not Secret Manager) |
GCP_SM_DB_PASSWORD | Secret Manager secret id (not the password text) for DB_PASSWORD |
GCP_SM_JWT_SECRET | Secret id for JWT_SECRET |
GCP_SM_OPENAI_API_KEY | Optional secret id for OPENAI_API_KEY (AI chat / task-tag AI); omit if unused |
GCP_SM_FIREBASE_CREDENTIALS | Optional secret id for FIREBASE_CREDENTIALS (FCM push). JSON from Firebase project oter-8c4e4 (see androidApp/google-services.json); omit disables push |
5. Workflow behavior
Workflow file: [.github/workflows/publish-server-release.yml](../../.github/workflows/publish-server-release.yml).
- Build: authenticates with WIF, logs in to REGION-docker.pkg.dev, builds and pushes oter-server with tags from docker/metadata-action (includes the version_tag used for deploy).
- Deploy: gcloud run deploy with --add-cloudsql-instances, --service-account, --set-env-vars (DB_NAME, DB_USER, CLOUD_SQL_INSTANCE, GCS_BUCKET_NAME, ENVIRONMENT=production), and --set-secrets for DB_PASSWORD, JWT_SECRET, and optional OPENAI_API_KEY / FIREBASE_CREDENTIALS. --set-env-vars replaces the env block on each revision, so any var the server expects (including GCS_BUCKET_NAME) must be listed there or it will be dropped on the next deploy.
Adjust ingress, CPU, memory, concurrency, and min instances in the Cloud Run console or by extending the workflow; defaults may be fine for first deploy.
6. Manual redeploy from local CLI
Use this path to ship a hotfix or experiment without going through GitHub Actions (no tag push, no WIF). The Cloud Run service keeps its existing env vars, secrets, runtime SA, and Cloud SQL attachment — only the image is replaced.
6.1 One-time setup
gcloud auth login
gcloud config set project oter-490318
gcloud auth configure-docker us-east1-docker.pkg.devThe configure-docker step writes a credential helper for the Artifact Registry host so docker push can authenticate.
6.2 Build, push, deploy
Run from the repository root (so the Docker context includes gradlew and the Gradle project):
docker build \
--platform=linux/amd64 \
-f server/Dockerfile \
-t us-east1-docker.pkg.dev/oter-490318/oter/app:latest \
.
docker push us-east1-docker.pkg.dev/oter-490318/oter/app:latest
gcloud run deploy oter \
--image=us-east1-docker.pkg.dev/oter-490318/oter/app:latest \
--region=us-east1Important details:
- **--platform=linux/amd64** is required when building from Apple Silicon or any ARM host. Cloud Run runs linux/amd64; an ARM image will start-fail with exec format error.
- **-f server/Dockerfile .** uses the server Dockerfile but the repo root as build context. Building with server/ as context fails because gradlew lives at the repo root (see the guard in server/Dockerfile).
- Tag image coordinates are REGION-docker.pkg.dev/PROJECT/AR_REPO/IMAGE:TAG. The CI workflow pushes to image **oter-server** with version tags (see section 5); the manual path above uses **app:latest** for the existing Cloud Run service. Keep the same image name the service is currently pulling from — check it with gcloud run services describe oter --region=us-east1 --format='value(spec.template.spec.containers[0].image)'.
- Reusing :latest is fine for hotfixes but does not create traceable history. For anything beyond an emergency, prefer a SHA or version tag (for examplye :hotfix-$(git rev-parse --short HEAD)) and pass that to --image=....
- gcloud run deploy without --set-env-vars / --set-secrets / --service-account / --add-cloudsql-instances preserves the values from the previous revision. To change any of them in a manual deploy, pass the same flags the workflow uses.
6.3 Verify the new revision
gcloud run services describe oter --region=us-east1 \
--format='value(status.url,status.latestReadyRevisionName)'
curl -fsS "$(gcloud run services describe oter --region=us-east1 --format='value(status.url)')/health"If /health fails, jump to Diagnosis above and tail logs with:
gcloud run services logs tail oter --region=us-east17. First-time manual checks
- Confirm the service URL responds on **/health**.
- Confirm the revision logs show successful Flyway migration and no Cloud SQL auth errors.
- If clients get 403 from Cloud Run, add invoker IAM (roles/run.invoker) for your gateway identity or enable public access according to your security model.
- If logs show R2DBC **SSLHandshakeException** / **Hostname or IP address is undefined** / **InstanceCheckingTrustManger** on port 3307: Ktor 3.x brings Netty 4.2.x; use **cloud-sql-connector-r2dbc-postgres and postgres-socket-factory 1.28.0+** (socket-factory#2227 ). Older connectors break TLS with Netty 4.2. The server also sets **sslmode=disable** on the GCP R2DBC URL in [Config.kt](../../server/src/main/kotlin/com/esteban/ruano/utils/Config.kt). Rebuild the image after upgrading those dependencies.
- If the app crashes at startup with **NoSuchFieldError: ... PostgresqlConnectionFactoryProvider ... SSL_NEGOTIATION**: **org.postgresql:r2dbc-postgresql must be 1.1.x** (see gradle/libs.versions.toml). **cloud-sql-connector-r2dbc-postgres 1.28.x** is built against r2dbc-postgresql 1.1.1; an older 1.0.x driver on the classpath causes that error at runtime.
8. Templates in repo
- [infra/gcp/server.env.example](../../infra/gcp/server.env.example) — env var names for manual updates.
- [infra/gcp/README.md](../../infra/gcp/README.md) — short index.
Prevention
- Pin important changes in pull requests that touch .github/workflows/publish-server-release.yml or IAM bindings.
- Rotate JWT and DB secrets only with a coordinated client/session plan; see [server/README.md](../../server/README.md).
- Keep Artifact Registry retention policy sensible; unlike DOCR tag cleanup in the old workflow, image lifecycle is governed in GCP (lifecycle rules or manual deletes).
Related Docs
- gcp-web-cloud-run.md — Web (Next.js) deploy on Cloud Run (sibling; same WIF secrets and Artifact Registry repo).
- automation-options.md — automation/workflow tooling options and cost analysis.
- DOCR quota fix — legacy DigitalOcean registry housekeeping.
- [server/README.md](../../server/README.md) — environment variables and JWT stability notes.