pay.sh docs
Building with payDeployment

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 pay as 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-kms for production fee-payer signing.
  • Grant the runtime service account access only to the exact secrets and KMS key it needs.
  • Use the published latest pay image until versioned container tags are available.
  • Probe GET /__402/health and test a paid route with pay curl after 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.example

provider.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.

provider.yml
# 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.01

The 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.

terraform/Dockerfile
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.

terraform/entrypoint.sh
#!/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.sh

Build 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/main.tf
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"
}
terraform/variables.tf
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
}
terraform/outputs.tf
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
}
terraform/terraform.tfvars.example
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 32

Then 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-kms tells 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.signer and roles/cloudkms.viewer on 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 latest image 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_instances to 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

SymptomLikely causeFix
Cloud Run says the container never listened on the expected portThe entrypoint did not bind to $PORTUse --bind "0.0.0.0:${PORT:-8080}" in entrypoint.sh.
operator.signer.backend = gcp-kms requires the gcp_kms build featureThe image was built without Cloud KMS supportUse the official image that includes KMS support or rebuild pay with the gcp_kms feature.
/__402/health works but your route returns 404The path is missing from endpoints[] or has a method mismatchAdd the exact method and path to provider.yml, then rebuild and redeploy.
The gateway returns 402, but replay payment failsMissing KMS IAM, insufficient signer SOL, bad RPC URL, or unstable challenge secretCheck gateway logs, signer balance/IAM, RPC URL, and PAY_MPP_CHALLENGE_SECRET.
The upstream returns 401 after paymentThe upstream credential was not injectedCheck the routing.auth.value_from_env name and the matching Cloud Run secret env var.

On this page