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
GCP
Service Account attached to the workload
roles/storage.objectCreator with a prefix IAM Condition
Coming from Azure: there this role is User-assigned Managed Identity.
Coming from AWS: there this role is IAM Role (ECS task role).
1 gotcha in this role
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.
Private access
GCP
Private Google Access≈ Partial equivalent
subnet flag
Reaches 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.
1 gotcha in this role
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
Object storage
GCP
Cloud Storage bucket
Uniform bucket-level access + Public access prevention enforced
Coming 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.
2 gotchas in this role
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
Expiration policy
GCP
Object Lifecycle Management
Delete by age + AbortIncompleteMultipartUpload
Coming from Azure: there this role is Lifecycle management policy.
Coming from AWS: there this role is S3 Lifecycle rule.
2 gotchas in this role
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
Data perimeter
GCP
Bucket IP filtering (optional)≈ Partial equivalent
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.
1 gotcha in this role
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
Google Cloud
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
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).
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.
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.
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.
Lifecycle changes can take up to 24 h and actions are asynchronous
Same as Azure and S3, it isn't an exact TTL: an object meeting the condition may still exist for a while. If you need a deletion guarantee, do it from the application.
Terraform
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
}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
}
}