Template repo for a JVM service on GCP Cloud Run. Brings together: a Ktor HTTP app with OpenAPI-driven codegen and Swagger UI, a gRPC app, a shared library module, Jib container builds, a GCS-backed Terraform deploy, and a one-shot bootstrap script that provisions Workload Identity Federation for GitHub Actions.
| Module | Purpose |
|---|---|
| lib/ | Shared utilities + test fixtures consumed by both apps. |
| ktor-app/ | HTTP service (Ktor + Netty, port 8080). Generates Kotlin models from openapi/api.yaml at build time and serves Swagger UI at /docs. Default deploy target. |
| grpc-app/ | gRPC service. Alternate deploy target — set TARGET_APP in the workflow to switch. |
The top-level openapi/ directory is the single source of truth for the HTTP API contract. Edit api.yaml directly; compileKotlin re-runs codegen, and Jib bakes the spec into the deployed container at /app/resources/openapi/ so the running app can serve it from /docs and /openapi.yaml.
- A GCP project you own (and billing enabled on it).
gcloudCLI, authenticated (gcloud auth login).- Terraform ≥ 1.14.3 (the deploy workflow pins this).
- JDK 22 (Foojay auto-downloads via the Gradle toolchain — no manual install required).
- Docker — for local
docker composeruns.
-
Clone & rename. Update
rootProject.namein settings.gradle.kts. Optionally rename thecom.autokotlinpackage across the source tree (and updateprojectNamein ktor-app/build.gradle.kts to match — it drives the OpenAPI-generated package). -
Create the GCP project (skip if it already exists):
gcloud projects create <PROJECT_ID> --name="<display name>" gcloud beta billing projects link <PROJECT_ID> --billing-account=<BILLING_ACCOUNT_ID>
-
Run the bootstrap script. This enables required APIs, creates the GCS state bucket, provisions the Workload Identity Federation pool/provider for GitHub Actions, and creates the deploy service account:
./scripts/bootstrap/setup-project.sh \ <PROJECT_ID> <PROJECT_NAME> <github-org/repo> <your-email>
Note the final block of output — you'll need the state bucket name, WIF provider name, and service-account email in the next step.
-
Create an Artifact Registry Docker repository (the bootstrap script does NOT do this — pick a name and location of your choice):
gcloud artifacts repositories create <repo-name> \ --repository-format=docker \ --location=<REGION> \ --project=<PROJECT_ID>
Your full registry host path is
<REGION>-docker.pkg.dev/<PROJECT_ID>/<repo-name>. -
Replace
REPLACE_MEplaceholders. Agrep -rn REPLACE_ME .will surface every one, but here's the explicit checklist:- .github/workflows/deploy.yml —
REGION,PROJECT_ID,PROJECT_NUMBER,WORKLOAD_IDENTITY_PROVIDER,SERVICE_ACCOUNT,GAR_REPO. - infra/main.tf —
backend "gcs"bucket name. - infra/variables.tf —
service_imagedefault (only matters if you runterraform planfrom your laptop; CI overrides it). - ktor-app/build.gradle.kts — fallback
to.imagehost in thejib { }block. - docker-compose.yml —
image:line (match what the jib config produces).
- .github/workflows/deploy.yml —
-
Push to
main. TheBuild & Deployworkflow runs Gradle tests, builds + pushes the image via Jib, then runsterraform applyto provision/update the Cloud Run service. PRs run the same flow up toterraform planand post the plan as a comment; add theterraform:applylabel on a PR to apply from a PR run.
The fastest inner loop bypasses Artifact Registry entirely — Jib can build straight into your local Docker daemon:
-
Build the image locally:
./gradlew :ktor-app:jibDockerBuild
This compiles the app (including OpenAPI codegen), packages it as a container image, and loads it into your Docker daemon under the
to.image:latesttag from ktor-app/build.gradle.kts. No network registry needed. -
Run it:
docker compose up
The app is now on
http://localhost:8080:GET /→Hello World!GET /hello→{"message":"Hello World!"}(served from the generatedHellomodel)GET /docs→ Swagger UI rendered fromopenapi/api.yamlGET /openapi.yaml→ the raw spec
-
Iterate. After source changes, re-run
./gradlew :ktor-app:jibDockerBuild, thendocker compose up --force-recreate. Editingopenapi/api.yamlre-triggers codegen as part ofcompileKotlin.
Alternatively, ./gradlew :ktor-app:run runs the app on the host JVM (no Docker, no compose) — useful when you want a live-reload-ish workflow under Gradle's --continuous flag.
The deploy workflow defaults to TARGET_APP: ktor-app. To deploy grpc-app instead:
- Change
TARGET_APPin .github/workflows/deploy.yml. - Keep the path-filter globs in sync (GitHub Actions path filters can't read
${{ env.* }}). - Ensure the chosen subproject has a jib block in its
build.gradle.kts. Today only ktor-app/build.gradle.kts has one — porting the same jib block to grpc-app/build.gradle.kts is a one-time copy.
scripts/bootstrap/setup-project.sh enables the APIs needed at bootstrap time (iam, iamcredentials, cloudresourcemanager, storage). The following are also required at runtime; the script does not enable them, so enable them manually (console or gcloud services enable …) on first setup:
- Cloud Run Admin API (
run.googleapis.com) - Artifact Registry API (
artifactregistry.googleapis.com)