Private object ingestion with expiration (TTL)
A workload uploads files to a private bucket, keyless, over a private network, and files delete themselves after N days.
Problem
You need to receive temporary files (exports, user uploads, ETL staging) that must not be exposed or pile up. Requirements: zero public access, zero static keys, private-network traffic, permissions scoped to the ingestion prefix, and automatic deletion after N days.
The topology is the same on every cloud; what changes is what implements each role.
Secretless identity
AWS
IAM Role (ECS task role)
s3:PutObject scoped to bucket/incoming/*
Coming from Azure: there this role is User-assigned Managed Identity.
Coming from GCP: there this role is Service Account attached to the workload.
No fine print in this role.
Azure
User-assigned Managed Identity
Storage Blob Data Contributor on the container
Coming from AWS: there this role is IAM Role (ECS task role). The smallest role assignment scope is the container, not a prefix as in an S3 policy: scoping by path needs an ABAC condition.
Coming from GCP: there this role is Service Account attached to the workload.
2 gotchas in this role
Private access
AWS
S3 Gateway VPC Endpoint≈ Partial equivalent
attached to the private route table
It's a route-table entry, not a private IP: it isn't reachable from on-premises or another region (that takes an Interface Endpoint).
Coming from Azure: there this role is Private Endpoint (subresource blob). It isn't a Private Endpoint: it's a free route-table entry that isn't reachable from on-premises or another region. The real equivalent is an Interface Endpoint, billed hourly.
Coming from GCP: there this role is Private Google Access.
1 gotcha in this role
Azure
Private Endpoint (subresource blob)
+ privatelink.blob.core.windows.net zone
Coming from AWS: there this role is S3 Gateway VPC Endpoint. Besides the endpoint you need the privatelink.blob.core.windows.net zone linked to the VNet; without it the public IP resolves and the request gets a 403.
Coming from GCP: there this role is Private Google Access.
1 gotcha in this role
Object storage
AWS
S3 bucket
Block Public Access + BucketOwnerEnforced + SSE-KMS
Coming from Azure: there this role is Storage Account (StorageV2) + container.
Coming from GCP: there this role is Cloud Storage bucket.
2 gotchas in this role
Azure
Storage Account (StorageV2) + container
Shared Key disabled, public network access disabled
Coming from AWS: there this role is S3 bucket.
Coming from GCP: there this role is Cloud Storage bucket.
2 gotchas in this role
Expiration policy
AWS
S3 Lifecycle rule
Expiration.Days + AbortIncompleteMultipartUpload
Coming from Azure: there this role is Lifecycle management policy. The filter is just the prefix inside the bucket, and billing stops at the expiration date even if physical deletion takes days.
Coming from GCP: there this role is Object Lifecycle Management.
3 gotchas in this role
Azure
Lifecycle management policy
delete_after_days_since_creation_greater_than
Coming from AWS: there this role is S3 Lifecycle rule. The filter prefix includes the container name: an "incoming/" copied from S3 matches nothing and raises no error.
Coming from GCP: there this role is Object Lifecycle Management. The filter prefix includes the container name, and with soft delete on, deletion doesn't free capacity until retention expires.
3 gotchas in this role
Data perimeter
AWS
Bucket policy
Deny if aws:SourceVpce ≠ endpoint
Coming from Azure: there this role is Storage firewall.
Coming from GCP: there this role is Bucket IP filtering (optional).
1 gotcha in this role
Azure
Storage firewall≈ Partial equivalent
public_network_access = Disabled
For an organization-level perimeter across several PaaS services, see Network Security Perimeter.
Coming from AWS: there this role is Bucket policy.
Coming from GCP: there this role is Bucket IP filtering (optional).
No fine print in this role.
Amazon Web Services
Implementation
- Secretless identity
- IAM Role (ECS task role)s3:PutObject scoped to bucket/incoming/*Coming from Azure: there this role is User-assigned Managed Identity.Coming from GCP: there this role is Service Account attached to the workload.
- Private access
- S3 Gateway VPC Endpoint≈attached to the private route tableIt's a route-table entry, not a private IP: it isn't reachable from on-premises or another region (that takes an Interface Endpoint).Coming from Azure: there this role is Private Endpoint (subresource blob). It isn't a Private Endpoint: it's a free route-table entry that isn't reachable from on-premises or another region. The real equivalent is an Interface Endpoint, billed hourly.Coming from GCP: there this role is Private Google Access.
- Object storage
- S3 bucketBlock Public Access + BucketOwnerEnforced + SSE-KMSComing from Azure: there this role is Storage Account (StorageV2) + container.Coming from GCP: there this role is Cloud Storage bucket.
- Expiration policy
- S3 Lifecycle ruleExpiration.Days + AbortIncompleteMultipartUploadComing from Azure: there this role is Lifecycle management policy. The filter is just the prefix inside the bucket, and billing stops at the expiration date even if physical deletion takes days.Coming from GCP: there this role is Object Lifecycle Management.
- Data perimeter
- Bucket policyDeny if aws:SourceVpce ≠ endpointComing from Azure: there this role is Storage firewall.Coming from GCP: there this role is Bucket IP filtering (optional).
Cost model
Scales to zero
No fixed cost
No fixed cost: the Gateway Endpoint is free and the AWS-managed KMS key (aws/s3) has no monthly fee; Bucket Keys reduce the KMS calls that are billed per request.
Monthly floor with no traffic
USD 0/ mo
USD 0: no components with a fixed cost.
Fine print · 6 gotchas
Block Public Access and disabled ACLs are default on new buckets (but declare them anyway)
By default, Object Ownership is BucketOwnerEnforced and all ACLs are disabled. AWS announced that, starting in April 2023, S3 would enable Block Public Access and disable ACLs on all new buckets, with no change for existing ones. Still declare them in IaC: declaring the state prevents drift if someone edits them in the console.
A Deny on aws:SourceVpce locks you out too
An explicit Deny beats any Allow, including the admin's. If the policy denies all s3:* outside the endpoint, the console and the CI pipeline (outside the VPC) lose access, and you can end up unable to edit the bucket policy itself. This module limits the Deny to object operations and lets you exclude break-glass principals via aws:PrincipalArn.
The Gateway Endpoint is free, but only works within the VPC and region
It's a route table entry with no hourly or per-GB charge, which is why it's the default for S3. But it's not reachable from on-premises (VPN/Direct Connect) or from another region. For those cases you need an Interface Endpoint, which is billed hourly per AZ and is the real equivalent of Azure Private Endpoint.
Expiration is asynchronous, but billing stops at the expiration date
S3 queues expirations and processes them asynchronously: anywhere from days to weeks can pass between the expiration date and the actual deletion. You're not charged for the expiration or for the storage time of an object that has already expired.
On a versioned bucket, Expiration deletes nothing
With versioning on, the Expiration action only adds a delete marker and the version becomes noncurrent, which is still billed. You also need NoncurrentVersionExpiration (and to clean orphaned delete markers). This pattern leaves the bucket unversioned on purpose: the TTL is the desired behavior.
Incomplete multipart uploads keep being billed
S3 keeps the parts of a multipart upload until you complete or stop it, and their storage is billed the whole time. Always add an AbortIncompleteMultipartUpload rule: aborting the upload deletes the parts.
Terraform
main.tf
# Ingesta privada de objetos con expiración (TTL) — AWS
#
# Bucket S3 privado, cifrado con SSE-KMS, accesible por Gateway Endpoint,
# con escritura acotada a un prefijo y expiración a los N días.
# --- Red ----------------------------------------------------------------------
resource "aws_vpc" "this" {
#checkov:skip=CKV2_AWS_11:Flow logs fuera de alcance del patrón (se cubren en el patrón de networking base).
cidr_block = var.vpc_cidr
enable_dns_support = true
enable_dns_hostnames = true
tags = { Name = "vpc-${var.name}" }
}
# Sin reglas: nada usa el SG default (CIS 5.4).
resource "aws_default_security_group" "this" {
vpc_id = aws_vpc.this.id
}
resource "aws_route_table" "private" {
vpc_id = aws_vpc.this.id
tags = { Name = "rt-${var.name}-private" }
}
# Gateway endpoint: entrada en la route table, sin costo por hora ni por GB
# (gotcha: gateway-endpoint-scope).
resource "aws_vpc_endpoint" "s3" {
vpc_id = aws_vpc.this.id
service_name = "com.amazonaws.${var.region}.s3"
vpc_endpoint_type = "Gateway"
route_table_ids = [aws_route_table.private.id]
tags = { Name = "vpce-${var.name}-s3" }
}
# --- Bucket -------------------------------------------------------------------
resource "aws_s3_bucket" "ingest" {
#checkov:skip=CKV_AWS_21:Sin versioning a propósito: con versioning, Expiration solo agrega un delete marker (gotcha: versioning-delete-marker).
#checkov:skip=CKV_AWS_144:Replicación cross-region no aplica a datos efímeros con TTL.
#checkov:skip=CKV_AWS_18:Access logging fuera de alcance; usar CloudTrail data events si se requiere auditoría.
#checkov:skip=CKV2_AWS_62:Notificaciones de eventos fuera de alcance del patrón.
bucket = var.bucket_name
force_destroy = false
}
# Default desde abril 2023 en buckets nuevos; se declara para evitar drift
# (gotcha: secure-defaults-since-2023).
resource "aws_s3_bucket_public_access_block" "ingest" {
bucket = aws_s3_bucket.ingest.id
block_public_acls = true
block_public_policy = true
ignore_public_acls = true
restrict_public_buckets = true
}
resource "aws_s3_bucket_ownership_controls" "ingest" {
bucket = aws_s3_bucket.ingest.id
rule {
object_ownership = "BucketOwnerEnforced"
}
}
resource "aws_s3_bucket_server_side_encryption_configuration" "ingest" {
#checkov:skip=CKV2_AWS_67:Clave administrada por AWS (aws/s3): sin rotación manual que gestionar ni costo mensual.
bucket = aws_s3_bucket.ingest.id
rule {
apply_server_side_encryption_by_default {
sse_algorithm = "aws:kms"
}
# Bucket Keys: menos llamadas a KMS (que se cobran por request).
bucket_key_enabled = true
}
}
# --- Expiración (TTL) ---------------------------------------------------------
resource "aws_s3_bucket_lifecycle_configuration" "ingest" {
bucket = aws_s3_bucket.ingest.id
rule {
id = "expire-incoming"
status = "Enabled"
filter {
prefix = var.object_prefix
}
expiration {
days = var.ttl_days
}
}
# Las partes huérfanas se cobran y no se listan (gotcha: abort-incomplete-multipart).
rule {
id = "abort-incomplete-multipart"
status = "Enabled"
filter {}
abort_incomplete_multipart_upload {
days_after_initiation = 1
}
}
}
# --- Perímetro: solo TLS y solo desde el VPC endpoint -------------------------
locals {
object_arns = "${aws_s3_bucket.ingest.arn}/*"
# Excluye principals de break-glass del Deny por endpoint. Si la lista está vacía
# no se agrega la condición (un ArnNotLike vacío no excluye a nadie).
vpce_deny_condition = merge(
{ StringNotEquals = { "aws:SourceVpce" = aws_vpc_endpoint.s3.id } },
length(var.break_glass_principal_arns) > 0 ? { ArnNotLike = { "aws:PrincipalArn" = var.break_glass_principal_arns } } : {},
)
}
resource "aws_s3_bucket_policy" "ingest" {
bucket = aws_s3_bucket.ingest.id
# La public access block tiene que existir antes que la policy.
depends_on = [aws_s3_bucket_public_access_block.ingest]
policy = jsonencode({
Version = "2012-10-17"
Statement = [
{
Sid = "DenyInsecureTransport"
Effect = "Deny"
Principal = "*"
Action = "s3:*"
Resource = [aws_s3_bucket.ingest.arn, local.object_arns]
Condition = { Bool = { "aws:SecureTransport" = "false" } }
},
{
# Solo operaciones de objetos: la configuración del bucket sigue siendo
# gestionable desde fuera de la VPC (gotcha: sourcevpce-lockout).
Sid = "DenyObjectAccessOutsideVpce"
Effect = "Deny"
Principal = "*"
Action = ["s3:GetObject", "s3:PutObject", "s3:DeleteObject"]
Resource = local.object_arns
Condition = local.vpce_deny_condition
},
]
})
}
# --- Identidad del productor --------------------------------------------------
resource "aws_iam_role" "producer" {
name = "${var.name}-producer"
# Ejemplo con ECS: cambiá el principal según dónde corra el workload
# (ec2.amazonaws.com, lambda.amazonaws.com, o un OIDC provider para EKS).
assume_role_policy = jsonencode({
Version = "2012-10-17"
Statement = [{
Effect = "Allow"
Principal = { Service = "ecs-tasks.amazonaws.com" }
Action = "sts:AssumeRole"
}]
})
}
resource "aws_iam_role_policy" "producer_put" {
name = "put-incoming-objects"
role = aws_iam_role.producer.id
# Acotado al prefijo: el equivalente de Azure necesita ABAC (gotcha: rbac-scope-is-container).
policy = jsonencode({
Version = "2012-10-17"
Statement = [{
Effect = "Allow"
Action = ["s3:PutObject"]
Resource = "${aws_s3_bucket.ingest.arn}/${var.object_prefix}*"
}]
})
}variables.tf
variable "name" {
description = "Prefijo para nombrar los recursos (ej. \"ingest-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 "bucket_name" {
description = "Nombre global único del bucket S3."
type = string
validation {
condition = can(regex("^[a-z0-9][a-z0-9.-]{1,61}[a-z0-9]$", var.bucket_name))
error_message = "bucket_name: 3-63 caracteres, minúsculas, números, puntos o guiones."
}
}
variable "region" {
description = "Región de AWS."
type = string
default = "us-east-1"
}
variable "ttl_days" {
description = "Días desde la creación tras los cuales expira el objeto."
type = number
default = 7
validation {
condition = var.ttl_days >= 1 && floor(var.ttl_days) == var.ttl_days
error_message = "ttl_days debe ser un entero >= 1."
}
}
variable "object_prefix" {
description = "Prefijo de las keys a las que aplica el TTL (en S3 el prefijo NO incluye el bucket)."
type = string
default = "incoming/"
}
variable "vpc_cidr" {
description = "CIDR de la VPC del workload."
type = string
default = "10.20.0.0/16"
}
variable "break_glass_principal_arns" {
description = "ARNs (admite comodines) excluidos del Deny por VPC endpoint: consola, CI. Gotcha: sourcevpce-lockout."
type = list(string)
default = []
}
variable "tags" {
description = "Tags aplicados a todos los recursos."
type = map(string)
default = {}
}outputs.tf
output "bucket_arn" {
description = "ARN del bucket."
value = aws_s3_bucket.ingest.arn
}
output "vpc_endpoint_id" {
description = "ID del Gateway Endpoint (usado en la condición aws:SourceVpce)."
value = aws_vpc_endpoint.s3.id
}
output "producer_role_arn" {
description = "ARN del rol que tiene que asumir el workload productor."
value = aws_iam_role.producer.arn
}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
}
}Microsoft Azure
Implementation
- Secretless identity
- User-assigned Managed IdentityStorage Blob Data Contributor on the containerComing from AWS: there this role is IAM Role (ECS task role). The smallest role assignment scope is the container, not a prefix as in an S3 policy: scoping by path needs an ABAC condition.Coming from GCP: there this role is Service Account attached to the workload.
- Private access
- Private Endpoint (subresource blob)+ privatelink.blob.core.windows.net zoneComing from AWS: there this role is S3 Gateway VPC Endpoint. Besides the endpoint you need the privatelink.blob.core.windows.net zone linked to the VNet; without it the public IP resolves and the request gets a 403.Coming from GCP: there this role is Private Google Access.
- Object storage
- Storage Account (StorageV2) + containerShared Key disabled, public network access disabledComing from AWS: there this role is S3 bucket.Coming from GCP: there this role is Cloud Storage bucket.
- Expiration policy
- Lifecycle management policydelete_after_days_since_creation_greater_thanComing from AWS: there this role is S3 Lifecycle rule. The filter prefix includes the container name: an "incoming/" copied from S3 matches nothing and raises no error.Coming from GCP: there this role is Object Lifecycle Management. The filter prefix includes the container name, and with soft delete on, deletion doesn't free capacity until retention expires.
- Data perimeter
- Storage firewall≈public_network_access = DisabledFor an organization-level perimeter across several PaaS services, see Network Security Perimeter.Coming from AWS: there this role is Bucket policy.Coming from GCP: there this role is Bucket IP filtering (optional).
Cost model
Partially scales to zero
Fixed cost even with zero traffic:
- Private Endpoint (hourly charge, plus processed GB)
- Private DNS zone (monthly charge per zone)
Storage scales to zero (you pay GB and operations). The fixed cost comes from private networking. That's the main difference with AWS, where the S3 Gateway Endpoint is free.
Monthly floor with no traffic
USD 7.80/ mo
With the Terraform module’s default configuration. Good for comparing orders of magnitude, not for budgeting.
- Private Endpoint × 1 · 0.01 USD/hUSD 7.30
- Private DNS zone × 1 · 0.5 USD/moUSD 0.50
List price in eastus as of 2026-10-24, in USD, with no discounts or taxes.
Fine print · 6 gotchas
Disabling Shared Key forces Terraform to use Entra ID
With shared_access_key_enabled = false, every data-plane call must authenticate with Entra ID, including the provider's own. Set storage_use_azuread = true on the azurerm provider and grant the pipeline identity a data role (Contributor is control plane only and is not enough).
Role assignments are per account or container; by prefix you need ABAC conditions
Azure Storage supports role assignments for both storage accounts and blob containers. To scope by prefix or path you need ABAC conditions on the role assignment (Blob prefix and Blob path attributes). Simple alternative: a container dedicated to ingestion (what this module does).
Private Endpoint without a private DNS zone: the service firewall can answer 403
Creating the Private Endpoint is not enough: the FQDN <account>.blob.core.windows.net must resolve to the private IP via the privatelink.blob.core.windows.net zone linked to the VNet (or your hybrid DNS). If it resolves to the public IP and the account has a firewall, it rejects the request with 403 and looks like a permissions issue.
With soft delete on, "delete" does not free capacity
If blob soft delete is on, the lifecycle delete action leaves the blob soft-deleted for the retention period, and you keep paying for it. Your effective cost TTL is ttl_days + soft-delete days. The module exposes it as a variable so it's an explicit decision.
The policy takes up to 24 h to apply and runs in the background
A new or edited lifecycle policy can take up to 24 h to take effect, and each run can take more than a day on large accounts. Don't use lifecycle as a security control ("the file is gone at 48 h"): if the requirement is strict, delete from the application or run your own job.
In Azure the filter prefix includes the container name
prefix_match is "container/path", not just the path. An "incoming/" filter that works in S3 or Cloud Storage matches a container named "incoming" in Azure and never deletes anything. The failure is silent: the policy is created without error.
Terraform
main.tf
# Ingesta privada de objetos con expiración (TTL) — Azure
#
# Storage Account sin claves (solo Entra ID), sin acceso público de red,
# alcanzable por Private Endpoint, con un contenedor de ingesta cuyo
# contenido se borra a los N días.
resource "azurerm_resource_group" "this" {
name = "rg-${var.name}"
location = var.location
tags = var.tags
}
# --- Red ----------------------------------------------------------------------
resource "azurerm_virtual_network" "this" {
name = "vnet-${var.name}"
resource_group_name = azurerm_resource_group.this.name
location = azurerm_resource_group.this.location
address_space = [var.vnet_address_space]
tags = var.tags
}
resource "azurerm_subnet" "endpoints" {
name = "snet-private-endpoints"
resource_group_name = azurerm_resource_group.this.name
virtual_network_name = azurerm_virtual_network.this.name
address_prefixes = [var.endpoints_subnet_prefix]
}
# Sin reglas propias: las default permiten tráfico intra-VNet y bloquean internet.
# Para que el NSG filtre tráfico hacia Private Endpoints hay que habilitar
# network policies en la subnet (private_endpoint_network_policies).
resource "azurerm_network_security_group" "endpoints" {
name = "nsg-${var.name}-endpoints"
resource_group_name = azurerm_resource_group.this.name
location = azurerm_resource_group.this.location
tags = var.tags
}
resource "azurerm_subnet_network_security_group_association" "endpoints" {
subnet_id = azurerm_subnet.endpoints.id
network_security_group_id = azurerm_network_security_group.endpoints.id
}
# --- Storage ------------------------------------------------------------------
resource "azurerm_storage_account" "this" {
#checkov:skip=CKV2_AZURE_1:CMK fuera de alcance del patrón; cifrado con claves de Microsoft + infraestructura.
#checkov:skip=CKV_AZURE_206:ZRS es suficiente para datos efímeros con TTL; GRS duplica costo sin beneficio.
#checkov:skip=CKV_AZURE_33:No se usa Queue Storage en este patrón.
#checkov:skip=CKV_AZURE_59:checkov evalúa public_network_access_enabled (deprecado en azurerm 5); acá se usa public_network_access = "Disabled".
#checkov:skip=CKV_AZURE_36:Sin bypass de servicios de confianza a propósito: el único camino es el Private Endpoint.
name = var.storage_account_name
resource_group_name = azurerm_resource_group.this.name
location = azurerm_resource_group.this.location
account_kind = "StorageV2"
account_tier = "Standard"
account_replication_type = "ZRS"
# Identidad: solo Entra ID. Sin Shared Key, sin SAS de cuenta, sin usuarios locales.
shared_access_key_enabled = false
default_to_oauth_authentication = true
local_user_enabled = false
# Red: sin endpoint público. El único camino es el Private Endpoint.
public_network_access = "Disabled"
allow_nested_items_to_be_public = false
cross_tenant_replication_enabled = false
https_traffic_only_enabled = true
min_tls_version = "TLS1_2"
infrastructure_encryption_enabled = true
network_rules {
default_action = "Deny"
bypass = ["None"]
}
blob_properties {
# Sin versioning: con versiones previas, lifecycle no borra la versión actual
# hasta borrar las anteriores, y el TTL deja de ser predecible.
versioning_enabled = false
delete_retention_policy {
days = var.soft_delete_retention_days
}
container_delete_retention_policy {
days = 7
}
}
tags = var.tags
}
resource "azurerm_storage_container" "ingest" {
#checkov:skip=CKV2_AZURE_21:Logging de lectura vía diagnostic settings, fuera de alcance del patrón.
# storage_account_id → se gestiona por Resource Manager (control plane),
# así que funciona con public_network_access = "Disabled".
name = "ingest"
storage_account_id = azurerm_storage_account.this.id
container_access_type = "private"
}
# --- Expiración (TTL) ---------------------------------------------------------
resource "azurerm_storage_management_policy" "ttl" {
storage_account_id = azurerm_storage_account.this.id
rule {
name = "expire-ingest"
enabled = true
filters {
blob_types = ["blockBlob"]
# El prefijo DEBE empezar con el nombre del contenedor
# (gotcha: prefix-includes-container).
prefix_match = ["${azurerm_storage_container.ingest.name}/${var.object_prefix}"]
}
actions {
base_blob {
delete_after_days_since_creation_greater_than = var.ttl_days
}
}
}
}
# --- Identidad del productor --------------------------------------------------
resource "azurerm_user_assigned_identity" "producer" {
name = "id-${var.name}-producer"
resource_group_name = azurerm_resource_group.this.name
location = azurerm_resource_group.this.location
tags = var.tags
}
resource "azurerm_role_assignment" "producer_blob_writer" {
# Scope mínimo posible sin ABAC: el contenedor (gotcha: rbac-scope-is-container).
scope = azurerm_storage_container.ingest.id
role_definition_name = "Storage Blob Data Contributor"
principal_id = azurerm_user_assigned_identity.producer.principal_id
principal_type = "ServicePrincipal"
}
# --- Private Endpoint + DNS ---------------------------------------------------
resource "azurerm_private_dns_zone" "blob" {
name = "privatelink.blob.core.windows.net"
resource_group_name = azurerm_resource_group.this.name
tags = var.tags
}
resource "azurerm_private_dns_zone_virtual_network_link" "blob" {
name = "link-${var.name}"
private_dns_zone_id = azurerm_private_dns_zone.blob.id
virtual_network_id = azurerm_virtual_network.this.id
registration_enabled = false
tags = var.tags
}
resource "azurerm_private_endpoint" "blob" {
name = "pe-${var.name}-blob"
resource_group_name = azurerm_resource_group.this.name
location = azurerm_resource_group.this.location
subnet_id = azurerm_subnet.endpoints.id
private_service_connection {
name = "psc-${var.name}-blob"
private_connection_resource_id = azurerm_storage_account.this.id
subresource_names = ["blob"]
is_manual_connection = false
}
# Sin esto el FQDN resuelve a la IP pública y la request falla con 403
# (gotcha: private-endpoint-needs-dns).
private_dns_zone_group {
name = "default"
private_dns_zone_ids = [azurerm_private_dns_zone.blob.id]
}
tags = var.tags
}variables.tf
variable "name" {
description = "Prefijo para nombrar los recursos (ej. \"ingest-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 "storage_account_name" {
description = "Nombre global único de la Storage Account (3-24, solo minúsculas y números)."
type = string
validation {
condition = can(regex("^[a-z0-9]{3,24}$", var.storage_account_name))
error_message = "storage_account_name: 3-24 caracteres, solo minúsculas y números."
}
}
variable "location" {
description = "Región de Azure."
type = string
default = "eastus2"
}
variable "ttl_days" {
description = "Días desde la creación tras los cuales se borra el blob."
type = number
default = 7
validation {
condition = var.ttl_days >= 1 && floor(var.ttl_days) == var.ttl_days
error_message = "ttl_days debe ser un entero >= 1."
}
}
variable "object_prefix" {
description = "Ruta dentro del contenedor a la que aplica el TTL (sin el nombre del contenedor)."
type = string
default = "incoming/"
}
variable "soft_delete_retention_days" {
description = "Retención de soft delete. El costo efectivo del TTL es ttl_days + este valor (gotcha: soft-delete-keeps-capacity)."
type = number
default = 7
validation {
condition = var.soft_delete_retention_days >= 1 && var.soft_delete_retention_days <= 365
error_message = "soft_delete_retention_days debe estar entre 1 y 365."
}
}
variable "vnet_address_space" {
description = "Espacio de direcciones de la VNet del workload."
type = string
default = "10.10.0.0/16"
}
variable "endpoints_subnet_prefix" {
description = "Prefijo de la subnet para Private Endpoints."
type = string
default = "10.10.1.0/24"
}
variable "tags" {
description = "Tags aplicados a todos los recursos."
type = map(string)
default = {}
}outputs.tf
output "storage_account_id" {
description = "ID de la Storage Account."
value = azurerm_storage_account.this.id
}
output "ingest_container_url" {
description = "URL del contenedor de ingesta (resuelve a IP privada desde la VNet)."
value = "${azurerm_storage_account.this.primary_blob_endpoint}${azurerm_storage_container.ingest.name}"
}
output "producer_identity_client_id" {
description = "Client ID de la Managed Identity que tiene que usar el workload productor."
value = azurerm_user_assigned_identity.producer.client_id
}versions.tf
terraform {
required_version = ">= 1.7.0"
required_providers {
azurerm = {
source = "hashicorp/azurerm"
version = "~> 5.8"
}
}
}
provider "azurerm" {
features {}
# Con shared_access_key_enabled = false el provider tiene que usar Entra ID
# para las llamadas de data plane (gotcha: shared-key-needs-azuread-provider).
storage_use_azuread = true
# subscription_id se toma de ARM_SUBSCRIPTION_ID.
}