Skip to content

ci(deploy): add the OpenTofu configuration for the GCP dev environment - #1132

Draft
muhlemmer wants to merge 9 commits into
mainfrom
ci/gcp-dev-infra
Draft

muhlemmer wants to merge 9 commits into
mainfrom
ci/gcp-dev-infra

Conversation

@muhlemmer

@muhlemmer muhlemmer commented Sep 3, 2026

Copy link
Copy Markdown
Contributor

Summary

Part of #734.

Recovers the infrastructure-as-code from the closed #188 (feat/tofu-infra), rebased onto current main.

The important thing about that PR: the resources it declares are already applied in zitadel-dev-492704. The CI plan comment on #188 shows every resource Refreshing state... against real IDs, and the final plan was 0 to add, 1 to change, 0 to destroy (the one change being cosmetic drift — cpu "1000m" → "1", short network names → fully qualified). The branch was abandoned before merge, so main has never carried the code that describes our live infrastructure. State lives in gs://zitadel-ops-tofu-state under prefix infra/dev.

Rather than rebase ten commits with merge commits in them, this takes the tree as a single commit off current main — the original PR is closed and its history is not worth preserving.

Changes on top of the abandoned branch:

  • New secrets module. It owns the Secret Manager containers and the access bindings but never the values. A secret with no versions carries no credential material, so nothing sensitive reaches OpenTofu state, while who may read each secret stays reviewable in code. Values come from GitHub environment secrets (see the companion PR).
  • Least-privilege bindings. The runtime SA gets secretAccessor; the github-deploy SA gets secretVersionAdder — version adder, not admin, so CI can write a new version but cannot read one back, delete the secret, or change who has access.
  • README correction. The secrets section documented NEXTGEN_SERVER_ENCRYPTION_KEY, a variable that no longer exists — it was replaced by the rotatable master-key config, which is a map and therefore cannot be set from an environment variable at all. The rewritten section explains why the master key has to arrive as a mounted file.
  • Documents the unwired migrate job. zitadel-migrate-<env> has no work to do: the released binary has no migrate subcommand, so migrations run at server startup.

Nothing here changes any GCP resource on its own — merging runs tofu plan on the mgmt tier and tofu apply on dev, which should be close to a no-op apart from the new (empty) secret containers and their bindings.

Validation

  • tofu validateSuccess! The configuration is valid.
  • tofu fmt -check -recursive — clean.
  • tofu init -backend=false against the committed lock file.

Not run: tofu plan against the real project. This environment has no gcloud and no GCP credentials, so the plan will first appear on this PR from CI.

Release notes / changeset

No changeset required — no shipped behavior changed. Deployment wiring only, which is why the title is ci rather than feat.

Notes

🤖 Generated with Claude Code

Copilot AI lite review requested due to automatic review settings September 3, 2026 13:49
@vercel

vercel Bot commented Sep 3, 2026

Copy link
Copy Markdown

The latest updates on your projects. Learn more about Vercel for GitHub.

Project Deployment Actions Updated
nextgen Ready Ready Preview Sep 9, 2026 3:29pm UTC
nextgen-docs Ready Ready Preview Sep 9, 2026 3:29pm UTC
nextgen-mock-zitadel Ready Ready Preview Sep 9, 2026 3:29pm UTC

Request Review

@github-actions

github-actions Bot commented Sep 3, 2026

Copy link
Copy Markdown
Contributor

OpenTofu Plan — dev

module.load_balancer.google_compute_global_address.ipv4: Refreshing state... [id=projects/zitadel-dev-492704/global/addresses/zitadel-ip-dev]
module.load_balancer.google_compute_global_address.ipv6: Refreshing state... [id=projects/zitadel-dev-492704/global/addresses/zitadel-ipv6-dev]
module.dns.google_dns_managed_zone.default: Refreshing state... [id=projects/zitadel-dev-492704/managedZones/zitadel-dev]
module.project.google_project_service.apis["cloudtrace.googleapis.com"]: Refreshing state... [id=zitadel-dev-492704/cloudtrace.googleapis.com]
module.project.google_project_service.apis["vpcaccess.googleapis.com"]: Refreshing state... [id=zitadel-dev-492704/vpcaccess.googleapis.com]
module.project.google_project_service.apis["servicenetworking.googleapis.com"]: Refreshing state... [id=zitadel-dev-492704/servicenetworking.googleapis.com]
module.load_balancer.google_compute_url_map.http_redirect: Refreshing state... [id=projects/zitadel-dev-492704/global/urlMaps/zitadel-http-redirect-dev]
module.project.google_project_service.apis["certificatemanager.googleapis.com"]: Refreshing state... [id=zitadel-dev-492704/certificatemanager.googleapis.com]
module.project.google_project_service.apis["dns.googleapis.com"]: Refreshing state... [id=zitadel-dev-492704/dns.googleapis.com]
module.project.google_project_service.apis["telemetry.googleapis.com"]: Refreshing state... [id=zitadel-dev-492704/telemetry.googleapis.com]
module.project.google_project_service.apis["run.googleapis.com"]: Refreshing state... [id=zitadel-dev-492704/run.googleapis.com]
module.project.google_project_service.apis["compute.googleapis.com"]: Refreshing state... [id=zitadel-dev-492704/compute.googleapis.com]
module.project.google_project_service.apis["iamcredentials.googleapis.com"]: Refreshing state... [id=zitadel-dev-492704/iamcredentials.googleapis.com]
module.project.google_project_service.apis["cloudresourcemanager.googleapis.com"]: Refreshing state... [id=zitadel-dev-492704/cloudresourcemanager.googleapis.com]
module.project.google_project_service.apis["iam.googleapis.com"]: Refreshing state... [id=zitadel-dev-492704/iam.googleapis.com]
module.project.google_project_service.apis["secretmanager.googleapis.com"]: Refreshing state... [id=zitadel-dev-492704/secretmanager.googleapis.com]
module.project.google_project_service.apis["sqladmin.googleapis.com"]: Refreshing state... [id=zitadel-dev-492704/sqladmin.googleapis.com]
module.dns.google_dns_record_set.wildcard_aaaa: Refreshing state... [id=projects/zitadel-dev-492704/managedZones/zitadel-dev/rrsets/*.dev.zitadel.io./AAAA]
module.load_balancer.google_compute_target_http_proxy.redirect: Refreshing state... [id=projects/zitadel-dev-492704/global/targetHttpProxies/zitadel-http-redirect-dev]
module.dns.google_dns_record_set.aaaa: Refreshing state... [id=projects/zitadel-dev-492704/managedZones/zitadel-dev/rrsets/dev.zitadel.io./AAAA]
module.dns.google_dns_record_set.wildcard_a: Refreshing state... [id=projects/zitadel-dev-492704/managedZones/zitadel-dev/rrsets/*.dev.zitadel.io./A]
module.dns.google_dns_record_set.a: Refreshing state... [id=projects/zitadel-dev-492704/managedZones/zitadel-dev/rrsets/dev.zitadel.io./A]
module.load_balancer.google_compute_global_forwarding_rule.http_redirect_ipv4: Refreshing state... [id=projects/zitadel-dev-492704/global/forwardingRules/zitadel-http-fwd-dev]
module.load_balancer.google_compute_global_forwarding_rule.http_redirect_ipv6: Refreshing state... [id=projects/zitadel-dev-492704/global/forwardingRules/zitadel-http6-fwd-dev]
module.iam.google_project_iam_member.run_cert_manager: Refreshing state... [id=zitadel-dev-492704/roles/certificatemanager.editor/serviceAccount:zitadel-run@zitadel-dev-492704.iam.gserviceaccount.com]
module.iam.google_service_account.run: Refreshing state... [id=projects/zitadel-dev-492704/serviceAccounts/zitadel-run@zitadel-dev-492704.iam.gserviceaccount.com]
module.iam.google_project_iam_member.run_dns: Refreshing state... [id=zitadel-dev-492704/roles/dns.admin/serviceAccount:zitadel-run@zitadel-dev-492704.iam.gserviceaccount.com]
module.network.google_compute_network.default: Refreshing state... [id=projects/zitadel-dev-492704/global/networks/zitadel-dev]
module.iam.google_service_account.migrator: Refreshing state... [id=projects/zitadel-dev-492704/serviceAccounts/zitadel-migrator@zitadel-dev-492704.iam.gserviceaccount.com]
module.iam.google_project_iam_member.run_lb_admin: Refreshing state... [id=zitadel-dev-492704/roles/compute.loadBalancerAdmin/serviceAccount:zitadel-run@zitadel-dev-492704.iam.gserviceaccount.com]
module.certificate_map.google_certificate_manager_dns_authorization.baseline: Refreshing state... [id=projects/zitadel-dev-492704/locations/global/dnsAuthorizations/zitadel-baseline-auth-dev]
module.certificate_map.google_certificate_manager_certificate_map.default: Refreshing state... [id=projects/zitadel-dev-492704/locations/global/certificateMaps/zitadel-cert-map-dev]
module.network.google_compute_address.nat: Refreshing state... [id=projects/zitadel-dev-492704/regions/us-central1/addresses/zitadel-nat-dev]
module.iam.google_service_account_iam_member.deploy_impersonate_migrator: Refreshing state... [id=projects/zitadel-dev-492704/serviceAccounts/zitadel-migrator@zitadel-dev-492704.iam.gserviceaccount.com/roles/iam.serviceAccountUser/serviceAccount:github-deploy@zitadel-ops.iam.gserviceaccount.com]
module.iam.google_project_iam_member.migrator_logging: Refreshing state... [id=zitadel-dev-492704/roles/logging.logWriter/serviceAccount:zitadel-migrator@zitadel-dev-492704.iam.gserviceaccount.com]
module.network.google_compute_router.default: Refreshing state... [id=projects/zitadel-dev-492704/regions/us-central1/routers/zitadel-router-dev]
module.network.google_compute_subnetwork.cloud_run: Refreshing state... [id=projects/zitadel-dev-492704/regions/us-central1/subnetworks/zitadel-run-dev]
module.certificate_map.google_certificate_manager_certificate.baseline: Refreshing state... [id=projects/zitadel-dev-492704/locations/global/certificates/zitadel-baseline-cert-dev]
module.postgres.google_compute_global_address.private_service_access: Refreshing state... [id=projects/zitadel-dev-492704/global/addresses/zitadel-postgres-psa-dev]
module.iam.google_project_iam_member.run_logging: Refreshing state... [id=zitadel-dev-492704/roles/logging.logWriter/serviceAccount:zitadel-run@zitadel-dev-492704.iam.gserviceaccount.com]
module.iam.google_project_iam_member.run_metrics: Refreshing state... [id=zitadel-dev-492704/roles/monitoring.metricWriter/serviceAccount:zitadel-run@zitadel-dev-492704.iam.gserviceaccount.com]
module.iam.google_service_account_iam_member.deploy_impersonate_run: Refreshing state... [id=projects/zitadel-dev-492704/serviceAccounts/zitadel-run@zitadel-dev-492704.iam.gserviceaccount.com/roles/iam.serviceAccountUser/serviceAccount:github-deploy@zitadel-ops.iam.gserviceaccount.com]
module.dns.google_dns_record_set.cert_challenge["baseline"]: Refreshing state... [id=projects/zitadel-dev-492704/managedZones/zitadel-dev/rrsets/_acme-challenge.dev.zitadel.io./CNAME]
module.iam.google_project_iam_member.run_tracing: Refreshing state... [id=zitadel-dev-492704/roles/cloudtrace.agent/serviceAccount:zitadel-run@zitadel-dev-492704.iam.gserviceaccount.com]
module.postgres.google_service_networking_connection.private_vpc_connection: Refreshing state... [id=projects%2Fzitadel-dev-492704%2Fglobal%2Fnetworks%2Fzitadel-dev:servicenetworking.googleapis.com]
module.network.google_compute_router_nat.default: Refreshing state... [id=zitadel-dev-492704/us-central1/zitadel-router-dev/zitadel-nat-dev]
module.cloud_run.google_cloud_run_v2_job.migrate: Refreshing state... [id=projects/zitadel-dev-492704/locations/us-central1/jobs/zitadel-migrate-dev]
module.cloud_run.google_cloud_run_v2_service.zitadel: Refreshing state... [id=projects/zitadel-dev-492704/locations/us-central1/services/zitadel-dev]
module.certificate_map.google_certificate_manager_certificate_map_entry.baseline_apex: Refreshing state... [id=projects/zitadel-dev-492704/locations/global/certificateMaps/zitadel-cert-map-dev/certificateMapEntries/zitadel-baseline-apex-dev]
module.certificate_map.google_certificate_manager_certificate_map_entry.baseline_wildcard: Refreshing state... [id=projects/zitadel-dev-492704/locations/global/certificateMaps/zitadel-cert-map-dev/certificateMapEntries/zitadel-baseline-wildcard-dev]
module.cloud_run.google_cloud_run_v2_service_iam_member.allow_lb: Refreshing state... [id=projects/zitadel-dev-492704/locations/us-central1/services/zitadel-dev/roles/run.invoker/allUsers]
module.load_balancer.google_compute_region_network_endpoint_group.serverless: Refreshing state... [id=projects/zitadel-dev-492704/regions/us-central1/networkEndpointGroups/zitadel-neg-dev]
module.postgres.google_sql_database_instance.zitadel: Refreshing state... [id=zitadel-dev]
module.load_balancer.google_compute_backend_service.default: Refreshing state... [id=projects/zitadel-dev-492704/global/backendServices/zitadel-backend-dev]
module.load_balancer.google_compute_backend_service.cacheable: Refreshing state... [id=projects/zitadel-dev-492704/global/backendServices/zitadel-cacheable-backend-dev]
module.postgres.google_sql_database.zitadel: Refreshing state... [id=projects/zitadel-dev-492704/instances/zitadel-dev/databases/zitadel]
module.load_balancer.google_compute_url_map.default: Refreshing state... [id=projects/zitadel-dev-492704/global/urlMaps/zitadel-lb-dev]
module.load_balancer.google_compute_target_https_proxy.default: Refreshing state... [id=projects/zitadel-dev-492704/global/targetHttpsProxies/zitadel-https-dev]
module.load_balancer.google_compute_global_forwarding_rule.https_ipv4: Refreshing state... [id=projects/zitadel-dev-492704/global/forwardingRules/zitadel-https-fwd-dev]
module.load_balancer.google_compute_global_forwarding_rule.https_ipv6: Refreshing state... [id=projects/zitadel-dev-492704/global/forwardingRules/zitadel-https6-fwd-dev]

OpenTofu used the selected providers to generate the following execution
plan. Resource actions are indicated with the following symbols:
  + create
  ~ update in-place
  - destroy

OpenTofu will perform the following actions:

  # module.cloud_run.google_cloud_run_v2_job.migrate will be updated in-place
  ~ resource "google_cloud_run_v2_job" "migrate" {
        id                       = "projects/zitadel-dev-492704/locations/us-central1/jobs/zitadel-migrate-dev"
        name                     = "zitadel-migrate-dev"
        # (24 unchanged attributes hidden)

      ~ template {
            # (4 unchanged attributes hidden)

          ~ template {
                # (5 unchanged attributes hidden)

              ~ containers {
                  ~ args       = [
                        "migrate",
                      - "--config",
                      - "/etc/zitadel/config/dev.toml",
                      - "--bootstrap",
                    ]
                    # (3 unchanged attributes hidden)

                  - env {
                      - name  = "ZITADEL_ENCRYPTION__ACTIVE_KEY_ID" -> null
                      - value = "k0" -> null
                    }
                  - env {
                      - name  = "ZITADEL_ENCRYPTION__KEYS__0__ID" -> null
                      - value = "k0" -> null
                    }
                  - env {
                      - name = "ZITADEL_ENCRYPTION__KEYS__0__SECRET" -> null

                      - value_source {
                          - secret_key_ref {
                              - secret  = "zitadel-encryption-key-secret-dev" -> null
                              - version = "2" -> null
                            }
                        }
                    }
                  - env {
                      - name = "ZITADEL_SERVER__BOOTSTRAP_ADMIN_PASSWORD" -> null

                      - value_source {
                          - secret_key_ref {
                              - secret  = "zitadel-bootstrap-admin-password-dev" -> null
                              - version = "1" -> null
                            }
                        }
                    }

                    # (1 unchanged block hidden)
                }

                # (1 unchanged block hidden)
            }
        }
    }

  # module.cloud_run.google_cloud_run_v2_service.zitadel will be updated in-place
  ~ resource "google_cloud_run_v2_service" "zitadel" {
        id                      = "projects/zitadel-dev-492704/locations/us-central1/services/zitadel-dev"
        name                    = "zitadel-dev"
        # (29 unchanged attributes hidden)

      ~ template {
            # (7 unchanged attributes hidden)

          ~ containers {
                name       = "nextgen-1"
                # (5 unchanged attributes hidden)

              - env {
                  - name = "NEXTGEN_DATABASE_POSTGRES" -> null

                  - value_source {
                      - secret_key_ref {
                          - secret  = "zitadel-postgres-secret-dev" -> null
                          - version = "latest" -> null
                        }
                    }
                }
              - env {
                  - name = "NEXTGEN_SERVER_ENCRYPTION_KEY" -> null

                  - value_source {
                      - secret_key_ref {
                          - secret  = "zitadel-encryption-key-secret-dev" -> null
                          - version = "latest" -> null
                        }
                    }
                }

              ~ resources {
                  ~ limits            = {
                      ~ "cpu"    = "1000m" -> "1"
                        # (1 unchanged element hidden)
                    }
                    # (2 unchanged attributes hidden)
                }

                # (2 unchanged blocks hidden)
            }

          ~ vpc_access {
                # (1 unchanged attribute hidden)

              ~ network_interfaces {
                  ~ network    = "zitadel-dev" -> "projects/zitadel-dev-492704/global/networks/zitadel-dev"
                  ~ subnetwork = "zitadel-run-dev" -> "projects/zitadel-dev-492704/regions/us-central1/subnetworks/zitadel-run-dev"
                    tags       = []
                }
            }

            # (1 unchanged block hidden)
        }

        # (2 unchanged blocks hidden)
    }

  # module.iam.google_project_iam_member.run_cert_manager will be destroyed
  # (because google_project_iam_member.run_cert_manager is not in configuration)
  - resource "google_project_iam_member" "run_cert_manager" {
      - etag    = "BwZZ3seqm00=" -> null
      - id      = "zitadel-dev-492704/roles/certificatemanager.editor/serviceAccount:zitadel-run@zitadel-dev-492704.iam.gserviceaccount.com" -> null
      - member  = "serviceAccount:zitadel-run@zitadel-dev-492704.iam.gserviceaccount.com" -> null
      - project = "zitadel-dev-492704" -> null
      - role    = "roles/certificatemanager.editor" -> null
    }

  # module.iam.google_project_iam_member.run_dns will be destroyed
  # (because google_project_iam_member.run_dns is not in configuration)
  - resource "google_project_iam_member" "run_dns" {
      - etag    = "BwZZ3seqm00=" -> null
      - id      = "zitadel-dev-492704/roles/dns.admin/serviceAccount:zitadel-run@zitadel-dev-492704.iam.gserviceaccount.com" -> null
      - member  = "serviceAccount:zitadel-run@zitadel-dev-492704.iam.gserviceaccount.com" -> null
      - project = "zitadel-dev-492704" -> null
      - role    = "roles/dns.admin" -> null
    }

  # module.iam.google_project_iam_member.run_lb_admin will be destroyed
  # (because google_project_iam_member.run_lb_admin is not in configuration)
  - resource "google_project_iam_member" "run_lb_admin" {
      - etag    = "BwZZ3seqm00=" -> null
      - id      = "zitadel-dev-492704/roles/compute.loadBalancerAdmin/serviceAccount:zitadel-run@zitadel-dev-492704.iam.gserviceaccount.com" -> null
      - member  = "serviceAccount:zitadel-run@zitadel-dev-492704.iam.gserviceaccount.com" -> null
      - project = "zitadel-dev-492704" -> null
      - role    = "roles/compute.loadBalancerAdmin" -> null
    }

  # module.secrets.google_secret_manager_secret.runtime["database_postgres"] will be created
  + resource "google_secret_manager_secret" "runtime" {
      + create_time           = (known after apply)
      + deletion_protection   = false
      + effective_annotations = (known after apply)
      + effective_labels      = {
          + "goog-terraform-provisioned" = "true"
        }
      + expire_time           = (known after apply)
      + id                    = (known after apply)
      + name                  = (known after apply)
      + project               = "zitadel-dev-492704"
      + secret_id             = "zitadel-database-postgres-dev"
      + terraform_labels      = {
          + "goog-terraform-provisioned" = "true"
        }

      + replication {
          + auto {
            }
        }
    }

  # module.secrets.google_secret_manager_secret.runtime["master_key"] will be created
  + resource "google_secret_manager_secret" "runtime" {
      + create_time           = (known after apply)
      + deletion_protection   = false
      + effective_annotations = (known after apply)
      + effective_labels      = {
          + "goog-terraform-provisioned" = "true"
        }
      + expire_time           = (known after apply)
      + id                    = (known after apply)
      + name                  = (known after apply)
      + project               = "zitadel-dev-492704"
      + secret_id             = "zitadel-master-key-dev"
      + terraform_labels      = {
          + "goog-terraform-provisioned" = "true"
        }

      + replication {
          + auto {
            }
        }
    }

  # module.secrets.google_secret_manager_secret_iam_member.deploy_version_adder["database_postgres"] will be created
  + resource "google_secret_manager_secret_iam_member" "deploy_version_adder" {
      + etag      = (known after apply)
      + id        = (known after apply)
      + member    = "serviceAccount:github-deploy@zitadel-ops.iam.gserviceaccount.com"
      + project   = "zitadel-dev-492704"
      + role      = "roles/secretmanager.secretVersionAdder"
      + secret_id = "zitadel-database-postgres-dev"
    }

  # module.secrets.google_secret_manager_secret_iam_member.deploy_version_adder["master_key"] will be created
  + resource "google_secret_manager_secret_iam_member" "deploy_version_adder" {
      + etag      = (known after apply)
      + id        = (known after apply)
      + member    = "serviceAccount:github-deploy@zitadel-ops.iam.gserviceaccount.com"
      + project   = "zitadel-dev-492704"
      + role      = "roles/secretmanager.secretVersionAdder"
      + secret_id = "zitadel-master-key-dev"
    }

  # module.secrets.google_secret_manager_secret_iam_member.migrator_accessor["database_postgres"] will be created
  + resource "google_secret_manager_secret_iam_member" "migrator_accessor" {
      + etag      = (known after apply)
      + id        = (known after apply)
      + member    = "serviceAccount:zitadel-migrator@zitadel-dev-492704.iam.gserviceaccount.com"
      + project   = "zitadel-dev-492704"
      + role      = "roles/secretmanager.secretAccessor"
      + secret_id = "zitadel-database-postgres-dev"
    }

  # module.secrets.google_secret_manager_secret_iam_member.migrator_accessor["master_key"] will be created
  + resource "google_secret_manager_secret_iam_member" "migrator_accessor" {
      + etag      = (known after apply)
      + id        = (known after apply)
      + member    = "serviceAccount:zitadel-migrator@zitadel-dev-492704.iam.gserviceaccount.com"
      + project   = "zitadel-dev-492704"
      + role      = "roles/secretmanager.secretAccessor"
      + secret_id = "zitadel-master-key-dev"
    }

  # module.secrets.google_secret_manager_secret_iam_member.run_accessor["database_postgres"] will be created
  + resource "google_secret_manager_secret_iam_member" "run_accessor" {
      + etag      = (known after apply)
      + id        = (known after apply)
      + member    = "serviceAccount:zitadel-run@zitadel-dev-492704.iam.gserviceaccount.com"
      + project   = "zitadel-dev-492704"
      + role      = "roles/secretmanager.secretAccessor"
      + secret_id = "zitadel-database-postgres-dev"
    }

  # module.secrets.google_secret_manager_secret_iam_member.run_accessor["master_key"] will be created
  + resource "google_secret_manager_secret_iam_member" "run_accessor" {
      + etag      = (known after apply)
      + id        = (known after apply)
      + member    = "serviceAccount:zitadel-run@zitadel-dev-492704.iam.gserviceaccount.com"
      + project   = "zitadel-dev-492704"
      + role      = "roles/secretmanager.secretAccessor"
      + secret_id = "zitadel-master-key-dev"
    }

Plan: 8 to add, 2 to change, 3 to destroy.

Changes to Outputs:
  + database_postgres_secret_id = "zitadel-database-postgres-dev"
  + master_key_secret_id        = "zitadel-master-key-dev"

─────────────────────────────────────────────────────────────────────────────

Saved the plan to: tfplan

To perform exactly these actions, run the following command to apply:
    tofu apply "tfplan"

Pushed by @muhlemmer

@github-actions

github-actions Bot commented Sep 3, 2026

Copy link
Copy Markdown
Contributor

⚠️ No Changeset found

Latest commit: be75376

Merging this PR will not cause a version bump for any packages. If these changes should not result in a new version, you're good to go. If these changes should result in a version bump, you need to add a changeset.

This PR includes no changesets

When changesets are added to this PR, you'll see the packages that this PR includes changesets for and the associated semver types

Click here to learn what changesets are, and how to add one.

Click here if you're a maintainer who wants to add a changeset to this PR

Copilot AI left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🟡 Changes recommended

The new infra workflow masks tofu plan failures due to missing pipefail, which can cause CI to report success and post misleading plan output.

Once you've addressed the issues Copilot identified, you can request another Copilot review.

Pull request overview

This PR restores and modernizes the repository’s GCP infrastructure-as-code by adding an OpenTofu-based two-tier setup (management project + per-environment workload project), including a new secrets module and CI automation to plan/apply the dev environment.

Changes:

  • Adds infra/ OpenTofu root module plus reusable modules for networking, Cloud Run, load balancing, DNS, Cloud SQL, certificates, IAM, and secrets.
  • Adds infra/mgmt/ OpenTofu configuration for shared management-project resources (Artifact Registry, CI service accounts, and WIF).
  • Adds a GitHub Actions workflow to run tofu plan on PRs and tofu apply on merges for the dev environment, plus drift-detection plans for mgmt changes.
File summaries
File Description
.github/workflows/infra.yml CI workflow to plan/apply dev infra and plan mgmt tier for drift/guardrails
infra/.gitignore Ignores local OpenTofu/Terraform artifacts and state files
infra/.terraform.lock.hcl Provider lockfile for environment-tier OpenTofu configuration
infra/README.md Documentation for infra architecture, workflow, secrets, and migrations
infra/bootstrap.sh One-time helper script to create the GCS state bucket
infra/environments/dev.tfbackend Backend config for dev state prefix
infra/environments/dev.tfvars Dev environment variables (project/domain/runtime sizing)
infra/environments/prod.tfbackend Backend config for prod state prefix
infra/environments/prod.tfvars Prod environment variable template
infra/main.tf Environment root module wiring all infra submodules
infra/outputs.tf Exposes key infra outputs (DB, LB, secrets, etc.) for consumers/ops
infra/providers.tf Provider and backend configuration for environment tier
infra/variables.tf Input variables for environment tier configuration
infra/mgmt/.terraform.lock.hcl Provider lockfile for mgmt-tier OpenTofu configuration
infra/mgmt/main.tf Mgmt project resources (AR, CI SAs, WIF, cross-project bindings)
infra/mgmt/mgmt.tfbackend Backend config for mgmt state prefix
infra/mgmt/mgmt.tfvars Mgmt variables (mgmt project, repo, env project ids)
infra/mgmt/outputs.tf Outputs needed for CI/repo variables (WIF provider, SA emails, AR URL)
infra/mgmt/providers.tf Provider and backend configuration for mgmt tier
infra/mgmt/variables.tf Input variables for mgmt tier configuration
infra/modules/certificate-map/main.tf Certificate Manager map + baseline DNS authorization and cert entries
infra/modules/certificate-map/outputs.tf Exposes cert map IDs and DNS challenge records
infra/modules/certificate-map/variables.tf Inputs for certificate-map module
infra/modules/cloud-run/main.tf Cloud Run service + migrate job scaffolding with drift-ignores for deploy-owned fields
infra/modules/cloud-run/outputs.tf Exposes Cloud Run service/job names and URI
infra/modules/cloud-run/variables.tf Inputs for cloud-run module
infra/modules/dns/main.tf Managed zone + A/AAAA + wildcard records + cert challenge records
infra/modules/dns/outputs.tf Exposes DNS zone name servers and zone name
infra/modules/dns/variables.tf Inputs for dns module
infra/modules/iam/main.tf Runtime and migrator service accounts plus key project IAM bindings
infra/modules/iam/outputs.tf Exposes runtime/migrator SA emails
infra/modules/iam/variables.tf Inputs for iam module
infra/modules/load-balancer/main.tf External managed LB resources (addresses, NEGs, backend services, URL maps, proxies, forwarding rules)
infra/modules/load-balancer/moved.tf moved blocks to preserve state across renames
infra/modules/load-balancer/outputs.tf Exposes LB IPs and key resource names
infra/modules/load-balancer/variables.tf Inputs for load-balancer module
infra/modules/network/main.tf VPC, subnet, router, and Cloud NAT setup
infra/modules/network/outputs.tf Exposes network/subnet IDs and NAT IP
infra/modules/network/variables.tf Inputs for network module
infra/modules/postgres/main.tf Cloud SQL Postgres instance + DB plus private service access plumbing
infra/modules/postgres/outputs.tf Exposes SQL instance connection info and private IP
infra/modules/postgres/variables.tf Inputs for postgres module
infra/modules/project/main.tf Enables required APIs in the target project
infra/modules/project/outputs.tf Exposes the enabled API list
infra/modules/project/variables.tf Inputs for project module
infra/modules/secrets/main.tf Secret Manager secret containers + IAM bindings (no secret values)
infra/modules/secrets/outputs.tf Exposes Secret Manager secret IDs for runtime/deploy integration
infra/modules/secrets/variables.tf Inputs for secrets module
Review details

Files not reviewed (2)

  • infra/.terraform.lock.hcl: Generated file
  • infra/mgmt/.terraform.lock.hcl: Generated file

Suppressed comments (1)

infra/mgmt/main.tf:85

  • This comment line also includes garbled replacement characters ("���"). Please replace them so the file uses consistent UTF-8 characters throughout.
# ─── Service Account: github-deploy (app deploy CI) ���────────────────────────
  • Files reviewed: 46/48 changed files
  • Comments generated: 4
  • Review effort level: Lite

💡 Add a code-review agent skill or configure MCP servers for context-aware, tailored reviews. Learn more in the docs.

Comment thread .github/workflows/infra.yml
Comment thread .github/workflows/infra.yml
Comment thread infra/README.md
Comment thread infra/mgmt/main.tf Outdated
Comment thread infra/mgmt/mgmt.tfvars
Comment thread infra/modules/cloud-run/main.tf
muhlemmer added a commit that referenced this pull request Sep 3, 2026
Review question on #1132. `containers.image` is required to create a
google_cloud_run_v2_service, but the deploy workflow owns which release runs,
so image sits under lifecycle.ignore_changes. That makes the value a
create-time bootstrap only — read once when the service does not exist, never
again. The plan CI runs on this PR shows it: the live service runs the
released image, the config still says `hello`, and no image change is
proposed.

Also notes on the job that its placeholder is still what actually runs, since
there is no `migrate` subcommand to point it at (#1138).

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>

@fforootd fforootd left a comment

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The secrets module is right: containers without values, accessor for the runtime, version-adder for deploy, and the README matches the runtime code. The blockers are in the surrounding automation. Two of them let the auto-apply job destroy dev data or the dev master key, and the mgmt drift job has never actually run. Each fix is a few lines; details inline.

Context for the mgmt tier: #1133 will deploy straight from GHCR, which Cloud Run has supported for public images since the July 14 GA. That leaves the Artifact Registry repo and its three bindings without a consumer. Not for this PR, since it imports live state and the mgmt plan should stay empty; see the note on the repo resource.

Non-blocking, fine as a follow-up issue: no ssl_policy on the HTTPS proxy so TLS 1.0 is accepted, the secrets module doesn't pass deletion_protection, load_balancer and dns miss depends_on = [module.project], the cacheable path rule targets routes the server doesn't serve, google-beta is required but unused, and the README's step 1 path and variable list don't match the workflows: GCP_DEV_PROJECT_ID is missing, while GCP_MGMT_PROJECT_ID and AR_REGION only served the mirror step #1133 is dropping.


Generated by Claude Code

Comment thread infra/modules/postgres/main.tf
Comment thread infra/modules/cloud-run/main.tf
Comment thread .github/workflows/infra.yml Outdated
Comment thread .github/workflows/infra.yml
Comment thread infra/mgmt/main.tf
Comment thread infra/mgmt/main.tf
Comment thread infra/modules/iam/main.tf Outdated
Comment thread infra/mgmt/main.tf
muhlemmer added a commit that referenced this pull request Sep 5, 2026
Review question on #1132. `containers.image` is required to create a
google_cloud_run_v2_service, but the deploy workflow owns which release runs,
so image sits under lifecycle.ignore_changes. That makes the value a
create-time bootstrap only — read once when the service does not exist, never
again. The plan CI runs on this PR shows it: the live service runs the
released image, the config still says `hello`, and no image change is
proposed.

Also notes on the job that its placeholder is still what actually runs, since
there is no `migrate` subcommand to point it at (#1138).

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
@github-actions

github-actions Bot commented Sep 5, 2026

Copy link
Copy Markdown
Contributor

OpenTofu Plan — mgmt

data.google_project.env["dev"]: Reading...
google_project_service.apis["iamcredentials.googleapis.com"]: Refreshing state... [id=zitadel-ops/iamcredentials.googleapis.com]
google_project_service.apis["sts.googleapis.com"]: Refreshing state... [id=zitadel-ops/sts.googleapis.com]
google_project_service.apis["cloudresourcemanager.googleapis.com"]: Refreshing state... [id=zitadel-ops/cloudresourcemanager.googleapis.com]
google_project_service.apis["artifactregistry.googleapis.com"]: Refreshing state... [id=zitadel-ops/artifactregistry.googleapis.com]
google_project_service.apis["iam.googleapis.com"]: Refreshing state... [id=zitadel-ops/iam.googleapis.com]
data.google_project.env["dev"]: Read complete after 0s [id=projects/zitadel-dev-492704]
google_iam_workload_identity_pool.github: Refreshing state... [id=projects/zitadel-ops/locations/global/workloadIdentityPools/github-actions]
google_service_account.github_deploy: Refreshing state... [id=projects/zitadel-ops/serviceAccounts/github-deploy@zitadel-ops.iam.gserviceaccount.com]
google_service_account.infra_apply: Refreshing state... [id=projects/zitadel-ops/serviceAccounts/infra-apply@zitadel-ops.iam.gserviceaccount.com]
google_artifact_registry_repository.zitadel: Refreshing state... [id=projects/zitadel-ops/locations/us-central1/repositories/zitadel]
google_project_iam_member.infra_apply_editor["dev"]: Refreshing state... [id=zitadel-dev-492704/roles/editor/serviceAccount:infra-apply@zitadel-ops.iam.gserviceaccount.com]
google_project_iam_member.infra_apply_iam["dev"]: Refreshing state... [id=zitadel-dev-492704/roles/iam.securityAdmin/serviceAccount:infra-apply@zitadel-ops.iam.gserviceaccount.com]
google_project_iam_member.infra_apply_storage: Refreshing state... [id=zitadel-ops/roles/storage.admin/serviceAccount:infra-apply@zitadel-ops.iam.gserviceaccount.com]
google_project_iam_member.infra_apply_ar_reader: Refreshing state... [id=zitadel-ops/roles/artifactregistry.reader/serviceAccount:infra-apply@zitadel-ops.iam.gserviceaccount.com]
google_project_iam_member.infra_apply_spanner["dev"]: Refreshing state... [id=zitadel-dev-492704/roles/spanner.admin/serviceAccount:infra-apply@zitadel-ops.iam.gserviceaccount.com]
google_project_iam_member.deploy_ar_writer: Refreshing state... [id=zitadel-ops/roles/artifactregistry.writer/serviceAccount:github-deploy@zitadel-ops.iam.gserviceaccount.com]
google_project_iam_member.deploy_run_developer["dev"]: Refreshing state... [id=zitadel-dev-492704/roles/run.developer/serviceAccount:github-deploy@zitadel-ops.iam.gserviceaccount.com]
google_service_account_iam_member.wif_github_deploy: Refreshing state... [id=projects/zitadel-ops/serviceAccounts/github-deploy@zitadel-ops.iam.gserviceaccount.com/roles/iam.workloadIdentityUser/principalSet://iam.googleapis.com/projects/831790715403/locations/global/workloadIdentityPools/github-actions/attribute.repository/zitadel/nextgen]
google_iam_workload_identity_pool_provider.github: Refreshing state... [id=projects/zitadel-ops/locations/global/workloadIdentityPools/github-actions/providers/github-oidc]
google_service_account_iam_member.wif_infra_apply: Refreshing state... [id=projects/zitadel-ops/serviceAccounts/infra-apply@zitadel-ops.iam.gserviceaccount.com/roles/iam.workloadIdentityUser/principalSet://iam.googleapis.com/projects/831790715403/locations/global/workloadIdentityPools/github-actions/attribute.repository/zitadel/nextgen]
google_artifact_registry_repository_iam_member.env_run_agent_reader["dev"]: Refreshing state... [id=projects/zitadel-ops/locations/us-central1/repositories/zitadel/roles/artifactregistry.reader/serviceAccount:service-1050991084001@serverless-robot-prod.iam.gserviceaccount.com]

No changes. Your infrastructure matches the configuration.

OpenTofu has compared your real infrastructure against your configuration and
found no differences, so no changes are needed.

Mgmt applies are manual — pull this branch and run cd infra/mgmt && tofu apply -var-file=mgmt.tfvars after review.

muhlemmer added a commit that referenced this pull request Sep 8, 2026
)

## Summary

The Go binary now has a `migrate` subcommand so schema changes can run
as their own step and exit, which is what the Cloud Run migrate job in
#734/#1138 needs.

- `nextgen migrate` loads the same config as `server`, connects, applies
pending goose migrations, and exits 0 (including no-op reruns).
- `nextgen` and `nextgen server` still start the HTTP server. They no
longer migrate unless `--migrate` is passed.
- Local and image entrypoints keep applying schema: `zitadel start`
spawns `--migrate`, the Dockerfile `CMD` is `--migrate`, and `moon run
workspace:server` injects the flag.

Closes #1138.

## Validation

- `go test ./cmd/server/ -run
'TestCommandHelpListsMigrate|TestMigrateCommandAppliesSchemaIdempotently|TestStartDatabaseSkipsMigrationsUnlessRequested'`
(pass after review follow-up; migrate logs `stream=runtime`)
- `go test ./cmd/server/` (full package)
- `moon run server:format`
- `corepack pnpm --filter @zitadel/cli exec vitest run
tests/unit/lib/local-server/binary.test.ts` (status metadata includes
injected `--migrate`)
- Earlier on this PR: `moon run server:test`, `moon run cli:test`, `node
scripts/check-pr-title.mjs --title "feat(migration): add migrate
subcommand and opt-in --migrate flag"`, `corepack pnpm exec changeset
status --since origin/main`
- Live `go run . migrate` against a temp SQLite dir: first run applies,
second run exits 0, `goose_db_version` and application tables present
- `go run . --help` lists `migrate`; `go run . migrate --help` exposes
`--config`

## Release notes / changeset

Changeset: `.changeset/nextgen-migrate-command.md` — operators can run
`nextgen migrate` to apply schema and exit; `server` migrates only with
`--migrate`; `zitadel start` and the published image still migrate.
Lists `@zitadel/server` (minor; fixed group bumps together).

No extra changeset for review follow-ups: logging, Close handling,
SilenceErrors, and recording `--migrate` in `zitadel status` metadata
are polish on the same feature. The run-server `completion` skip is
contributor-script only.

## Notes

- Review follow-up: `migrate` now calls `setUpLogging` (same OTel
metrics as `run`, StreamRuntime, metrics shutdown, no HTTP). Close
errors after a successful migrate are `zlog` debug-only and do not
change the exit code. `SilenceErrors: true` on root, `server`, and
`migrate`. UI builds skipped for `completion` in the run-server
wrappers. Help test asserts both the `migrate` subcommand and
`--migrate` flag; skip-migrations test uses `t.Context()`. Local binary
runtime metadata now stores the same argv that was spawned, including
`--migrate`.
- Cloud Run job wiring (`gcloud run jobs update` / `execute --wait`,
migrator `secretAccessor`) is still follow-up after #1132/#1133. This PR
only adds the binary command.
- Spanner migrate-in-release-image remains #1137.
- No oclif `zitadel migrate`; `@zitadel/server migrate` works via argv
passthrough.
- `loadConfig` is reused unchanged, so `migrate` still creates
`data_dir` and may mint a local master key.

---------

Co-authored-by: Cursor Agent <cursoragent@cursor.com>
Co-authored-by: Silvan <adlerhurst@users.noreply.github.com>
Co-authored-by: Tim Möhlmann <tim+github@zitadel.com>
muhlemmer added a commit that referenced this pull request Sep 9, 2026
## Summary

Keys discovered in the master key directory are identified by file name,
and the scan skipped only directories. A Kubernetes-style projected
secret volume — the shape both Cloud Run and GKE mount secrets with —
lays the directory out like this:

```
..2026_09_05_12_00_00.123456/master-key.pem   the real file
..data -> ..2026_09_05_12_00_00.123456        symlink to that directory
master-key.pem -> ..data/master-key.pem       symlink to the key
```

`..data` is a **symlink**, so `DirEntry.IsDir()` reports `false` for it
and it was adopted as a key named `..data`. Startup then died in
`buildMasterKey`:

```
Error: failed to create Crypter: server: failed to read encryption key file
".../master-keys/..data": read .../master-keys/..data: is a directory
```

Found while wiring the GCP deployment (#734, #1132, #1133), but it is
not GCP-specific: any self-hoster mounting keys through a Kubernetes
secret hits it today.

Two changes in `ensureServerMasterKey`:

- **Skip dot-prefixed entries.** A key's file name doubles as its key
ID, so no key an operator authored starts with a dot — while plenty of
machinery does: projected volumes, editor swap files, `.DS_Store`.
- **`os.Stat` the path instead of reading `DirEntry.Info()`.**
`DirEntry` reports on the link itself, so a symlinked directory looks
like a regular file. Following the link also means the modification time
that picks the newest key is the key's, not the link's.

## Deliberate choices

**Entries that cannot be stat'ed still fail startup.** Silently skipping
something in this directory is the more dangerous behaviour: it can
bring the server up under a *different* key than intended, orphaning
every KEK the real one wrapped. Only what is positively identifiable as
not-a-key — dot-prefixed, or a directory — is skipped.

**Not filtering by extension.** `MasterKeyConfig.File` accepts a PEM or
a JWK, so the file name carries no reliable extension contract.
Dot-prefix is the right granularity.

**Scope is one call site.** The only other `os.ReadDir`/`WalkDir` loops
in non-test Go are `api/cmd/gen_event_schemas` and
`api/cmd/gen_openapi_errors`, both build-time generators walking repo
source. This is the only runtime scan of an operator- or
platform-supplied directory.

## Validation

- `go test ./cmd/server/ -count=1` — full package passes.
- Three new tests, **each confirmed to fail before the change**:

| test | failure on old code |
| --- | --- |
| projected volume layout | `map[..data:… master-key.pem:…] should have
1 item(s), but has 2` |
| symlinked directory, no dot prefix | `map[linked-dir:…
master-key.pem:…] should have 1 item(s), but has 2` |
| stray `.DS_Store` | `map[".DS_Store":…] does not contain
"master-key.pem"` |

- `gofmt -l` and `go vet ./cmd/server/` clean.
- Reproduced end-to-end before fixing: running the server against the
projected layout produced exactly the `is a directory` startup failure
above.

The `.DS_Store` case is the nastiest of the three. Because a non-empty
directory also suppresses generation, the old code did not merely ignore
the stray file — it made `.DS_Store` the deployment's one master key,
marked for encryption.

## Release notes / changeset

Changeset: `.changeset/master-key-dir-hidden-entries.md` —
`@zitadel/server` patch. This corrects shipped behavior a user could
have hit, which is why the title is `fix`.

## Notes

- Independent of #1132 and #1133; it needs no infrastructure and can
land on its own.
- It does de-risk #1133, which mounts the master key as a file into this
directory. Whether Cloud Run actually uses the `..data` projection is
still unconfirmed — nothing is deployed with a mount yet — and this
change makes both layouts safe either way, so that stops being a merge
gate.
- Related: #655 / #1151 (a flag to refuse startup rather than generate a
key). Complementary — that one governs whether generation happens at
all; this one governs what counts as an existing key.

🤖 Generated with [Claude Code](https://claude.com/claude-code)

---------

Co-authored-by: Claude Opus 5 <noreply@anthropic.com>
@muhlemmer muhlemmer moved this from Inbox to In review in Engineering Kanban Sep 9, 2026
@muhlemmer muhlemmer self-assigned this Sep 9, 2026
muhlemmer and others added 8 commits September 9, 2026 15:25
Recovers the infrastructure-as-code from the closed PR #188
(feat/tofu-infra), rebased onto current main. The resources it declares are
already applied in zitadel-dev-492704 — the branch was abandoned before merge,
so the code describing live infrastructure was never on main.

Changes on top of the abandoned branch:

- Adds a `secrets` module. It owns the Secret Manager containers and the
  access bindings but never the values, so no credential material reaches
  OpenTofu state while who may read each secret stays reviewable in code.
  Values come from GitHub environment secrets, pushed as new versions by the
  deploy workflow.

- Grants the runtime SA `secretAccessor` and the github-deploy SA
  `secretVersionAdder` on those two secrets. Version adder, not admin: CI can
  write a new version but cannot read one back, delete the secret, or change
  who has access.

- Rewrites the README's secrets section, which documented
  NEXTGEN_SERVER_ENCRYPTION_KEY — a variable that no longer exists. It was
  replaced by the rotatable master-key config, which is a map and therefore
  cannot be set from an environment variable at all.

- Documents that the zitadel-migrate-<env> job is deliberately unwired: the
  released binary has no `migrate` subcommand, so migrations run at server
  startup.

Validated with `tofu validate` and `tofu fmt -check -recursive`.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
All four inherited from PR #188 and caught by Copilot review.

- `tofu plan ... | tee` ran without pipefail in the plan and apply jobs, so
  the pipeline's exit status came from tee: a failed plan passed the step and
  posted a plan comment that looked fine. Confirmed with `bash -e -c 'false |
  tee'`, which exits 0. The mgmt-plan job already set pipefail, which is what
  makes this an oversight rather than a deliberate choice.

- infra/mgmt/main.tf carried six U+FFFD replacement characters in two section
  rules, from a bad copy-paste in the original branch. Restored the box
  drawing characters and re-evened both rules to the file's 79-column
  convention.

- The resource-ownership table credited `infra/mgmt/` with the GCS state
  bucket, but no bucket resource exists there — bootstrap.sh creates it with
  gcloud, deliberately, since it has to exist before the backend it stores.
  Split it into its own row so nobody expects OpenTofu to reconcile drift on
  it.

Revalidated with `tofu validate` (both tiers) and `tofu fmt -check
-recursive`; re-parsed the workflow to confirm every `tofu plan` step now
sets pipefail.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Review question on #1132. `containers.image` is required to create a
google_cloud_run_v2_service, but the deploy workflow owns which release runs,
so image sits under lifecycle.ignore_changes. That makes the value a
create-time bootstrap only — read once when the service does not exist, never
again. The plan CI runs on this PR shows it: the live service runs the
released image, the config still says `hello`, and no image change is
proposed.

Also notes on the job that its placeholder is still what actually runs, since
there is no `migrate` subcommand to point it at (#1138).

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Four blockers from review, two of which let the auto-apply job destroy data.

Cloud SQL could be replaced. disk_size was pinned while disk_autoresize is
on, so once the disk grows past the configured floor the provider reads
config-below-state as a shrink (ForceNewIfChange on settings.0.disk_size) and
plans a destroy/recreate — with deletion_protection = false on dev, the apply
job would carry it out. Confirmed still at 20 GB in the project today, so
this is armed rather than fired. disk_size is now ignored after create.

The master key mount could be stripped. `--set-secrets=<path>=secret` creates
template.volumes and volume_mounts, and ignore_changes covered neither, so
the first apply after a deploy would drop the mount and the server would mint
a throwaway key under the same ID master-key.pem — every KEK wrapped by the
real key then fails to decrypt. Rather than widen ignore_changes, the module
now declares the volume itself: a mount that is OpenTofu's own state cannot
be stripped by OpenTofu.

That choice has a consequence worth stating, because it is the trap it
replaces: `gcloud run services update --set-secrets` is declarative over
volumes as well as env, so a deploy passing it for the DSN alone would delete
the module's mount. The module therefore also owns the DSN env secret, env
comes out of ignore_changes, and the deploy workflow shrinks to `--image`
only. OpenTofu owns configuration; CI owns the release.

The mgmt drift gate had never run. The default depth-1 checkout has neither
SHA, so `git diff` died with "fatal: bad object", and inside an `if`
condition — where set -e does not fire — that failure was read as "nothing
changed". Job 100744516053 shows it: on the PR that adds all of infra/mgmt/,
every step after detection was skipped and the job still reported success.
Now fetch-depth: 0, and the diff runs on its own line so git's exit code
fails the step instead of being swallowed.

The mgmt plan discarded its own exit code. -detailed-exitcode returns 2 for
"there are changes", which under the runner's bash -e killed the step before
PLAN_EXIT was written, skipping the comment and summary steps in exactly the
case they exist for. Verified against a stubbed tofu: 0 and 2 now record and
pass, 1 records and fails.

Also, on the review's non-blocking points: dropped certificatemanager.editor,
compute.loadBalancerAdmin and dns.admin from the runtime SA. They were
granted for an "infra.rs" runtime that does not exist in this repository —
no Rust, and no such clients in go.mod — so on the identity terminating
internet traffic they were blast radius and nothing else. The README's setup
steps and variable list now match what the workflows actually read, including
the environment-scoped GCP_PROJECT_ID and GCP_REGION.

Validated with tofu validate and tofu fmt -check -recursive, by re-parsing
the workflow, and by exercising the new exit-code handling against a stub.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Moving the secret references into OpenTofu introduced a first-apply failure:
the same apply creates the secret containers and points the Cloud Run service
at them, but a revision cannot start against a secret that has no versions —
and the containers are created empty, on purpose, because OpenTofu must never
hold a secret value. Merging as-is would have failed the apply it triggers.

The volume, its mount and the DSN env reference are now gated on
runtime_secrets_ready, default false, so a new environment applies cleanly and
the sequence becomes explicit: apply, seed the secrets through the deploy
workflow, then flip the flag. The flip is a one-line change whose plan shows
the mount being added, which makes the riskiest step the one that gets read
before it runs. Documented under "First deploy" in the README.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
A comment added in 8d9ef14 said the placeholder image is what the job still
runs. The project shows otherwise: image is under ignore_changes, and the job
carries an old zitadel image set out of band. The operative point is
unchanged — nothing invokes the job, because there is no `migrate`
subcommand (#1138) — but the detail was wrong.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
With the change detection fixed, the mgmt plan ran for the first time and
immediately reported a destroy: google_project_iam_member.infra_apply_spanner
["dev"] is in state and applied, but absent from this configuration, so the
plan proposed removing a live IAM binding.

It was added to the mgmt tier after the original infra branch was abandoned,
which is why the branch does not carry it. Declaring it here keeps the plan
honest about what exists. Whether a CI identity should hold spanner.admin at
all is a separate question, tracked in #1163.

Note the plan that surfaced this did not finish: it aborted on a 403 for
serviceusage.services.list on zitadel-ops, so only part of the state was
refreshed. There may be further drift behind that error, and it cannot be
seen until infra-apply can read the mgmt project.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
#1152 landed a `migrate` subcommand and, in the same change, made `server`
stop migrating unless `--migrate` is passed. That second half breaks this
configuration as written: the live service carries args ["server"] from an
old console deploy, which overrides the image's CMD of ["--migrate"], so a
current release would have served against an unmigrated schema.

The fix is the design the job was always for, and #1152 says as much — the
subcommand exists because of #734/#1138.

- The job runs `args = ["migrate"]`, and carries the DSN and the same VPC
  access the service has. `gcloud run jobs execute --wait` in the deploy
  workflow makes a failed migration a failed deploy rather than a broken
  revision.
- The service declares `args = ["server"]` explicitly and `args` comes out of
  ignore_changes. The contract — the job migrates, the service serves — is now
  stated in code rather than inherited from whatever argv a container was last
  left with.
- Both carry NEXTGEN_SERVER_GENERATE_MASTER_KEY=false, gated with the mount.
  Refusing to generate is only safe once a key is actually mounted; setting it
  outside that gate would leave the service unable to start at all while
  runtime_secrets_ready is still false.

The job also mounts the master key, which it does not use — `migrate` runs
goose and exits without building a crypter. It is there because `migrate`
loads the same configuration as `server`, and with generation disabled
(#1151, landing shortly) a start that finds no key fails inside loadConfig,
which `migrate` calls too. So the mount keeps the job startable rather than
serving the migration, and should come off if that stops being true; the
grant and the volume are commented as a pair and a follow-up issue tracks it.

Validated with tofu validate and tofu fmt -check -recursive.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
muhlemmer added a commit that referenced this pull request Sep 9, 2026
…Tofu

Addresses the review on this PR.

The mirror step is gone. It existed because "Cloud Run pulls only from
Artifact Registry or GCR" — which is no longer true: Cloud Run imports public
GHCR images directly. That removes the mirror, the registry login and the
mgmt project variable, and with them the deploy identity's need for
artifactregistry.writer. The existence check stays, and the workflow now
resolves the digest and deploys that, so the one-hour cache Cloud Run keeps
for public registries cannot come into it and the revision records exactly
what shipped. Verified against 1.0.0-alpha.20: the tag resolves to
sha256:ac57b5f1…, and a bogus tag fails the step.

The deploy sets only --image. #1132 now declares the master-key volume and
the DSN reference, and `--set-secrets` is declarative over volumes as well as
env, so passing it here for the DSN alone would have deleted the mount that
exists to stop the server minting a throwaway key. Dropped --service-account
and --port for the same reason — OpenTofu owns them and `services update`
keeps what it is not asked to change — and NEXTGEN_SERVER_DATA_DIR, which the
Dockerfile already sets.

Dropped the rotation claim from sync_secrets. A new version under the same
file name keeps the same key ID, so old kids resolve to the new key and
MigrateToLatestMasterKey has nothing to do; rotation needs a new ID alongside
the old one (docs/operations/encryption-keys.md). The input now says what it
actually does: seed the secrets once, before the environment's
runtime_secrets_ready flag is flipped.

The required-variables check now covers GCP_WIF_PROVIDER and
GCP_WIF_SERVICE_ACCOUNT, and the project and region come from
environment-scoped GCP_PROJECT_ID and GCP_REGION. The job already runs under
`environment:`, so this makes the environment input mean something instead of
borrowing an Artifact Registry variable as the Cloud Run region.

Also corrected a comment added in 8d9ef14: the migrate job is not sitting on
the hello-job placeholder, it points at an old zitadel/zitadel image. Either
way it is not invoked, because the binary has no migrate subcommand (#1138).

Validated by parsing the workflow, resolving the digest against the real
published image, and exercising the required-variables check with values
present and missing.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
muhlemmer added a commit that referenced this pull request Sep 9, 2026
#1152 made `server` stop migrating unless --migrate is passed, and added the
`migrate` subcommand the Cloud Run job was always meant to run. Without this
step a deploy would roll a revision onto a schema that was never applied —
the service in #1132 deliberately runs `args = ["server"]`, which overrides
the image's CMD of ["--migrate"].

The job is pointed at the same digest as the service and executed with
--wait, so a failed migration fails the deploy. Without --wait the service
would roll anyway and the first broken query would be the notification.

Also drops the stale comment claiming migrations run at server startup, and
reports the job in the run summary so a deploy says what applied the schema.

Validated by parsing the workflow.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
The follow-up issue now exists; reference it directly instead of pointing
readers at another file.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
@muhlemmer

Copy link
Copy Markdown
Contributor Author

Waiting for #1151 to land, so we can cleanup the masterkey regeneration warnings. Other comments are resolved.

@muhlemmer

Copy link
Copy Markdown
Contributor Author

Note for review: this apply takes dev down until the first deploy

The plan removes two env vars from the running service and adds nothing back:

- env { NEXTGEN_DATABASE_POSTGRES     → zitadel-postgres-secret-dev }
- env { NEXTGEN_SERVER_ENCRYPTION_KEY → zitadel-encryption-key-secret-dev }

That is deliberate, and the downtime has been accepted. Recording why here so it does not read as an oversight in the plan output.

Why it happens. Moving the master-key mount into OpenTofu (the fix for the --set-secrets blocker) means OpenTofu has to own env too, since gcloud run services update --set-secrets is declarative over volumes and env — a deploy passing it for the DSN alone would delete the mount. So env came out of ignore_changes. While runtime_secrets_ready = false nothing is declared, so the apply strips what is there.

Why it is not worked around. ignore_changes has to be a static list, so env cannot be ignored only while the flag is false. And the secrets cannot be seeded before the containers exist, which is the same apply. The alternative — landing env ownership together with the flag flip — was considered and rejected as more moving parts for a dev environment mid-cutover.

What is actually lost. The serving revision is a June build (0.0.1-snapshot-8c78c632) reading NEXTGEN_SERVER_ENCRYPTION_KEY, a configuration key the server no longer has. Data loss across this cutover has been accepted, so nothing is being preserved that anyone wants.

The window closes at step 4:

  1. Merge this PR — secret containers and bindings created, service rolls without a database
  2. Set the dev environment's variables and secrets
  3. Merge ci(deploy): deploy released images to Cloud Run #1133 and run Deploy / Cloud Run with sync_secrets: true
  4. Flip runtime_secrets_ready = true — the mount and the DSN land, and that plan is worth reading before it applies

Steps 1 and 4 are the two applies; everything between them is dev being down on purpose.

This branch was successfully deployed

3 active deployments
Preview – nextgen-docs be753763 Deployed Sep 9, 2026 by vercel[bot]
Preview – nextgen be753763 Deployed Sep 9, 2026 by vercel[bot]
Preview – nextgen-mock-zitadel be753763 Deployed Sep 9, 2026 by vercel[bot]
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

Status: In review

Development

Successfully merging this pull request may close these issues.

3 participants