Deploy to Cloud Run with Terraform
Deploy pay to Cloud Run with Terraform, Secret Manager, Cloud KMS signing, and a pinned Docker image.
Use this path when you want native GCP service accounts, Secret Manager, Cloud KMS/HSM signing, and infrastructure review. Cloud Run runs one pinned pay container per paid API surface.
Agent summary
- Deploy
payas a Cloud Run service, not a Cloud Run function. - Use Secret Manager for
PAY_RPC_URL,PAY_PAYMENT_RECIPIENT,PAY_MPP_CHALLENGE_SECRET, and upstream API keys. - Use
operator.signer.backend: gcp-kmsfor production fee-payer signing. - Grant the runtime service account access only to the exact secrets and KMS key it needs.
- Use the published
latestpayimage until versioned container tags are available. - Probe
GET /__402/healthand test a paid route withpay curlafter each deploy.
What you are deploying
Use this layout for a Terraform-backed Cloud Run gateway:
deploy/
provider.yml
openapi.json
terraform/
Dockerfile
entrypoint.sh
main.tf
variables.tf
outputs.tf
terraform.tfvars.exampleprovider.yml describes the public paid API surface. openapi.json is optional but recommended, because it lets agents inspect your endpoint schema without guessing.
Provider spec
Start with one proxied endpoint. This example charges one cent per GET /v1/quote/{symbol} request and forwards the paid request to an upstream API.
# yaml-language-server: $schema=https://pay.sh/docs-assets/provider.schema.json
name: quotes
subdomain: quotes
title: 'Quote Gateway'
description: 'Paid stock quote endpoint.'
category: finance
version: v1
routing:
type: proxy
url: https://api.example.com/
auth:
method: header
key: Authorization
value_from_env: UPSTREAM_API_KEY
operator:
currencies:
usd: ['USDC']
network: mainnet
fee_payer: true
rpc_url: '${PAY_RPC_URL}'
recipient: '${PAY_PAYMENT_RECIPIENT}'
challenge_binding_secret: '${PAY_MPP_CHALLENGE_SECRET}'
signer:
backend: gcp-kms
key_name: '${PAY_GCP_KMS_KEY_NAME}'
pubkey: '${PAY_GCP_KMS_PUBKEY}'
endpoints:
- method: GET
path: 'v1/quote/{symbol}'
description: 'Return a quote for one ticker symbol.'
metering:
dimensions:
- direction: usage
unit: requests
scale: 1
tiers:
- price_usd: 0.01The gateway only exposes paths listed in endpoints[]. Anything else returns 404 even if the upstream serves it.
Docker image
This path mirrors the agent-gateway pattern: one Cloud Run service per API surface, one pinned Docker image, runtime secrets from Secret Manager, and a Cloud KMS signing key for the operator wallet.
Deploy pay as a Cloud Run service, not a Cloud Run function. The gateway needs a container process that binds to Cloud Run's $PORT.
Use the published pay image when it includes the signer backend you need. For Cloud KMS, the image must be built with the gcp_kms feature.
ARG PAY_IMAGE=ghcr.io/solana-foundation/pay:latest
FROM ${PAY_IMAGE}
COPY provider.yml /app/provider.yml
COPY openapi.json /app/openapi.json
COPY terraform/entrypoint.sh /app/entrypoint.sh
ENTRYPOINT ["/app/entrypoint.sh"]Cloud Run's container runtime contract injects a PORT environment variable. Translate it to --bind; otherwise pay server start uses its local default port.
#!/bin/sh
set -eu
set -- server start /app/provider.yml \
--openapi /app/openapi.json \
--bind "0.0.0.0:${PORT:-8080}" \
--currency "${PAY_CURRENCY:-USDC}"
[ -n "${PAY_RPC_URL:-}" ] && set -- "$@" --rpc-url "$PAY_RPC_URL"
[ -n "${PAY_PAYMENT_RECIPIENT:-}" ] && set -- "$@" --recipient "$PAY_PAYMENT_RECIPIENT"
exec pay "$@"Make the entrypoint executable before building:
chmod +x terraform/entrypoint.shBuild and push the image to Artifact Registry:
export PROJECT_ID=my-gcp-project
export REGION=us-central1
export REPOSITORY=pay-gateways
export IMAGE="${REGION}-docker.pkg.dev/${PROJECT_ID}/${REPOSITORY}/quotes:v1"
gcloud artifacts repositories create "$REPOSITORY" \
--repository-format=docker \
--location="$REGION"
gcloud auth configure-docker "${REGION}-docker.pkg.dev"
docker build -f terraform/Dockerfile -t "$IMAGE" .
docker push "$IMAGE"Create the Artifact Registry repository once per region. For repeat deploys, build a new immutable tag, push it, and pass that tag to Terraform.
Terraform
This is a compact, copyable Cloud Run setup. It creates:
- one runtime service account,
- four Secret Manager secrets,
- one Ed25519 Cloud KMS signing key,
- one public Cloud Run service,
- least-privilege IAM for the service account.
The example stores secret values through Terraform for brevity. In stricter production environments, create secret versions outside Terraform and pass only secret IDs into the module so plaintext values do not land in Terraform state.
terraform {
required_providers {
google = {
source = "hashicorp/google"
version = "~> 6.0"
}
}
}
provider "google" {
project = var.project_id
region = var.region
}
locals {
service_name = "${var.name}-pay-gateway"
kms_key_version = "${google_kms_crypto_key.signing.id}/cryptoKeyVersions/1"
}
resource "google_project_service" "apis" {
for_each = toset([
"artifactregistry.googleapis.com",
"cloudkms.googleapis.com",
"run.googleapis.com",
"secretmanager.googleapis.com",
])
service = each.key
disable_on_destroy = false
}
resource "google_service_account" "gateway" {
account_id = "${var.name}-pay-gateway"
display_name = "${var.name} pay gateway"
depends_on = [google_project_service.apis]
}
resource "google_kms_key_ring" "gateway" {
name = "${var.name}-pay-gateway"
location = var.region
depends_on = [google_project_service.apis]
}
resource "google_kms_crypto_key" "signing" {
name = "operator-signing-key"
key_ring = google_kms_key_ring.gateway.id
purpose = "ASYMMETRIC_SIGN"
version_template {
algorithm = "EC_SIGN_ED25519"
protection_level = "HSM"
}
}
resource "google_kms_crypto_key_iam_member" "signer" {
crypto_key_id = google_kms_crypto_key.signing.id
role = "roles/cloudkms.signer"
member = "serviceAccount:${google_service_account.gateway.email}"
}
resource "google_kms_crypto_key_iam_member" "viewer" {
crypto_key_id = google_kms_crypto_key.signing.id
role = "roles/cloudkms.viewer"
member = "serviceAccount:${google_service_account.gateway.email}"
}
resource "google_secret_manager_secret" "rpc_url" {
secret_id = "${var.name}-pay-rpc-url"
replication {
auto {}
}
depends_on = [google_project_service.apis]
}
resource "google_secret_manager_secret_version" "rpc_url" {
secret = google_secret_manager_secret.rpc_url.id
secret_data = var.rpc_url
}
resource "google_secret_manager_secret_iam_member" "rpc_url" {
secret_id = google_secret_manager_secret.rpc_url.id
role = "roles/secretmanager.secretAccessor"
member = "serviceAccount:${google_service_account.gateway.email}"
}
resource "google_secret_manager_secret" "payment_recipient" {
secret_id = "${var.name}-payment-recipient"
replication {
auto {}
}
depends_on = [google_project_service.apis]
}
resource "google_secret_manager_secret_version" "payment_recipient" {
secret = google_secret_manager_secret.payment_recipient.id
secret_data = var.payment_recipient
}
resource "google_secret_manager_secret_iam_member" "payment_recipient" {
secret_id = google_secret_manager_secret.payment_recipient.id
role = "roles/secretmanager.secretAccessor"
member = "serviceAccount:${google_service_account.gateway.email}"
}
resource "google_secret_manager_secret" "mpp_challenge_secret" {
secret_id = "${var.name}-mpp-challenge-secret"
replication {
auto {}
}
depends_on = [google_project_service.apis]
}
resource "google_secret_manager_secret_version" "mpp_challenge_secret" {
secret = google_secret_manager_secret.mpp_challenge_secret.id
secret_data = var.mpp_challenge_secret
}
resource "google_secret_manager_secret_iam_member" "mpp_challenge_secret" {
secret_id = google_secret_manager_secret.mpp_challenge_secret.id
role = "roles/secretmanager.secretAccessor"
member = "serviceAccount:${google_service_account.gateway.email}"
}
resource "google_secret_manager_secret" "upstream_api_key" {
secret_id = "${var.name}-upstream-api-key"
replication {
auto {}
}
depends_on = [google_project_service.apis]
}
resource "google_secret_manager_secret_version" "upstream_api_key" {
secret = google_secret_manager_secret.upstream_api_key.id
secret_data = var.upstream_api_key
}
resource "google_secret_manager_secret_iam_member" "upstream_api_key" {
secret_id = google_secret_manager_secret.upstream_api_key.id
role = "roles/secretmanager.secretAccessor"
member = "serviceAccount:${google_service_account.gateway.email}"
}
resource "google_cloud_run_v2_service" "gateway" {
name = local.service_name
location = var.region
ingress = "INGRESS_TRAFFIC_ALL"
template {
service_account = google_service_account.gateway.email
scaling {
min_instance_count = 0
max_instance_count = var.max_instances
}
containers {
image = var.image
ports {
container_port = 8080
}
resources {
limits = {
cpu = "1"
memory = "512Mi"
}
}
env {
name = "PAY_RPC_URL"
value_source {
secret_key_ref {
secret = google_secret_manager_secret.rpc_url.secret_id
version = "latest"
}
}
}
env {
name = "PAY_PAYMENT_RECIPIENT"
value_source {
secret_key_ref {
secret = google_secret_manager_secret.payment_recipient.secret_id
version = "latest"
}
}
}
env {
name = "PAY_MPP_CHALLENGE_SECRET"
value_source {
secret_key_ref {
secret = google_secret_manager_secret.mpp_challenge_secret.secret_id
version = "latest"
}
}
}
env {
name = "UPSTREAM_API_KEY"
value_source {
secret_key_ref {
secret = google_secret_manager_secret.upstream_api_key.secret_id
version = "latest"
}
}
}
env {
name = "PAY_GCP_KMS_KEY_NAME"
value = local.kms_key_version
}
env {
name = "PAY_GCP_KMS_PUBKEY"
value = var.kms_pubkey
}
env {
name = "HOME"
value = "/tmp"
}
startup_probe {
http_get {
path = "/__402/health"
port = 8080
}
initial_delay_seconds = 3
period_seconds = 5
failure_threshold = 10
}
}
}
depends_on = [
google_kms_crypto_key_iam_member.signer,
google_kms_crypto_key_iam_member.viewer,
google_secret_manager_secret_iam_member.rpc_url,
google_secret_manager_secret_iam_member.payment_recipient,
google_secret_manager_secret_iam_member.mpp_challenge_secret,
google_secret_manager_secret_iam_member.upstream_api_key,
]
}
resource "google_cloud_run_v2_service_iam_member" "public" {
project = var.project_id
location = var.region
name = google_cloud_run_v2_service.gateway.name
role = "roles/run.invoker"
member = "allUsers"
}variable "project_id" {
type = string
description = "GCP project ID."
}
variable "region" {
type = string
description = "GCP region."
default = "us-central1"
}
variable "name" {
type = string
description = "Short deployment name used in resource names."
default = "quotes"
}
variable "image" {
type = string
description = "Fully qualified Artifact Registry image, including immutable tag or digest."
}
variable "rpc_url" {
type = string
description = "Solana RPC URL used to verify and settle payments."
sensitive = true
}
variable "payment_recipient" {
type = string
description = "Solana wallet that receives gateway revenue. Keep this separate from the fee-payer signer where possible."
sensitive = true
}
variable "mpp_challenge_secret" {
type = string
description = "Stable random secret used to bind gateway-issued challenges. Generate once per environment."
sensitive = true
}
variable "upstream_api_key" {
type = string
description = "Credential for the upstream API."
sensitive = true
}
variable "kms_pubkey" {
type = string
description = "Solana public key corresponding to the Cloud KMS Ed25519 signing key."
}
variable "max_instances" {
type = number
description = "Maximum Cloud Run instances. Tune this to your upstream rate limits and fee-payer SOL budget."
default = 10
}output "service_url" {
description = "Public Cloud Run URL."
value = google_cloud_run_v2_service.gateway.uri
}
output "kms_key_version" {
description = "KMS key version passed to pay as PAY_GCP_KMS_KEY_NAME."
value = local.kms_key_version
}project_id = "my-gcp-project"
region = "us-central1"
name = "quotes"
image = "us-central1-docker.pkg.dev/my-gcp-project/pay-gateways/quotes:v1"
kms_pubkey = "YOUR_KMS_SIGNER_SOLANA_PUBKEY"
rpc_url = "https://mainnet.helius-rpc.com/?api-key=..."
payment_recipient = "YOUR_REVENUE_WALLET"
upstream_api_key = "Bearer upstream-secret"
# Generate once with: openssl rand -hex 32
mpp_challenge_secret = "replace-with-64-hex-characters"Generate the challenge secret once, copy it into terraform.tfvars, and apply the service:
openssl rand -hex 32Then run Terraform:
cd terraform
terraform init
terraform apply -var="image=${IMAGE}"Use the terraform.tfvars.example fields for the stable environment values. Generate mpp_challenge_secret once and keep it stable; rotating it invalidates outstanding challenges and sessions.
Signing and secure enclaves
Local pay accounts use the platform keystore path where available: Apple Keychain with Touch ID on macOS, Windows Hello on Windows, GNOME Keyring or 1Password on Linux. That is the right shape for user-approved local spending, but it is not a good production gateway signer because Cloud Run cannot stop on every request for an interactive biometric prompt.
Use Cloud KMS when you need production signer isolation:
operator.signer.backend: gcp-kmstells the gateway to ask Cloud KMS to sign operator transactions.- The Cloud KMS key is Ed25519 and created with
purpose = "ASYMMETRIC_SIGN". protection_level = "HSM"keeps private key material inside Google-managed Cloud HSM modules. If your region or organization does not allow HSM keys, use software KMS only as an explicit tradeoff.- The Cloud Run service account receives
roles/cloudkms.signerandroles/cloudkms.vieweron that one key, plus Secret Manager accessor on the secrets it needs. - Do not store a production Solana keypair JSON in the image or provider spec. Avoid putting it in Secret Manager as the normal path; KMS signing exists so the private key is not exportable to the container.
Keep the signer wallet, fee budget, and revenue wallet separate when possible. Fund the signer with enough SOL to pay sponsored transaction fees, and send stablecoin revenue to PAY_PAYMENT_RECIPIENT.
Verify the deployment
Set the service URL.
Read the Cloud Run URL from Terraform outputs:
SERVICE_URL="$(terraform output -raw service_url)"Check process health:
curl -fsS "$SERVICE_URL/__402/health"Check the OpenAPI document:
curl -fsS "$SERVICE_URL/openapi.json" | jq '.openapi // .swagger // .info.title'Call a paid endpoint through pay:
pay curl "$SERVICE_URL/v1/quote/AAPL"Read Cloud Run logs when startup or payment verification fails:
gcloud run services logs read quotes-pay-gateway \
--region="$REGION" \
--project="$PROJECT_ID"Production checklist
- Record the deployed
latestimage digest in your deploy notes until versioned container tags are available. - Keep provider specs, OpenAPI files, and Terraform reviewed together. A priced path that is missing from
endpoints[]will not be exposed. - Grant the runtime service account access only to the exact secrets and KMS key it needs.
- Set
max_instancesto match your upstream rate limits and fee-payer SOL budget. - Use a custom domain or load balancer when you need stable public URLs, TLS policy control, or multiple regional services.
- Alert on
/__402/health, Cloud Run crash loops, payment verification errors, KMS signing errors, and upstream 5xx responses.
Troubleshooting
| Symptom | Likely cause | Fix |
|---|---|---|
| Cloud Run says the container never listened on the expected port | The entrypoint did not bind to $PORT | Use --bind "0.0.0.0:${PORT:-8080}" in entrypoint.sh. |
operator.signer.backend = gcp-kms requires the gcp_kms build feature | The image was built without Cloud KMS support | Use the official image that includes KMS support or rebuild pay with the gcp_kms feature. |
/__402/health works but your route returns 404 | The path is missing from endpoints[] or has a method mismatch | Add the exact method and path to provider.yml, then rebuild and redeploy. |
| The gateway returns 402, but replay payment fails | Missing KMS IAM, insufficient signer SOL, bad RPC URL, or unstable challenge secret | Check gateway logs, signer balance/IAM, RPC URL, and PAY_MPP_CHALLENGE_SECRET. |
| The upstream returns 401 after payment | The upstream credential was not injected | Check the routing.auth.value_from_env name and the matching Cloud Run secret env var. |