ESEN

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.

Microsoft Azure

Private object ingestion with expiration (TTL) — Azuregets tokenPUT objectdeletes after N daysrestricts originProducer workloadSecretless identityUser-assigned ManagedIdentity2 gotchas in this rolePrivate accessPrivate Endpoint(subresource blob)1 gotcha in this roleObject storageStorage Account(StorageV2) +container2 gotchas in this roleExpiration policyLifecycle managementpolicy3 gotchas in this roleData perimeter ≈Storage firewall

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.

List price in eastus as of 2026-10-24, in USD, with no discounts or taxes.

Fine print · 6 gotchas

IAM● verified 34 days ago◌ no external reviewer

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).

⚑ Report outdated
IAM● verified 34 days ago◌ no external reviewer

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).

⚑ Report outdated
Networking● verified 34 days ago◌ no external reviewer

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.

⚑ Report outdated
Cost● verified 34 days ago◌ no external reviewer

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.

⚑ Report outdated
Reliability● verified 34 days ago◌ no external reviewer

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.

⚑ Report outdated

Terraform

azurerm ~> 5.8✓ fmt · validate · tflint · checkov · terraform testGitHub ↗
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.
}

Google Cloud

Private object ingestion with expiration (TTL) — GCPgets tokenPUT objectdeletes after N daysrestricts originProducer workloadSecretless identityService Accountattached to theworkload1 gotcha in this rolePrivate access ≈Private Google Access1 gotcha in this roleObject storageCloud Storage bucket2 gotchas in this roleExpiration policyObject LifecycleManagement2 gotchas in this roleData perimeter ≈Bucket IP filtering(optional)1 gotcha in this role

Implementation

Secretless identity
Service Account attached to the workloadroles/storage.objectCreator with a prefix IAM ConditionComing from Azure: there this role is User-assigned Managed Identity.Coming from AWS: there this role is IAM Role (ECS task role).
Private access
Private Google Access≈subnet flagReaches Google APIs without a public IP, but it isn't a private endpoint for the bucket and doesn't restrict who reaches it.Coming from Azure: there this role is Private Endpoint (subresource blob). It isn't a Private Endpoint: the bucket gets no private IP. Restricting the origin takes bucket IP filtering or VPC Service Controls.Coming from AWS: there this role is S3 Gateway VPC Endpoint.
Object storage
Cloud Storage bucketUniform bucket-level access + Public access prevention enforcedComing from Azure: there this role is Storage Account (StorageV2) + container. Soft delete is on by default (7 days): an ephemeral object is billed for an extra week unless you set it to 0.Coming from AWS: there this role is S3 bucket. Soft delete is on by default (7 days): an ephemeral object is billed for an extra week unless you set it to 0.
Expiration policy
Object Lifecycle ManagementDelete by age + AbortIncompleteMultipartUploadComing from Azure: there this role is Lifecycle management policy.Coming from AWS: there this role is S3 Lifecycle rule.
Data perimeter
Bucket IP filtering (optional)≈The full perimeter is VPC Service Controls, configured at organization level.Coming from Azure: there this role is Storage firewall.Coming from AWS: there this role is Bucket policy.

Cost model

Scales to zero

No fixed cost

No fixed cost: Private Google Access is free. Watch out for soft delete (see gotcha): it's this pattern's hidden cost on GCP.

Monthly floor with no traffic

USD 0/ mo

USD 0: no components with a fixed cost.

Fine print · 5 gotchas

Security● verified 34 days ago◌ no external reviewer

Uniform bucket-level access + public access prevention is the pair to declare

UBLA disables per-object ACLs (equivalent to BucketOwnerEnforced in S3) and is required for IAM Conditions. Public access prevention "enforced" blocks allUsers/allAuthenticatedUsers (equivalent to Block Public Access).

⚑ Report outdated
IAM● verified 34 days ago◌ no external reviewer

roles/storage.objectCreator is not enough to overwrite

Overwriting an existing object requires storage.objects.delete, which objectCreator lacks. For ingestion that's a feature (a compromised producer can't clobber files), but it breaks clients that retry with the same name. In S3, s3:PutObject does overwrite.

⚑ Report outdated
Networking● verified 34 days ago◌ no external reviewer

Private Google Access gives reach to Google APIs; filtering the bucket's origin is another control

VM instances that only have internal IP addresses can use Private Google Access to reach Google APIs and services. That solves reachability, not origin filtering. Bucket IP filtering lets you restrict access to the bucket based on client IP ranges and VPC networks, and works alongside IAM.

⚑ Report outdated
Cost● verified 34 days ago◌ no external reviewer

Soft delete is on by default (7 days) and makes ephemeral data more expensive

Every bucket has soft delete with 7-day retention unless you change it. An object that lives one hour is billed for that hour plus 7 days of soft delete. In a TTL ingestion bucket that can multiply cost. The module sets it to 0 (disabled) by default and exposes it as an explicit variable.

⚑ Report outdated

Terraform

google ~> 8.6✓ fmt · validate · tflint · checkov · terraform testGitHub ↗
main.tf
# Ingesta privada de objetos con expiración (TTL) — GCP
#
# Bucket con UBLA + public access prevention, soft delete explícito,
# acceso desde una subnet con Private Google Access, escritura acotada
# a un prefijo con IAM Conditions y borrado a los N días.

# --- Red ----------------------------------------------------------------------

resource "google_compute_network" "this" {
  name                    = "vpc-${var.name}"
  auto_create_subnetworks = false
}

# Denegar todo ingress explícitamente (las VPCs custom no traen reglas,
# pero declararlo deja la intención en el código).
resource "google_compute_firewall" "deny_all_ingress" {
  name      = "fw-${var.name}-deny-all-ingress"
  network   = google_compute_network.this.id
  direction = "INGRESS"
  priority  = 65534

  deny {
    protocol = "all"
  }

  source_ranges = ["0.0.0.0/0"]
}

resource "google_compute_subnetwork" "workloads" {
  name          = "snet-${var.name}-workloads"
  region        = var.region
  network       = google_compute_network.this.id
  ip_cidr_range = var.subnet_cidr

  # VMs/servicios sin IP pública llegan a storage.googleapis.com sin NAT.
  # No restringe quién accede al bucket (gotcha: pga-is-not-a-perimeter).
  private_ip_google_access = true

  log_config {
    aggregation_interval = "INTERVAL_10_MIN"
    flow_sampling        = 0.5
    metadata             = "INCLUDE_ALL_METADATA"
  }
}

# --- Bucket -------------------------------------------------------------------

resource "google_storage_bucket" "ingest" {
  #checkov:skip=CKV_GCP_62:Access logs fuera de alcance; usar Cloud Audit Logs (Data Access) si se requiere auditoría.
  #checkov:skip=CKV_GCP_78:Sin versioning a propósito: el TTL es el comportamiento buscado.
  name          = var.bucket_name
  location      = upper(var.region)
  storage_class = "STANDARD"
  force_destroy = false
  labels        = var.labels

  # Equivalente a BucketOwnerEnforced + Block Public Access (gotcha: ubla-and-pap).
  uniform_bucket_level_access = true
  public_access_prevention    = "enforced"

  versioning {
    enabled = false
  }

  # Default de GCS: 7 días. Acá es una decisión explícita (gotcha: soft-delete-default-7-days).
  soft_delete_policy {
    retention_duration_seconds = var.soft_delete_retention_seconds
  }

  lifecycle_rule {
    condition {
      age            = var.ttl_days
      matches_prefix = [var.object_prefix]
    }
    action {
      type = "Delete"
    }
  }

  lifecycle_rule {
    condition {
      age = 1
    }
    action {
      type = "AbortIncompleteMultipartUpload"
    }
  }

  dynamic "ip_filter" {
    for_each = var.restrict_to_vpc ? [1] : []
    content {
      mode = "Enabled"
      vpc_network_sources {
        network                = google_compute_network.this.id
        allowed_ip_cidr_ranges = [var.subnet_cidr]
      }
    }
  }
}

# --- Identidad del productor --------------------------------------------------

resource "google_service_account" "producer" {
  account_id   = "${var.name}-producer"
  display_name = "Productor de ingesta (${var.name})"
}

resource "google_storage_bucket_iam_member" "producer_create" {
  bucket = google_storage_bucket.ingest.name
  # objectCreator no puede sobrescribir (gotcha: objectcreator-cannot-overwrite).
  role   = "roles/storage.objectCreator"
  member = google_service_account.producer.member

  # Acotado al prefijo, como en S3. Requiere UBLA.
  condition {
    title       = "only-${trimsuffix(var.object_prefix, "/")}"
    description = "Solo objetos bajo ${var.object_prefix}"
    expression  = "resource.name.startsWith(\"projects/_/buckets/${var.bucket_name}/objects/${var.object_prefix}\")"
  }
}
variables.tf
variable "project_id" {
  description = "ID del proyecto de GCP."
  type        = string
}

variable "name" {
  description = "Prefijo para nombrar los recursos (ej. \"ingest-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 "bucket_name" {
  description = "Nombre global único del bucket."
  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, guiones o guiones bajos."
  }
}

variable "region" {
  description = "Región de GCP (el bucket se crea regional en la misma región)."
  type        = string
  default     = "us-central1"
}

variable "ttl_days" {
  description = "Edad en días tras la cual se borra 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 los objetos a los que aplica el TTL (en GCS el prefijo NO incluye el bucket)."
  type        = string
  default     = "incoming/"
}

variable "soft_delete_retention_seconds" {
  description = "Retención de soft delete. 0 lo deshabilita; el default de GCS es 7 días (gotcha: soft-delete-default-7-days)."
  type        = number
  default     = 0

  validation {
    condition     = var.soft_delete_retention_seconds == 0 || (var.soft_delete_retention_seconds >= 604800 && var.soft_delete_retention_seconds <= 7776000)
    error_message = "soft_delete_retention_seconds: 0 (deshabilitado) o entre 7 y 90 días (604800-7776000)."
  }
}

variable "subnet_cidr" {
  description = "CIDR de la subnet del workload."
  type        = string
  default     = "10.30.0.0/24"
}

variable "restrict_to_vpc" {
  description = "Activa bucket IP filtering para aceptar requests solo desde la VPC. Puede dejar afuera al pipeline si corre fuera de la VPC."
  type        = bool
  default     = false
}

variable "labels" {
  description = "Labels aplicados a los recursos que los soportan."
  type        = map(string)
  default     = {}
}
outputs.tf
output "bucket_url" {
  description = "URL gs:// del bucket."
  value       = google_storage_bucket.ingest.url
}

output "producer_service_account_email" {
  description = "Service Account que tiene que usar el workload productor."
  value       = google_service_account.producer.email
}
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
}