# 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:

```txt
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.

```yaml title="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.

```dockerfile title="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](https://cloud.google.com/run/docs/container-contract) injects a `PORT` environment variable. Translate it to `--bind`; otherwise `pay server start` uses its local default port.

```sh title="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:

```sh
chmod +x terraform/entrypoint.sh
```

Build and push the image to Artifact Registry:

```sh
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.

```hcl title="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"
}
```

```hcl title="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
}
```

```hcl title="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
}
```

```hcl title="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:

```sh
openssl rand -hex 32
```

Then run Terraform:

```sh
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](https://cloud.google.com/kms/docs/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:

```sh
SERVICE_URL="$(terraform output -raw service_url)"
```

Check process health:

```sh
curl -fsS "$SERVICE_URL/__402/health"
```

Check the OpenAPI document:

```sh
curl -fsS "$SERVICE_URL/openapi.json" | jq '.openapi // .swagger // .info.title'
```

Call a paid endpoint through `pay`:

```sh
pay curl "$SERVICE_URL/v1/quote/AAPL"
```

Read Cloud Run logs when startup or payment verification fails:

```sh
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

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