Trovella Wiki

GCP Secret Manager

How Trovella provisions and accesses secrets in GCP Secret Manager -- Terraform module, naming conventions, IAM roles, and adding new secrets.

All production secrets live in GCP Secret Manager within the trovella-prod project. Terraform creates the secret shells (metadata only); values are set outside Terraform to keep them out of state files.

Terraform Module

The infra/modules/secret-manager/main.tf module is intentionally minimal:

resource "google_secret_manager_secret" "secret" {
  for_each  = toset(var.secrets)
  project   = var.project_id
  secret_id = "trovella-${each.value}"

  labels = var.labels

  replication {
    auto {}
  }
}

Key design decisions:

  • trovella- prefix: Every secret ID starts with trovella- to namespace them within the GCP project. The module prepends this automatically from the short name.
  • Auto replication: Secrets are replicated automatically across GCP regions. There is no need to pin a specific region for a single-region deployment.
  • No versions managed: The module only creates the secret resource. Versions (actual values) are never in Terraform.

Naming Convention

Secret Manager IDs follow the pattern trovella-{service}-{purpose}:

PatternExamples
trovella-{provider}-api-keytrovella-anthropic-api-key, trovella-google-ai-api-key, trovella-typesense-api-key, trovella-resend-api-key
trovella-{service}-{detail}trovella-database-url, trovella-better-auth-secret, trovella-better-auth-url
trovella-google-oauth-{part}trovella-google-oauth-client-id, trovella-google-oauth-client-secret
trovella-upstash-redis-{part}trovella-upstash-redis-url, trovella-upstash-redis-token
trovella-inngest-{part}trovella-inngest-event-key, trovella-inngest-signing-key
trovella-sentry-dsnSingle-value secrets use just the service and type

All names use lowercase kebab-case. The short name (without the trovella- prefix) is what appears in the Terraform secrets list in infra/environments/prod/main.tf.

Production Secret List

The infra/environments/prod/main.tf file declares all 14 secrets:

module "secrets" {
  source     = "../../modules/secret-manager"
  project_id = var.project_id
  labels     = local.labels

  secrets = [
    "anthropic-api-key",
    "google-ai-api-key",
    "database-url",
    "better-auth-secret",
    "better-auth-url",
    "google-oauth-client-id",
    "google-oauth-client-secret",
    "resend-api-key",
    "upstash-redis-url",
    "upstash-redis-token",
    "sentry-dsn",
    "inngest-event-key",
    "inngest-signing-key",
    "typesense-api-key",
  ]
}

IAM Access Control

Two service accounts can read secrets. Both are granted roles/secretmanager.secretAccessor at the project level:

VM Service Account

Defined in infra/modules/compute-vm/main.tf:

resource "google_service_account" "vm" {
  account_id   = "trovella-vm-prod"
  display_name = "Trovella VM (prod)"
}

resource "google_project_iam_member" "vm_secret_accessor" {
  project = var.project_id
  role    = "roles/secretmanager.secretAccessor"
  member  = "serviceAccount:${google_service_account.vm.email}"
}

The VM uses this service account's ambient credentials (via the GCE metadata server) to run gcloud secrets versions access in sync-secrets-vm.sh. No key files are involved.

CI Service Account (via WIF)

Defined in infra/modules/wif/main.tf:

resource "google_project_iam_member" "secret_accessor" {
  for_each = { for k, v in var.target_projects : k => v if k != "shared" }
  project  = each.value
  role     = "roles/secretmanager.secretAccessor"
  member   = "serviceAccount:${google_service_account.github_actions.email}"
}

GitHub Actions authenticates via Workload Identity Federation OIDC, then impersonates this service account. The grant excludes the trovella-shared project (which has no secrets). See CI Secret Access for details.

No human access by default

Individual developers do not have secretmanager.secretAccessor in production. To read a secret value for debugging, use gcloud with a privileged account or request temporary access.

Adding a New Secret

Follow this sequence when introducing a new external service or credential:

1. Add to Terraform

Add the short name to the secrets list in infra/environments/prod/main.tf:

secrets = [
  # ... existing secrets ...
  "new-service-api-key",
]

2. Apply Terraform

cd infra/environments/prod
terraform plan   # Verify: 1 to add, 0 to change, 0 to destroy
terraform apply

This creates an empty secret named trovella-new-service-api-key in Secret Manager.

3. Set the value

echo -n "the-actual-secret-value" | \
  gcloud secrets versions add trovella-new-service-api-key \
    --project=trovella-prod \
    --data-file=-

Use echo -n (no trailing newline) to avoid whitespace issues in connection strings and API keys.

4. Add to sync script

Add the mapping to the SECRETS array in infra/sync-secrets-vm.sh:

"trovella-new-service-api-key:NEW_SERVICE_API_KEY"

The format is secret-manager-id:ENV_VAR_NAME.

5. Add to .env.example files

Add the variable with a safe default (or empty) to both:

  • apps/web/.env.example -- for the web app
  • .env.example (root) -- if the variable is used by database scripts

6. Use in code

Read from process.env["NEW_SERVICE_API_KEY"] through the appropriate @repo/* wrapper package. Follow the package boundary rules -- external SDKs must be accessed through their dedicated wrapper.

7. Deploy

Push to main. The deploy pipeline will:

  1. Run sync-secrets-vm.sh (picks up the new mapping)
  2. Write the value to /opt/trovella/.env
  3. Restart containers (which read the new env var)

Terraform Outputs

The secret module outputs a map of short names to full resource IDs:

output "secret_ids" {
  description = "Map of short name to full secret ID"
  value       = { for k, v in google_secret_manager_secret.secret : k => v.id }
}

This is surfaced in the production environment outputs as secret_ids, useful for scripting and cross-referencing.

On this page