# main.tf — generated by `autumn release init --target gcp-cloud-run`
#
# Provisions {{project_name}} on Google Cloud Run: an Artifact Registry
# repository (replaces the deprecated Container Registry), a VPC with a
# Serverless VPC Access connector, Cloud SQL for PostgreSQL on a private IP
# (no public exposure — reachable only through the connector), Secret
# Manager entries for the database URL and signing secret, a dedicated
# runtime service account scoped to exactly those secrets plus
# `roles/cloudsql.client`, the Cloud Run service itself, and a one-shot
# Cloud Run Job that runs `autumn migrate`. An optional Memorystore Redis
# instance is gated behind `enable_redis_cache` (off by default) — see the
# callout on it below.
#
# Secrets (`signing_secret`, `database_admin_password`) have no default in
# variables.tf. Supply them via `TF_VAR_*` environment variables or your
# CI's secret store — never commit real values here or in terraform.tfvars.
# The database connection string is NOT a variable: it's derived below from
# the Cloud SQL instance this same apply creates, so one `terraform apply`
# is enough — no second, targeted apply to fill in a value that depends on
# a resource the first apply was about to create.
#
# The Cloud Run service and migration job both start from Google's public
# Cloud Run "hello" quickstart image (`var.bootstrap_image`): Cloud Run must
# have SOME image to create the service's/job's first revision, and a
# brand-new Artifact Registry repository has none yet. Unlike App Runner's
# nginx placeholder (which needs its listening port hardcoded to match), the
# hello image already honors the `PORT` env var Cloud Run injects to match
# `containers.ports.container_port` below, so no bootstrap-specific port
# workaround is needed. CI (gcp-deploy.yml) swaps in the real image on every
# deploy via `gcloud run services update` / `gcloud run jobs update`, and
# Terraform is told to ignore that drift (see the `lifecycle` blocks below)
# so a later `terraform apply` doesn't revert a live deploy back to the
# placeholder.
terraform {
required_version = ">= 1.5"
required_providers {
google = {
source = "hashicorp/google"
version = "~> 6.0"
}
random = {
source = "hashicorp/random"
version = "~> 3.6"
}
}
}
provider "google" {
project = var.project_id
region = var.region
}
# Cloud Run's default URL includes the project NUMBER (not the project ID)
# since Google's 2022 URL format change — see local.service_url_host below.
# Reading it via a data source (rather than requiring the operator to look
# it up and pass it as a variable) means the trusted-hosts env var is
# derivable at plan time, with no chicken-and-egg problem the way App
# Runner's own assigned subdomain has (App Runner has nothing to derive its
# hostname from until the service is actually created).
#
# This read hits the Cloud Resource Manager API, which a fresh project may
# not have enabled yet — depends_on defers it until after
# google_project_service.apis has enabled cloudresourcemanager.googleapis.com,
# rather than letting Terraform evaluate it during the initial refresh/plan
# (before any resource in this configuration has run) and fail with
# SERVICE_DISABLED.
data "google_project" "this" {
project_id = var.project_id
depends_on = [google_project_service.apis]
}
# Secret Manager secret IDs and the Cloud SQL instance name are soft-deleted
# / reserved for a retention window after deletion — a bare re-apply under
# the same app_name inside that window would otherwise collide with the
# just-deleted resource. This suffix keeps repeat applies stable while
# avoiding that collision.
resource "random_id" "suffix" {
byte_length = 4
}
locals {
# Every GCP resource name this scaffold touches (Cloud Run service/job,
# Artifact Registry repository, VPC/connector, Cloud SQL instance, the
# service account) requires lowercase RFC 1035 labels — lowercase
# alphanumerics and hyphens, must start with a letter, must not end in a
# hyphen — well below what a Cargo package name allows (which may contain
# underscores or uppercase letters). Sanitize once here instead of
# re-deriving it per resource: map anything else to a hyphen, collapse
# hyphen runs, trim a leading/trailing hyphen, and fall back to a fixed
# "app" prefix when that leaves nothing or leaves a value not starting
# with a letter (a leading digit survives sanitization untouched, but
# every one of these resource types requires a letter first).
app_name_hyphenated_raw = trim(
replace(lower(replace(var.app_name, "/[^a-zA-Z0-9-]/", "-")), "/-+/", "-"),
"-"
)
app_name_hyphenated = (
local.app_name_hyphenated_raw == "" ? "app" : (
can(regex("^[a-z]", local.app_name_hyphenated_raw)) ? local.app_name_hyphenated_raw : "app-${local.app_name_hyphenated_raw}"
)
)
# Capped at 40 characters — comfortable headroom under Cloud Run
# service/job (63-char RFC 1035 label) and Artifact Registry (63-char)
# limits, leaving room for the longest suffix appended below ("-migrate",
# 8 characters) plus Cloud SQL's own "-pg-" + 8-hex-char random_id suffix.
app_name_safe = trim(substr(local.app_name_hyphenated, 0, 40), "-")
# Service account IDs must be 6-30 characters, lowercase alphanumerics and
# hyphens, and start with a lowercase letter. app_name_safe is capped at
# 40 above, so trimming to 26 here plus the fixed "-sa" (3 chars) always
# fits under 30; a short app name (e.g. the "a" in a single-letter Cargo
# package) could otherwise produce a base under 6 characters, so pad it
# out when needed.
service_account_id_base = "${substr(local.app_name_hyphenated, 0, 26)}-sa"
service_account_id = (
length(local.service_account_id_base) < 6
? "${local.service_account_id_base}-000"
: local.service_account_id_base
)
# Serverless VPC Access connector names must be under 21 characters where
# each hyphen counts as TWO characters toward that limit (an undocumented
# quirk of the underlying API, not the usual RFC 1035 label rule every
# other resource here follows) — e.g. "abcdefghij-connector" (a 10-letter
# base) is already at the 21-character weighted boundary and gets
# rejected. Stripping hyphens from the base entirely sidesteps the
# weighting question: with no hyphens left, weighted length equals raw
# character count, so an N-character base plus the fixed "-connector"
# suffix (weighted 2 + 9 = 11) just needs N <= 9 to stay under 21.
connector_name_safe = substr(replace(local.app_name_hyphenated, "-", ""), 0, 9)
# Memorystore instance IDs are capped at 40 characters — app_name_safe
# alone is already capped at 40, so appending the fixed "-redis" suffix
# (6 characters) can overflow it for any sanitized name longer than 34
# characters. Its own shorter budget here leaves exactly that headroom.
redis_name_safe = trim(substr(local.app_name_hyphenated, 0, 34), "-")
# Cloud Run's default URL is "https://<service>-<project number>.<region>.run.app"
# (not the project ID) — a format Google switched to in 2022 specifically
# so a deleted-and-recreated service under someone else's project can't be
# squatted at the old URL. Unlike App Runner's assigned-at-creation
# subdomain, this is fully derivable at plan time from the project number
# (data.google_project.this.number) plus the sanitized service name, so
# AUTUMN_SECURITY__TRUSTED_HOSTS__HOSTS never needs a second apply or a
# post-create CLI call the way App Runner's does.
service_url_host = "${local.app_name_safe}-${data.google_project.this.number}.${var.region}.run.app"
# A fresh Cloud SQL Postgres instance already reserves "postgres" and
# "cloudsqladmin" (Cloud SQL's own management database), plus
# "template0"/"template1" — every Postgres cluster, on any host, is
# initialized with those two as the templates new databases are cloned
# from. A Cargo package literally named one of those (or e.g.
# "cloudsql-admin", which sanitizes to the same underscored name) would
# otherwise collide with them and fail `terraform apply`.
postgres_database_name_raw = substr(replace(local.app_name_hyphenated, "-", "_"), 0, 63)
postgres_database_name = contains(
["postgres", "cloudsqladmin", "template0", "template1"],
local.postgres_database_name_raw
) ? "${substr(local.postgres_database_name_raw, 0, 58)}_prod" : local.postgres_database_name_raw
postgres_admin_username = "autumnadmin"
# APIs this scaffold's resources depend on. Enabling them here (rather
# than assuming the project already has them on) means a fresh GCP
# project works on the first `terraform apply` with no manual `gcloud
# services enable` step.
# redis.googleapis.com is deliberately conditional, not always-on: every
# core resource (the VPC, Artifact Registry, the service account, Secret
# Manager) is ordered behind this ENTIRE for_each set via depends_on, so
# unconditionally including an API here means a project/org policy that
# merely disallows Redis (with enable_redis_cache left at its default
# false — Redis never even requested) would fail the whole apply, not
# just the optional Redis piece.
required_apis = toset(concat(
[
"run.googleapis.com",
"sqladmin.googleapis.com",
"secretmanager.googleapis.com",
"vpcaccess.googleapis.com",
"artifactregistry.googleapis.com",
"servicenetworking.googleapis.com",
# google_compute_network / google_compute_global_address (below) are
# Compute Engine resources — a fresh project without this API
# already enabled fails the very first `terraform apply` at VPC
# creation otherwise.
"compute.googleapis.com",
# google_service_account.cloud_run (below) fails with
# SERVICE_DISABLED on a fresh project without the IAM API already
# enabled.
"iam.googleapis.com",
# data.google_project.this (above) reads project metadata through the
# Cloud Resource Manager API — a fresh project without this API
# already enabled fails at plan time with SERVICE_DISABLED, before
# any other resource here even starts creating.
"cloudresourcemanager.googleapis.com",
],
var.enable_redis_cache ? ["redis.googleapis.com"] : []
))
}
resource "google_project_service" "apis" {
for_each = local.required_apis
project = var.project_id
service = each.value
# Scaffold default: `terraform destroy` shouldn't disable APIs the project
# may use for other purposes outside this scaffold.
disable_on_destroy = false
}
# ── Networking ──────────────────────────────────────────────────────────────
resource "google_compute_network" "this" {
name = "${local.app_name_safe}-vpc"
auto_create_subnetworks = false
# Without this, Terraform has no reason to order network creation after
# API enablement (nothing here references an attribute of
# google_project_service.apis) and may create it concurrently — on a
# fresh project where Compute Engine isn't enabled yet, that races the
# enablement and fails the first `terraform apply`. Every other resource
# here that transitively depends on this network (the PSA global address,
# the VPC connector, Cloud SQL, Redis) inherits the correct ordering
# through their own reference to google_compute_network.this, so this is
# the only explicit dependency needed.
depends_on = [google_project_service.apis]
}
# Reserves an internal IP range for the private services access peering
# below — Cloud SQL's private IP and (when enabled) Memorystore Redis both
# live in this peered range, never on the public internet.
#
# `address` is set explicitly (not left for Google to auto-allocate) so
# this range is deterministically known and can never overlap
# `var.vpc_connector_cidr` (default 10.8.0.0/28): both this peering range
# and the connector's range are independently scheduled after network
# creation, so if Google auto-picked this /16 and it happened to contain
# the connector's CIDR, one of the two creations would fail with an
# overlap error. 10.100.0.0/16 is comfortably outside the 10.8.0.0/28
# default — if you change `vpc_connector_cidr`, pick something outside
# 10.100.0.0/16 too.
resource "google_compute_global_address" "private_service_access" {
name = "${local.app_name_safe}-psa"
purpose = "VPC_PEERING"
address_type = "INTERNAL"
address = "10.100.0.0"
prefix_length = 16
network = google_compute_network.this.id
}
resource "google_service_networking_connection" "this" {
network = google_compute_network.this.id
service = "servicenetworking.googleapis.com"
reserved_peering_ranges = [google_compute_global_address.private_service_access.name]
depends_on = [google_project_service.apis]
}
# Serverless VPC Access connector — the ONLY path Cloud Run has into the VPC
# (and therefore into private-IP Cloud SQL/Redis). `ip_cidr_range` must be an
# unused /28 in the VPC, distinct from the private-service-access range
# above.
resource "google_vpc_access_connector" "this" {
name = "${local.connector_name_safe}-connector"
region = var.region
network = google_compute_network.this.name
ip_cidr_range = var.vpc_connector_cidr
depends_on = [google_project_service.apis]
}
# ── Artifact Registry ────────────────────────────────────────────────────────
resource "google_artifact_registry_repository" "this" {
repository_id = local.app_name_safe
location = var.region
format = "DOCKER"
depends_on = [google_project_service.apis]
}
# ── Service account ─────────────────────────────────────────────────────────
# Runtime identity for BOTH the Cloud Run service and the migration job.
# Least privilege: `roles/cloudsql.client` (needed to reach the private-IP
# instance through the connector) plus per-secret `secretAccessor` grants
# below (never a project-wide `roles/secretmanager.secretAccessor`, which
# would let a compromised container read every secret in the project).
resource "google_service_account" "cloud_run" {
account_id = local.service_account_id
display_name = "Runtime identity for {{project_name}} on Cloud Run"
# Without this, Terraform has no reason to order service-account creation
# after the IAM API is enabled (nothing here references an attribute of
# google_project_service.apis) — on a fresh project this fails with
# SERVICE_DISABLED.
depends_on = [google_project_service.apis]
}
resource "google_project_iam_member" "cloudsql_client" {
project = var.project_id
role = "roles/cloudsql.client"
member = "serviceAccount:${google_service_account.cloud_run.email}"
}
# ── Cloud SQL ────────────────────────────────────────────────────────────────
resource "google_sql_database_instance" "this" {
name = "${local.app_name_safe}-pg-${random_id.suffix.hex}"
database_version = "POSTGRES_16"
region = var.region
# Scaffold default: lets `terraform destroy`/recreate cycles run without
# a manual `deletion_protection` override first. Set to `true` before a
# real production cutover.
deletion_protection = false
settings {
tier = var.db_tier
ip_configuration {
ipv4_enabled = false
private_network = google_compute_network.this.id
enable_private_path_for_google_cloud_services = true
}
backup_configuration {
enabled = true
}
}
depends_on = [google_service_networking_connection.this]
}
resource "google_sql_database" "this" {
name = local.postgres_database_name
instance = google_sql_database_instance.this.name
}
resource "google_sql_user" "this" {
name = local.postgres_admin_username
instance = google_sql_database_instance.this.name
password = var.database_admin_password
}
# ── Secret Manager ──────────────────────────────────────────────────────────
# AUTUMN_DATABASE__PRIMARY_URL — derived from the Cloud SQL instance this
# same apply creates (not a variable), so a single `terraform apply`
# provisions the database AND the secret that points at it. Uses the
# instance's PRIVATE IP directly (not the Cloud SQL Auth Proxy's unix
# socket) since the VPC connector already gives Cloud Run a private network
# path to it.
resource "google_secret_manager_secret" "database_url" {
secret_id = "${local.app_name_safe}-database-url-${random_id.suffix.hex}"
replication {
auto {}
}
depends_on = [google_project_service.apis]
}
resource "google_secret_manager_secret_version" "database_url" {
secret = google_secret_manager_secret.database_url.id
secret_data = "postgres://${local.postgres_admin_username}:${urlencode(var.database_admin_password)}@${google_sql_database_instance.this.private_ip_address}:5432/${local.postgres_database_name}?sslmode=require"
# Unlike AWS RDS (where the master password lives directly on the DB
# instance resource, so referencing aws_db_instance.this.address here
# already creates an implicit ordering edge), Cloud SQL splits the
# instance and its user credentials into two independent resources —
# google_sql_database_instance.this (referenced above, for the private
# IP) and google_sql_user.this (which actually owns `password`). Without
# this explicit dependency, rotating database_admin_password gives
# Terraform no reason to update google_sql_user.this before this secret
# version — the two can apply in either order or concurrently, so the
# Cloud Run revision this secret version triggers (see the
# secret_key_ref version pin below) could start using the NEW password
# before Cloud SQL has actually accepted it. Ordering the secret version
# after the user update closes that specific race, though it doesn't
# eliminate a brief reconnect window for OLD revisions still holding the
# password that's being replaced in place — a full zero-downtime rotation
# would need a staged second database user, out of scope for this
# scaffold.
depends_on = [google_sql_user.this]
}
# AUTUMN_SECURITY__SIGNING_SECRET — required in production; see
# docs/guide/signing-secrets.md. Generate with `openssl rand -hex 32`.
resource "google_secret_manager_secret" "signing_secret" {
secret_id = "${local.app_name_safe}-signing-secret-${random_id.suffix.hex}"
replication {
auto {}
}
depends_on = [google_project_service.apis]
}
resource "google_secret_manager_secret_version" "signing_secret" {
secret = google_secret_manager_secret.signing_secret.id
secret_data = var.signing_secret
}
resource "google_secret_manager_secret_iam_member" "database_url_access" {
secret_id = google_secret_manager_secret.database_url.secret_id
role = "roles/secretmanager.secretAccessor"
member = "serviceAccount:${google_service_account.cloud_run.email}"
}
resource "google_secret_manager_secret_iam_member" "signing_secret_access" {
secret_id = google_secret_manager_secret.signing_secret.secret_id
role = "roles/secretmanager.secretAccessor"
member = "serviceAccount:${google_service_account.cloud_run.email}"
}
# ── Optional Memorystore Redis ───────────────────────────────────────────────
# Gated behind `enable_redis_cache`, off by default so a plain deploy incurs
# no extra cost.
#
# This provisions the infrastructure and wires AUTUMN_CACHE__BACKEND /
# AUTUMN_CACHE__REDIS__URL into the Cloud Run service, but that alone does
# NOT make the app use it: Autumn's cache subsystem has no built-in Redis
# implementation (unlike sessions/channels/jobs, which activate from config
# alone once compiled with the `redis` Cargo feature). Your application must
# ALSO depend on the separate `autumn-cache-redis` crate and register
# `.plugin(RedisCachePlugin::new())` in main.rs — without that, these env
# vars are parsed into config and never read by anything, the app silently
# keeps its default in-process cache, and you pay for a Memorystore instance
# that does nothing. See the "Shared Cache" section of
# docs/guide/cloud-native.md for the exact three steps.
resource "google_redis_instance" "this" {
count = var.enable_redis_cache ? 1 : 0
name = "${local.redis_name_safe}-redis"
tier = "BASIC"
memory_size_gb = 1
region = var.region
redis_version = "REDIS_7_0"
authorized_network = google_compute_network.this.id
connect_mode = "PRIVATE_SERVICE_ACCESS"
# AUTH (a generated password, below) is enabled, but transit encryption is
# deliberately left off. Memorystore's `SERVER_AUTHENTICATION` mode
# presents a private, instance-specific CA certificate — not one chained
# to a public root — and `autumn-cache-redis`'s `RedisCache::connect`
# (rustls, via the `redis` crate's default TLS setup) only trusts the
# standard public CA store, with no hook to supply a custom one. Unlike
# AWS ElastiCache/Azure Redis Cache (both terminate TLS with
# publicly-trusted certificates and work with the exact same client),
# enabling this mode here would produce a `rediss://` URL the app can
# never actually connect with — TLS handshake fails cert verification and
# the app aborts at startup. Traffic to this instance never leaves the
# private VPC (see `connect_mode` above and the VPC connector wiring on
# the Cloud Run service/job), so AUTH-only is an acceptable trade-off
# here, not a silent downgrade to an internet-exposed unencrypted link.
auth_enabled = true
transit_encryption_mode = "DISABLED"
depends_on = [google_service_networking_connection.this, google_project_service.apis]
}
resource "google_secret_manager_secret" "redis_url" {
count = var.enable_redis_cache ? 1 : 0
secret_id = "${local.app_name_safe}-redis-url-${random_id.suffix.hex}"
replication {
auto {}
}
depends_on = [google_project_service.apis]
}
resource "google_secret_manager_secret_version" "redis_url" {
count = var.enable_redis_cache ? 1 : 0
secret = google_secret_manager_secret.redis_url[0].id
# "redis://", not "rediss://" — see the transit_encryption_mode comment
# on google_redis_instance.this above for why TLS is off here.
secret_data = "redis://:${urlencode(google_redis_instance.this[0].auth_string)}@${google_redis_instance.this[0].host}:${google_redis_instance.this[0].port}"
}
resource "google_secret_manager_secret_iam_member" "redis_url_access" {
count = var.enable_redis_cache ? 1 : 0
secret_id = google_secret_manager_secret.redis_url[0].secret_id
role = "roles/secretmanager.secretAccessor"
member = "serviceAccount:${google_service_account.cloud_run.email}"
}
# ── Cloud Run service ─────────────────────────────────────────────────────────
resource "google_cloud_run_v2_service" "this" {
name = local.app_name_safe
location = var.region
# Scaffold default: lets `terraform destroy`/recreate cycles run without a
# manual override first. Set to `true` before a real production cutover.
deletion_protection = false
# Managed, publicly reachable ingress with automatic TLS — the Cloud Run
# analog to Azure Container Apps' `external_enabled = true` and App
# Runner's public-by-default service URL.
ingress = "INGRESS_TRAFFIC_ALL"
template {
service_account = google_service_account.cloud_run.email
scaling {
min_instance_count = var.min_instances
max_instance_count = var.max_instances
}
# Requests per instance before Cloud Run starts another one. 80 is
# Cloud Run's own default when this is omitted entirely; set explicitly
# (rather than left implicit) so it's a visible, tunable knob instead of
# an unstated platform default.
max_instance_request_concurrency = 80
vpc_access {
connector = google_vpc_access_connector.this.id
# Only route RFC 1918 (private) destinations through the connector —
# general internet egress (a third-party API call, outbound mail)
# keeps using Cloud Run's normal path instead of being forced through
# the connector's own bandwidth ceiling.
egress = "PRIVATE_RANGES_ONLY"
}
containers {
# Terraform only ever sets the bootstrap placeholder here — see the
# header comment. Real deploys land via `gcloud run services update
# --image ...` (gcp-deploy.yml / the manual walkthrough in
# docs/guide/deployment.md), which the `lifecycle` block below stops a
# later `terraform apply` from reverting.
image = var.bootstrap_image
ports {
container_port = 3000
}
# Cloud Run sends no traffic to an instance until its startup probe
# passes, so the startup probe is the traffic gate and uses /ready.
# /ready fails until startup is complete and while a readiness
# dependency is down. Cloud Run stops traffic to an instance by itself
# before SIGTERM. A slow first boot gets 2 minutes (24 x 5s). The
# bootstrap "hello" image answers 200 on every path, so it passes too.
startup_probe {
period_seconds = 5
timeout_seconds = 2
failure_threshold = 24
http_get {
path = "/ready"
}
}
# A failed liveness probe restarts the instance. /live does not fail
# when a dependency, such as the database, is down.
liveness_probe {
period_seconds = 10
timeout_seconds = 3
http_get {
path = "/live"
}
}
# CPU is only allocated while an instance is actively handling a
# request by default (`cpu_idle = true`) — the cost-optimized setting,
# and required for `min_instances = 0` scale-to-zero to actually save
# cost between requests. Latency-sensitive workloads that do
# background work between requests (e.g. an in-process scheduler, a
# long-lived websocket keepalive) need the CPU allocated for the full
# instance lifetime instead: set `cpu_idle = false` below (Cloud Run
# then bills for the whole instance lifetime, not just request
# processing).
resources {
limits = {
cpu = "1"
memory = "512Mi"
}
cpu_idle = true
# cpu_idle = false # uncomment for always-allocated CPU
}
env {
name = "AUTUMN_PROFILE"
value = "prod"
}
# Required: the prod profile fails fast at startup — the process
# never binds — when security.trusted_hosts.hosts is empty (host-
# header allow-list, see docs/guide/deployment.md#trusted-hosts-host-
# header-allow-list). Comma-separate additional entries here (e.g. a
# custom domain once one is bound) — the config parser reads this as
# a CSV list.
env {
name = "AUTUMN_SECURITY__TRUSTED_HOSTS__HOSTS"
value = local.service_url_host
}
# version is pinned to the specific version number Terraform just
# created, not the "latest" alias: Cloud Run resolves a secret
# reference once, when a NEW revision is created — it never re-reads
# "latest" for an already-running revision's already-running
# instances. Rotating database_admin_password/signing_secret via
# Terraform creates a new google_secret_manager_secret_version, but if
# the env block here still read "latest", that's an unchanged literal
# from Terraform's point of view (no diff), so no new revision would
# be created at all — leaving every instance on the OLD secret value
# while Cloud SQL had already started requiring the NEW password.
# Pinning the version here means the rotation itself changes this
# value, which Terraform sees as a diff and rolls out as a new
# revision — keeping the whole fleet on one consistent secret value.
env {
name = "AUTUMN_DATABASE__PRIMARY_URL"
value_source {
secret_key_ref {
secret = google_secret_manager_secret.database_url.secret_id
version = google_secret_manager_secret_version.database_url.version
}
}
}
env {
name = "AUTUMN_SECURITY__SIGNING_SECRET"
value_source {
secret_key_ref {
secret = google_secret_manager_secret.signing_secret.secret_id
version = google_secret_manager_secret_version.signing_secret.version
}
}
}
# Autumn's cache config is `[cache] backend` + `[cache.redis] url`
# (env: AUTUMN_CACHE__BACKEND / AUTUMN_CACHE__REDIS__URL — note the
# double underscore before URL, matching the nested `cache.redis.url`
# TOML path). Without the backend switch, Autumn stays on its default
# in-memory cache and never reads the URL at all, silently ignoring
# this (paid) Memorystore instance.
dynamic "env" {
for_each = var.enable_redis_cache ? [1] : []
content {
name = "AUTUMN_CACHE__BACKEND"
value = "redis"
}
}
dynamic "env" {
for_each = var.enable_redis_cache ? [1] : []
content {
name = "AUTUMN_CACHE__REDIS__URL"
value_source {
secret_key_ref {
secret = google_secret_manager_secret.redis_url[0].secret_id
version = google_secret_manager_secret_version.redis_url[0].version
}
}
}
}
}
}
# The database_url/signing_secret/redis_url *version* resources (not just
# the secret containers referenced by ID in the env blocks above) must
# exist before the service is created — a `secret_key_ref` only implies a
# dependency on the empty secret container, since that's the resource
# actually referenced by `.secret_id` — without this, Terraform is free to
# create the service as soon as the (still-valueless) secret exists,
# racing the version write. The per-secret IAM grants must also exist
# first: without `secretAccessor`, the bootstrap revision fails to resolve
# the secret at startup and the first apply never stabilizes. Referencing
# the redis_url resources without an index is deliberate and valid even
# though they're `count`-gated: when `enable_redis_cache = false` they have
# zero instances, so this depends on nothing extra; when `true`, it closes
# the identical race for Redis that the comment above describes for the
# other two secrets.
depends_on = [
google_secret_manager_secret_version.database_url,
google_secret_manager_secret_version.signing_secret,
google_secret_manager_secret_version.redis_url,
google_secret_manager_secret_iam_member.database_url_access,
google_secret_manager_secret_iam_member.signing_secret_access,
google_secret_manager_secret_iam_member.redis_url_access,
]
lifecycle {
# CI manages the real image after bootstrap (see the header comment);
# without this, `terraform apply` would revert every live deploy back
# to `var.bootstrap_image`.
ignore_changes = [template[0].containers[0].image]
}
}
# Cloud Run services default to requiring IAM-authenticated invocations —
# without this, the deployed app would 403 every request from a browser.
# `roles/run.invoker` for `allUsers` mirrors Azure's
# `ingress.external_enabled = true` and App Runner's public-by-default
# service URL: a publicly reachable web app, same as every other target
# this scaffold supports.
resource "google_cloud_run_v2_service_iam_member" "public" {
name = google_cloud_run_v2_service.this.name
location = var.region
project = var.project_id
role = "roles/run.invoker"
member = "allUsers"
}
# One-shot migration job — mirrors the docker-compose target's dedicated
# `migrate` service and Azure's one-shot Container Apps Job. CI
# (gcp-deploy.yml) registers the new release image on this job and runs it
# to completion via `gcloud run jobs execute --wait` *before* the app
# service is updated to the new image, so a schema-dependent release never
# serves traffic against the old schema (the generated production config
# sets `auto_migrate_in_production = false` — no running revision migrates
# on its own). Nothing ever runs this automatically; only an explicit
# `gcloud run jobs execute` call starts it.
resource "google_cloud_run_v2_job" "migrate" {
name = "${local.app_name_safe}-migrate"
location = var.region
# Scaffold default: lets `terraform destroy`/recreate cycles run without a
# manual override first.
deletion_protection = false
template {
template {
service_account = google_service_account.cloud_run.email
max_retries = 0
timeout = "600s"
vpc_access {
connector = google_vpc_access_connector.this.id
egress = "PRIVATE_RANGES_ONLY"
}
containers {
image = var.bootstrap_image
command = ["sh", "-lc", "autumn migrate"]
env {
name = "AUTUMN_DATABASE__PRIMARY_URL"
value_source {
secret_key_ref {
secret = google_secret_manager_secret.database_url.secret_id
version = google_secret_manager_secret_version.database_url.version
}
}
}
}
}
}
depends_on = [
google_secret_manager_secret_version.database_url,
google_secret_manager_secret_iam_member.database_url_access,
]
lifecycle {
ignore_changes = [template[0].template[0].containers[0].image]
}
}