Scale-to-zero async worker with dead-letter
A worker processes messages from a queue, scales on queue length (down to zero), stores results in object storage and sets aside failing messages.
Problem
You have background work with irregular load (file processing, notifications, integrations). You want to pay close to zero when there are no messages, scale only when the queue grows, not lose failing messages and use no keys or connection strings: the worker authenticates with its identity against the queue and storage.
The topology is the same on every cloud; what changes is what implements each role.
Queue
AWS
Amazon SQS (Standard)
SQS-managed SSE
Coming from Azure: there this role is Azure Service Bus (Standard) — queue. The visibility timeout plays the role of the peek-lock lock, and it must be at least 6 times the Lambda timeout.
Coming from GCP: there this role is Pub/Sub topic + push subscription.
1 gotcha in this role
GCP
Pub/Sub topic + push subscription≈ Partial equivalent
OIDC-authenticated push
Pub/Sub is publish/subscribe: the topic doesn't store messages, they're retained per subscription.
Coming from Azure: there this role is Azure Service Bus (Standard) — queue. It isn't a queue: messages live in the subscription. With push, the ack is the HTTP response and must arrive within the ack deadline.
Coming from AWS: there this role is Amazon SQS (Standard). It isn't a queue: messages live in the subscription. With push, the ack is the HTTP response and must arrive within the ack deadline.
2 gotchas in this role
Worker
AWS
AWS Lambda
Event source mapping with ReportBatchItemFailures
Coming from Azure: there this role is Container Apps Job (event-driven).
Coming from GCP: there this role is Cloud Run (service).
3 gotchas in this role
GCP
Cloud Run (service)
Internal ingress, invoked by Pub/Sub
Coming from Azure: there this role is Container Apps Job (event-driven).
Coming from AWS: there this role is AWS Lambda.
1 gotcha in this role
Secretless identity
AWS
IAM Role (Lambda execution role)
sqs:ReceiveMessage/DeleteMessage + scoped s3:PutObject
Coming from Azure: there this role is User-assigned Managed Identity.
Coming from GCP: there this role is Service Accounts (worker + push invoker).
No fine print in this role.
GCP
Service Accounts (worker + push invoker)
roles/run.invoker for the push SA
Coming from Azure: there this role is User-assigned Managed Identity.
Coming from AWS: there this role is IAM Role (Lambda execution role).
1 gotcha in this role
Results
AWS
S3 bucket
Block Public Access + BucketOwnerEnforced
Coming from Azure: there this role is Storage Account + container.
Coming from GCP: there this role is Cloud Storage bucket.
No fine print in this role.
GCP
Cloud Storage bucket
UBLA + public access prevention
Coming from Azure: there this role is Storage Account + container.
Coming from AWS: there this role is S3 bucket.
No fine print in this role.
Failed messages
AWS
Separate SQS queue
redrive_policy with maxReceiveCount
Coming from Azure: there this role is $DeadLetterQueue subqueue. It isn't a subqueue: it's another queue, and in Standard the message expires based on when it entered the original queue. Give it longer retention than the source.
Coming from GCP: there this role is Dead-letter topic + pull subscription.
1 gotcha in this role
GCP
Dead-letter topic + pull subscription
max_delivery_attempts + service agent permissions
Coming from Azure: there this role is $DeadLetterQueue subqueue.
Coming from AWS: there this role is Separate SQS queue.
2 gotchas in this role
Amazon Web Services
Implementation
- Queue
- Amazon SQS (Standard)SQS-managed SSEComing from Azure: there this role is Azure Service Bus (Standard) — queue. The visibility timeout plays the role of the peek-lock lock, and it must be at least 6 times the Lambda timeout.Coming from GCP: there this role is Pub/Sub topic + push subscription.
- Worker
- AWS LambdaEvent source mapping with ReportBatchItemFailuresComing from Azure: there this role is Container Apps Job (event-driven).Coming from GCP: there this role is Cloud Run (service).
- Secretless identity
- IAM Role (Lambda execution role)sqs:ReceiveMessage/DeleteMessage + scoped s3:PutObjectComing from Azure: there this role is User-assigned Managed Identity.Coming from GCP: there this role is Service Accounts (worker + push invoker).
- Results
- S3 bucketBlock Public Access + BucketOwnerEnforcedComing from Azure: there this role is Storage Account + container.Coming from GCP: there this role is Cloud Storage bucket.
- Failed messages
- Separate SQS queueredrive_policy with maxReceiveCountComing from Azure: there this role is $DeadLetterQueue subqueue. It isn't a subqueue: it's another queue, and in Standard the message expires based on when it entered the original queue. Give it longer retention than the source.Coming from GCP: there this role is Dead-letter topic + pull subscription.
Cost model
Scales to zero
No fixed cost
Zero fixed cost: SQS, Lambda and S3 are pay-per-use. Note the event source mapping's long polling: SQS polling requests are billed even when the queue is empty (few, but not zero).
Monthly floor with no traffic
USD 0/ mo
USD 0: no components with a fixed cost.
Fine print · 4 gotchas
The queue's visibility timeout must be at least 6 times the Lambda timeout
If the function takes longer than the visibility timeout, the message becomes visible again and another invocation picks it up while the first is still processing: guaranteed duplicates. AWS recommends a visibility timeout ≥ 6× the function timeout. The module computes and validates it.
Without ReportBatchItemFailures, one failed message retries the whole batch
By default, if the function fails the whole batch goes back to the queue, including already processed messages. With function_response_types = ["ReportBatchItemFailures"] the function returns only the failed IDs. In Azure and GCP acks are per message, so this problem doesn't exist in the same way.
In Standard queues, the message expires in the DLQ based on when it entered the original queue
When moving a message to the DLQ, SQS keeps the original enqueue timestamp (in FIFO it resets). If the DLQ has the same retention as the source queue, messages can vanish from the DLQ almost immediately. Set the DLQ retention longer than the source (the module uses the maximum, 14 days).
Limit the event source mapping's concurrency, not just the function's
scaling_config.maximum_concurrency limits how many concurrent instances of the function the queue can invoke. It's an event source mapping setting, independent of the function's reserved concurrency and free of charge, and it keeps one queue from using all of the reserved concurrency or the account's concurrency quota. Note: the reserved concurrency must be greater than or equal to the sum of the maximums of all the function's event sources; otherwise Lambda might throttle your messages.
Terraform
main.tf
# Worker asíncrono con escala a cero — AWS
#
# Lambda disparada por SQS con respuestas parciales de lote, DLQ con retención
# mayor que la cola de origen y resultados en S3. Sin claves: el execution role
# de la función es la identidad.
data "aws_partition" "current" {}
locals {
# AWS recomienda visibility timeout >= 6× el timeout de la función.
visibility_timeout_seconds = var.function_timeout_seconds * 6
results_prefix = "results/"
}
# --- Colas ----------------------------------------------------------------------
resource "aws_sqs_queue" "dlq" {
name = "${var.name}-jobs-dlq"
sqs_managed_sse_enabled = true
# Máximo (14 días): en colas Standard el vencimiento cuenta desde que el mensaje
# entró a la cola original (gotcha: dlq-retention-original-timestamp).
message_retention_seconds = 1209600
}
resource "aws_sqs_queue" "jobs" {
name = "${var.name}-jobs"
sqs_managed_sse_enabled = true
visibility_timeout_seconds = local.visibility_timeout_seconds
message_retention_seconds = 345600
receive_wait_time_seconds = 20
redrive_policy = jsonencode({
deadLetterTargetArn = aws_sqs_queue.dlq.arn
maxReceiveCount = var.max_receive_count
})
}
# Solo la cola de trabajos puede usar esta DLQ.
resource "aws_sqs_queue_redrive_allow_policy" "dlq" {
queue_url = aws_sqs_queue.dlq.id
redrive_allow_policy = jsonencode({
redrivePermission = "byQueue"
sourceQueueArns = [aws_sqs_queue.jobs.arn]
})
}
# --- Bucket de resultados -------------------------------------------------------
resource "aws_s3_bucket" "results" {
#checkov:skip=CKV_AWS_21:Resultados regenerables con TTL; versioning duplicaría costo (ver patrón object-storage-ttl).
#checkov:skip=CKV_AWS_144:Replicación cross-region no aplica a resultados regenerables.
#checkov:skip=CKV_AWS_18:Access logging fuera de alcance; usar CloudTrail data events si se requiere.
#checkov:skip=CKV2_AWS_62:Notificaciones de eventos fuera de alcance del patrón.
bucket = var.results_bucket_name
}
resource "aws_s3_bucket_public_access_block" "results" {
bucket = aws_s3_bucket.results.id
block_public_acls = true
block_public_policy = true
ignore_public_acls = true
restrict_public_buckets = true
}
resource "aws_s3_bucket_ownership_controls" "results" {
bucket = aws_s3_bucket.results.id
rule {
object_ownership = "BucketOwnerEnforced"
}
}
resource "aws_s3_bucket_server_side_encryption_configuration" "results" {
#checkov:skip=CKV2_AWS_67:Clave administrada por AWS (aws/s3).
bucket = aws_s3_bucket.results.id
rule {
apply_server_side_encryption_by_default {
sse_algorithm = "aws:kms"
}
bucket_key_enabled = true
}
}
resource "aws_s3_bucket_lifecycle_configuration" "results" {
bucket = aws_s3_bucket.results.id
rule {
id = "expire-results"
status = "Enabled"
filter {
prefix = local.results_prefix
}
expiration {
days = var.results_ttl_days
}
}
rule {
id = "abort-incomplete-multipart"
status = "Enabled"
filter {}
abort_incomplete_multipart_upload {
days_after_initiation = 1
}
}
}
# --- Identidad de la función ----------------------------------------------------
resource "aws_iam_role" "worker" {
name = "${var.name}-worker"
assume_role_policy = jsonencode({
Version = "2012-10-17"
Statement = [{ Effect = "Allow", Principal = { Service = "lambda.amazonaws.com" }, Action = "sts:AssumeRole" }]
})
}
resource "aws_iam_role_policy_attachment" "worker_managed" {
for_each = toset([
"service-role/AWSLambdaBasicExecutionRole",
"AWSXRayDaemonWriteAccess",
])
role = aws_iam_role.worker.name
policy_arn = "arn:${data.aws_partition.current.partition}:iam::aws:policy/${each.value}"
}
resource "aws_iam_role_policy" "worker" {
name = "consume-jobs-write-results"
role = aws_iam_role.worker.id
policy = jsonencode({
Version = "2012-10-17"
Statement = [
{
Effect = "Allow"
Action = ["sqs:ReceiveMessage", "sqs:DeleteMessage", "sqs:GetQueueAttributes", "sqs:ChangeMessageVisibility"]
Resource = aws_sqs_queue.jobs.arn
},
{
Effect = "Allow"
Action = ["s3:PutObject"]
Resource = "${aws_s3_bucket.results.arn}/${local.results_prefix}*"
},
]
})
}
# --- Lambda + event source mapping ----------------------------------------------
resource "aws_cloudwatch_log_group" "worker" {
#checkov:skip=CKV_AWS_158:Cifrado con la clave administrada por CloudWatch; CMK opcional.
name = "/aws/lambda/${var.name}-worker"
retention_in_days = 365
}
resource "aws_lambda_function" "worker" {
#checkov:skip=CKV_AWS_115:La concurrencia se limita en el event source mapping (scaling_config), no con reserved concurrency (gotcha: max-concurrency-protects-downstream).
#checkov:skip=CKV_AWS_116:Invocación síncrona desde SQS: la DLQ vive en la cola, no en la función.
#checkov:skip=CKV_AWS_117:Sin VPC: SQS y S3 se acceden por sus endpoints de AWS; ver private-web-api para red privada.
#checkov:skip=CKV_AWS_173:Variables de entorno sin secretos; cifradas con la clave administrada por Lambda.
#checkov:skip=CKV_AWS_272:Code signing no aplica a funciones empaquetadas como imagen.
function_name = "${var.name}-worker"
role = aws_iam_role.worker.arn
package_type = "Image"
image_uri = var.image_uri
timeout = var.function_timeout_seconds
memory_size = 512
tracing_config {
mode = "Active"
}
environment {
variables = {
RESULTS_BUCKET = aws_s3_bucket.results.bucket
RESULTS_PREFIX = local.results_prefix
}
}
depends_on = [aws_cloudwatch_log_group.worker, aws_iam_role_policy_attachment.worker_managed]
}
resource "aws_lambda_event_source_mapping" "jobs" {
event_source_arn = aws_sqs_queue.jobs.arn
function_name = aws_lambda_function.worker.arn
batch_size = var.batch_size
maximum_batching_window_in_seconds = 5
# Solo los mensajes que fallaron vuelven a la cola (gotcha: partial-batch-failures).
function_response_types = ["ReportBatchItemFailures"]
scaling_config {
maximum_concurrency = var.max_concurrency
}
}variables.tf
variable "name" {
description = "Prefijo para nombrar los recursos (ej. \"worker-demo\")."
type = string
validation {
condition = can(regex("^[a-z0-9-]{3,20}$", var.name))
error_message = "name: 3-20 caracteres, minúsculas, números o guiones."
}
}
variable "region" {
description = "Región de AWS."
type = string
default = "us-east-1"
}
variable "results_bucket_name" {
description = "Nombre global único del bucket de resultados."
type = string
validation {
condition = can(regex("^[a-z0-9][a-z0-9.-]{1,61}[a-z0-9]$", var.results_bucket_name))
error_message = "results_bucket_name: 3-63 caracteres, minúsculas, números, puntos o guiones."
}
}
variable "image_uri" {
description = "Imagen de la función en ECR."
type = string
}
variable "function_timeout_seconds" {
description = "Timeout de la Lambda. El visibility timeout de la cola se fija en 6× este valor (gotcha: visibility-timeout-6x)."
type = number
default = 60
validation {
condition = var.function_timeout_seconds >= 1 && var.function_timeout_seconds <= 900
error_message = "function_timeout_seconds: entre 1 y 900 (límite de Lambda)."
}
}
variable "max_receive_count" {
description = "Entregas antes de mandar el mensaje a la DLQ."
type = number
default = 5
}
variable "batch_size" {
description = "Mensajes por invocación."
type = number
default = 10
}
variable "max_concurrency" {
description = "Invocaciones concurrentes máximas disparadas por la cola (gotcha: max-concurrency-protects-downstream)."
type = number
default = 10
validation {
condition = var.max_concurrency >= 2
error_message = "maximum_concurrency de un event source mapping de SQS es como mínimo 2."
}
}
variable "results_ttl_days" {
description = "Días que se conservan los resultados."
type = number
default = 30
}
variable "tags" {
description = "Tags aplicados a todos los recursos."
type = map(string)
default = {}
}outputs.tf
output "queue_url" {
description = "URL de la cola de trabajos (los productores necesitan sqs:SendMessage)."
value = aws_sqs_queue.jobs.id
}
output "dlq_url" {
description = "URL de la dead-letter queue."
value = aws_sqs_queue.dlq.id
}
output "results_bucket" {
description = "Bucket de resultados."
value = aws_s3_bucket.results.bucket
}versions.tf
terraform {
required_version = ">= 1.7.0"
required_providers {
aws = {
source = "hashicorp/aws"
version = "~> 6.67"
}
}
}
provider "aws" {
region = var.region
default_tags {
tags = var.tags
}
}Google Cloud
Implementation
- Queue
- Pub/Sub topic + push subscription≈OIDC-authenticated pushPub/Sub is publish/subscribe: the topic doesn't store messages, they're retained per subscription.Coming from Azure: there this role is Azure Service Bus (Standard) — queue. It isn't a queue: messages live in the subscription. With push, the ack is the HTTP response and must arrive within the ack deadline.Coming from AWS: there this role is Amazon SQS (Standard). It isn't a queue: messages live in the subscription. With push, the ack is the HTTP response and must arrive within the ack deadline.
- Worker
- Cloud Run (service)Internal ingress, invoked by Pub/SubComing from Azure: there this role is Container Apps Job (event-driven).Coming from AWS: there this role is AWS Lambda.
- Secretless identity
- Service Accounts (worker + push invoker)roles/run.invoker for the push SAComing from Azure: there this role is User-assigned Managed Identity.Coming from AWS: there this role is IAM Role (Lambda execution role).
- Results
- Cloud Storage bucketUBLA + public access preventionComing from Azure: there this role is Storage Account + container.Coming from AWS: there this role is S3 bucket.
- Failed messages
- Dead-letter topic + pull subscriptionmax_delivery_attempts + service agent permissionsComing from Azure: there this role is $DeadLetterQueue subqueue.Coming from AWS: there this role is Separate SQS queue.
Cost model
Scales to zero
No fixed cost
Zero fixed cost: Pub/Sub is billed by volume, Cloud Run scales to zero and Cloud Storage is pay-per-use. Push also avoids polling: there are no billable requests while the queue is empty.
Monthly floor with no traffic
USD 0/ mo
USD 0: no components with a fixed cost.
Fine print · 4 gotchas
Dead-lettering doesn't work without permissions for the Pub/Sub service agent
The forwarder to the dead-letter topic is service-<number>@gcp-sa-pubsub.iam.gserviceaccount.com, and it needs publisher on the dead-letter topic and subscriber on the source subscription. Without those roles the policy is configured but messages never move. The module grants both.
A dead-letter topic without a subscription drops messages
In Pub/Sub a topic doesn't store messages by itself: they're retained per subscription. If the dead-letter topic has no subscription created before messages arrive, they're lost. The minimum delivery attempts before forwarding is 5 (maximum 100).
With push, the ack is the HTTP response and must arrive within the ack deadline
Pub/Sub considers the message processed when the endpoint responds with 102, 200, 201, 202 or 204; any other code is a nack and Pub/Sub resends the message. The same happens if the worker takes longer than the ack deadline: Pub/Sub redelivers it while it's still running.
Pub/Sub offers exactly-once delivery, but only on pull subscriptions
Pub/Sub supports exactly-once delivery only with pull subscriptions: push (what triggers Cloud Run) doesn't support it, because the client doesn't know whether Pub/Sub received the response and processed it.
Terraform
main.tf
# Worker asíncrono con escala a cero — GCP
#
# Pub/Sub push autenticado (OIDC) hacia Cloud Run con ingress interno,
# dead-letter topic con su suscripción y permisos del service agent, y
# resultados en Cloud Storage.
data "google_project" "this" {
project_id = var.project_id
}
locals {
pubsub_service_agent = "serviceAccount:service-${data.google_project.this.number}@gcp-sa-pubsub.iam.gserviceaccount.com"
}
# --- Identidades -----------------------------------------------------------------
resource "google_service_account" "worker" {
account_id = "${var.name}-worker"
display_name = "Worker (${var.name})"
}
# Identidad con la que Pub/Sub firma el OIDC token del push.
resource "google_service_account" "push" {
account_id = "${var.name}-push"
display_name = "Pub/Sub push invoker (${var.name})"
}
# --- Resultados ------------------------------------------------------------------
resource "google_storage_bucket" "results" {
#checkov:skip=CKV_GCP_62:Access logs fuera de alcance; usar Cloud Audit Logs (Data Access) si se requiere.
#checkov:skip=CKV_GCP_78:Resultados regenerables con TTL; versioning duplicaría costo.
name = var.results_bucket_name
location = upper(var.region)
storage_class = "STANDARD"
uniform_bucket_level_access = true
public_access_prevention = "enforced"
labels = var.labels
soft_delete_policy {
retention_duration_seconds = 604800
}
lifecycle_rule {
condition {
age = var.results_ttl_days
}
action {
type = "Delete"
}
}
lifecycle_rule {
condition {
age = 1
}
action {
type = "AbortIncompleteMultipartUpload"
}
}
}
resource "google_storage_bucket_iam_member" "worker_results" {
bucket = google_storage_bucket.results.name
role = "roles/storage.objectCreator"
member = google_service_account.worker.member
}
# --- Worker en Cloud Run -----------------------------------------------------------
resource "google_cloud_run_v2_service" "worker" {
name = "${var.name}-worker"
location = var.region
# Pub/Sub cuenta como origen interno: no hace falta exponer el servicio.
ingress = "INGRESS_TRAFFIC_INTERNAL_ONLY"
deletion_protection = false
labels = var.labels
template {
service_account = google_service_account.worker.email
timeout = "${var.ack_deadline_seconds}s"
scaling {
min_instance_count = 0
max_instance_count = var.max_instances
}
containers {
image = var.container_image
env {
name = "RESULTS_BUCKET"
value = google_storage_bucket.results.name
}
}
}
}
resource "google_cloud_run_v2_service_iam_member" "push_invoker" {
name = google_cloud_run_v2_service.worker.name
location = google_cloud_run_v2_service.worker.location
role = "roles/run.invoker"
member = google_service_account.push.member
}
# --- Pub/Sub -----------------------------------------------------------------------
resource "google_pubsub_topic" "jobs" {
#checkov:skip=CKV_GCP_83:Cifrado con claves administradas por Google; CMEK opcional.
name = "${var.name}-jobs"
labels = var.labels
}
resource "google_pubsub_topic" "dead_letter" {
#checkov:skip=CKV_GCP_83:Cifrado con claves administradas por Google; CMEK opcional.
name = "${var.name}-jobs-dead-letter"
labels = var.labels
}
# Sin suscripción, lo que llega al dead-letter topic se pierde
# (gotcha: dead-letter-topic-needs-subscription).
resource "google_pubsub_subscription" "dead_letter" {
name = "${var.name}-jobs-dead-letter"
topic = google_pubsub_topic.dead_letter.id
message_retention_duration = "604800s"
ack_deadline_seconds = 60
labels = var.labels
expiration_policy {
ttl = ""
}
}
resource "google_pubsub_subscription" "jobs" {
name = "${var.name}-jobs-push"
topic = google_pubsub_topic.jobs.id
ack_deadline_seconds = var.ack_deadline_seconds
labels = var.labels
push_config {
push_endpoint = google_cloud_run_v2_service.worker.uri
oidc_token {
service_account_email = google_service_account.push.email
}
}
dead_letter_policy {
dead_letter_topic = google_pubsub_topic.dead_letter.id
max_delivery_attempts = var.max_delivery_attempts
}
retry_policy {
minimum_backoff = "10s"
maximum_backoff = "600s"
}
expiration_policy {
ttl = ""
}
depends_on = [google_pubsub_subscription.dead_letter]
}
# El service agent de Pub/Sub es quien mueve mensajes al dead-letter
# (gotcha: dlq-needs-service-agent-roles).
resource "google_pubsub_topic_iam_member" "dead_letter_publisher" {
topic = google_pubsub_topic.dead_letter.id
role = "roles/pubsub.publisher"
member = local.pubsub_service_agent
}
resource "google_pubsub_subscription_iam_member" "source_subscriber" {
subscription = google_pubsub_subscription.jobs.id
role = "roles/pubsub.subscriber"
member = local.pubsub_service_agent
}variables.tf
variable "project_id" {
description = "ID del proyecto de GCP."
type = string
}
variable "name" {
description = "Prefijo para nombrar los recursos (ej. \"worker-demo\")."
type = string
validation {
condition = can(regex("^[a-z][a-z0-9-]{2,19}$", var.name))
error_message = "name: 3-20 caracteres, empieza con letra, minúsculas, números o guiones."
}
}
variable "region" {
description = "Región de GCP."
type = string
default = "us-central1"
}
variable "results_bucket_name" {
description = "Nombre global único del bucket de resultados."
type = string
}
variable "container_image" {
description = "Imagen del worker (responde 2xx cuando procesó el mensaje)."
type = string
}
variable "ack_deadline_seconds" {
description = "Ack deadline de la suscripción push; también es el timeout del servicio (gotcha: push-ack-deadline)."
type = number
default = 60
validation {
condition = var.ack_deadline_seconds >= 10 && var.ack_deadline_seconds <= 600
error_message = "ack_deadline_seconds: entre 10 y 600."
}
}
variable "max_delivery_attempts" {
description = "Intentos antes de reenviar al dead-letter topic (Pub/Sub: 5 a 100)."
type = number
default = 5
validation {
condition = var.max_delivery_attempts >= 5 && var.max_delivery_attempts <= 100
error_message = "max_delivery_attempts: entre 5 y 100."
}
}
variable "max_instances" {
description = "Máximo de instancias del worker."
type = number
default = 10
}
variable "results_ttl_days" {
description = "Días que se conservan los resultados."
type = number
default = 30
}
variable "labels" {
description = "Labels aplicados a los recursos que los soportan."
type = map(string)
default = {}
}outputs.tf
output "jobs_topic" {
description = "Topic al que publican los productores (necesitan roles/pubsub.publisher)."
value = google_pubsub_topic.jobs.id
}
output "dead_letter_subscription" {
description = "Suscripción pull para inspeccionar y reprocesar mensajes fallidos."
value = google_pubsub_subscription.dead_letter.id
}
output "results_bucket" {
description = "Bucket de resultados."
value = google_storage_bucket.results.url
}versions.tf
terraform {
required_version = ">= 1.7.0"
required_providers {
google = {
source = "hashicorp/google"
version = "~> 8.6"
}
}
}
provider "google" {
project = var.project_id
region = var.region
}