OCI Object Storage Lifecycle Policies and Multipart Uploads: Production Data Management with Terraform

Object Storage on OCI is often treated as a simple file dump. Buckets get created, data lands in them, and nobody thinks about it again until the storage bill arrives or a compliance audit reveals that logs from three years ago are still sitting in standard storage at full price when they could have been archived or deleted months ago.

OCI Object Storage has three storage tiers, lifecycle policies that automate transitions and deletions, multipart upload for large objects, pre-authenticated requests for temporary access, and event-driven integration with OCI Functions and Streaming. Used properly, it is a complete data management platform, not just a file store. This post covers the full production configuration using Terraform.

Storage Tiers

OCI Object Storage has three tiers that differ in retrieval speed and cost.

Standard tier provides immediate access with no retrieval fee. Use it for active data that applications read and write regularly: application artifacts, current logs, configuration files, recent backups.

Infrequent Access tier costs less for storage but adds a retrieval fee and a minimum storage duration of 31 days. Use it for data you access occasionally: monthly reports, older backups, audit logs from the last 90 days.

Archive tier has the lowest storage cost but requires a restore operation before the data can be read, which takes up to an hour. Use it for compliance data, historical records, and anything you hope never to need but must retain for regulatory reasons.

Step 1: Bucket Configuration with Versioning and Encryption

resource "oci_objectstorage_bucket" "app_data" {
  compartment_id = var.compartment_id
  namespace      = data.oci_objectstorage_namespace.ns.namespace
  name           = "production-app-data"
  access_type    = "NoPublicAccess"
  storage_tier   = "Standard"
  versioning     = "Enabled"

  kms_key_id = var.vault_key_id

  metadata = {
    environment = "production"
    application = "orders-api"
    managed-by  = "terraform"
  }
}

resource "oci_objectstorage_bucket" "app_archive" {
  compartment_id = var.compartment_id
  namespace      = data.oci_objectstorage_namespace.ns.namespace
  name           = "production-app-archive"
  access_type    = "NoPublicAccess"
  storage_tier   = "Archive"

  kms_key_id = var.vault_key_id
}

data "oci_objectstorage_namespace" "ns" {
  compartment_id = var.compartment_id
}

output "bucket_namespace" {
  value = data.oci_objectstorage_namespace.ns.namespace
}

Versioning on the standard bucket retains previous versions of objects when they are overwritten or deleted. This protects against accidental deletion and application bugs that corrupt data. Each version is stored as a separate object and billed at the same rate. Configure lifecycle policies to delete old versions automatically.

Step 2: Lifecycle Policies

Lifecycle rules automate tier transitions and deletions based on object age and prefix. This is where the cost control lives.

resource "oci_objectstorage_object_lifecycle_policy" "app_data_lifecycle" {
  namespace = data.oci_objectstorage_namespace.ns.namespace
  bucket    = oci_objectstorage_bucket.app_data.name

  rules {
    name        = "move-logs-to-infrequent-access"
    action      = "INFREQUENT_ACCESS"
    is_enabled  = true
    time_amount = 30
    time_unit   = "DAYS"

    object_name_filter {
      inclusion_prefixes = ["logs/"]
    }
  }

  rules {
    name        = "archive-logs-after-90-days"
    action      = "ARCHIVE"
    is_enabled  = true
    time_amount = 90
    time_unit   = "DAYS"

    object_name_filter {
      inclusion_prefixes = ["logs/"]
    }
  }

  rules {
    name        = "delete-archived-logs-after-2-years"
    action      = "DELETE"
    is_enabled  = true
    time_amount = 730
    time_unit   = "DAYS"

    object_name_filter {
      inclusion_prefixes = ["logs/"]
    }
  }

  rules {
    name        = "delete-old-object-versions"
    action      = "DELETE"
    is_enabled  = true
    time_amount = 90
    time_unit   = "DAYS"
    target      = "previousObjectVersions"
  }

  rules {
    name        = "abort-incomplete-multipart-uploads"
    action      = "ABORT"
    is_enabled  = true
    time_amount = 7
    time_unit   = "DAYS"
    target      = "multipartUploads"
  }

  rules {
    name        = "archive-backups-after-30-days"
    action      = "ARCHIVE"
    is_enabled  = true
    time_amount = 30
    time_unit   = "DAYS"

    object_name_filter {
      inclusion_prefixes = ["backups/"]
    }
  }

  rules {
    name        = "delete-temp-files"
    action      = "DELETE"
    is_enabled  = true
    time_amount = 1
    time_unit   = "DAYS"

    object_name_filter {
      inclusion_prefixes = ["tmp/", "staging/"]
    }
  }
}

The abort-incomplete-multipart-uploads rule is critical and frequently forgotten. When a multipart upload is initiated but never completed, the uploaded parts accumulate in storage and are billed at the standard rate indefinitely. Setting this rule to 7 days cleans up any abandoned uploads automatically.

The delete-old-object-versions rule with target = "previousObjectVersions" removes old versions of versioned objects after 90 days. Without this, versioning causes storage to grow without bound as every write creates a new version.

Step 3: Multipart Upload for Large Objects

Objects larger than 100MB should use multipart upload. It breaks the object into parts, uploads them in parallel, and assembles them on the server side. This is faster than a single-stream upload, recoverable on failure (only the failed part needs to be retried), and required for objects over 5GB.

import oci
import os
import math
import logging
import concurrent.futures
from typing import List

logger = logging.getLogger(__name__)

class MultipartUploader:
    PART_SIZE_BYTES = 128 * 1024 * 1024  # 128 MB per part
    MAX_WORKERS     = 4

    def __init__(self, namespace: str, bucket: str):
        self.namespace = namespace
        self.bucket    = bucket
        config         = oci.config.from_file()
        self.client    = oci.object_storage.ObjectStorageClient(config)

    def upload(self, object_name: str, file_path: str) -> str:
        file_size = os.path.getsize(file_path)
        num_parts = math.ceil(file_size / self.PART_SIZE_BYTES)

        logger.info(f"Starting multipart upload: {object_name}, size: {file_size}, parts: {num_parts}")

        # Initiate the multipart upload
        response = self.client.create_multipart_upload(
            namespace_name=self.namespace,
            bucket_name=self.bucket,
            create_multipart_upload_details=oci.object_storage.models.CreateMultipartUploadDetails(
                object=object_name,
                storage_tier="Standard"
            )
        )
        upload_id = response.data.upload_id
        logger.info(f"Multipart upload initiated, upload_id: {upload_id}")

        try:
            part_etags = self._upload_parts(
                file_path, upload_id, object_name, num_parts
            )

            # Commit the upload
            self.client.commit_multipart_upload(
                namespace_name=self.namespace,
                bucket_name=self.bucket,
                object_name=object_name,
                upload_id=upload_id,
                commit_multipart_upload_details=oci.object_storage.models.CommitMultipartUploadDetails(
                    parts_to_commit=[
                        oci.object_storage.models.CommitMultipartUploadPartDetails(
                            part_num=i + 1,
                            etag=etag
                        )
                        for i, etag in enumerate(part_etags)
                    ]
                )
            )

            logger.info(f"Multipart upload committed: {object_name}")
            return upload_id

        except Exception as e:
            logger.error(f"Upload failed, aborting: {e}")
            self.client.abort_multipart_upload(
                namespace_name=self.namespace,
                bucket_name=self.bucket,
                object_name=object_name,
                upload_id=upload_id
            )
            raise

    def _upload_parts(self, file_path: str, upload_id: str, object_name: str, num_parts: int) -> List[str]:
        part_etags = [None] * num_parts

        def upload_part(part_number: int):
            offset = (part_number - 1) * self.PART_SIZE_BYTES
            with open(file_path, 'rb') as f:
                f.seek(offset)
                part_data = f.read(self.PART_SIZE_BYTES)

            response = self.client.upload_part(
                namespace_name=self.namespace,
                bucket_name=self.bucket,
                object_name=object_name,
                upload_id=upload_id,
                upload_part_num=part_number,
                upload_part_body=part_data
            )
            etag = response.headers['ETag']
            logger.debug(f"Part {part_number}/{num_parts} uploaded, etag: {etag}")
            return part_number, etag

        with concurrent.futures.ThreadPoolExecutor(max_workers=self.MAX_WORKERS) as executor:
            futures = {
                executor.submit(upload_part, i + 1): i
                for i in range(num_parts)
            }
            for future in concurrent.futures.as_completed(futures):
                part_number, etag = future.result()
                part_etags[part_number - 1] = etag

        return part_etags

Step 4: Pre-Authenticated Requests

Pre-authenticated requests (PARs) provide time-limited access to specific objects or buckets without requiring OCI credentials. Use them to share files with external systems, give temporary upload access to third parties, or generate download links for end users.

resource "oci_objectstorage_preauthrequest" "report_download" {
  namespace    = data.oci_objectstorage_namespace.ns.namespace
  bucket       = oci_objectstorage_bucket.app_data.name
  name         = "quarterly-report-download"
  access_type  = "ObjectRead"
  object_name  = "reports/Q2-2026-summary.pdf"
  time_expires = "2026-08-01T00:00:00Z"
}

output "report_download_url" {
  value     = oci_objectstorage_preauthrequest.report_download.full_path
  sensitive = true
}

From Python, generate PARs dynamically with a short expiry for user-facing download links:

import oci
from datetime import datetime, timezone, timedelta

def generate_download_url(namespace: str, bucket: str, object_name: str, expires_in_hours: int = 1) -> str:
    config = oci.config.from_file()
    client = oci.object_storage.ObjectStorageClient(config)

    expiry = datetime.now(timezone.utc) + timedelta(hours=expires_in_hours)

    response = client.create_preauthenticated_request(
        namespace_name=namespace,
        bucket_name=bucket,
        create_preauthenticated_request_details=oci.object_storage.models.CreatePreauthenticatedRequestDetails(
            name=f"download-{object_name.replace('/', '-')}-{int(expiry.timestamp())}",
            object_name=object_name,
            access_type="ObjectRead",
            time_expires=expiry
        )
    )

    return response.data.full_path

Step 5: Event-Driven Processing with OCI Events

When objects land in a bucket, OCI Events can trigger downstream processing automatically. Connect object creation events to an OCI Function that validates, transforms, or routes the data.

resource "oci_events_rule" "object_created_rule" {
  compartment_id = var.compartment_id
  display_name   = "process-new-uploads"
  is_enabled     = true

  condition = jsonencode({
    eventType = ["com.oraclecloud.objectstorage.createobject"]
    data = {
      additionalDetails = {
        bucketName = [oci_objectstorage_bucket.app_data.name]
        namespaceName = [data.oci_objectstorage_namespace.ns.namespace]
      }
    }
  })

  actions {
    actions {
      action_type = "FAAS"
      is_enabled  = true
      function_id = var.processing_function_id
      description = "Trigger file processing function on new upload"
    }
  }
}

# Restrict the event rule to a specific prefix using condition filter
resource "oci_events_rule" "incoming_orders_rule" {
  compartment_id = var.compartment_id
  display_name   = "process-incoming-orders"
  is_enabled     = true

  condition = jsonencode({
    eventType = ["com.oraclecloud.objectstorage.createobject"]
    data = {
      resourceName = [{
        "condition": "LIKE",
        "value": "incoming/orders/%"
      }]
      additionalDetails = {
        bucketName = [oci_objectstorage_bucket.app_data.name]
      }
    }
  })

  actions {
    actions {
      action_type = "ONS"
      is_enabled  = true
      topic_id    = var.order_processing_topic_id
      description = "Notify order processing pipeline of new order file"
    }
  }
}

Step 6: Replication for Cross-Region DR

OCI Object Storage supports asynchronous replication to a bucket in another region. Objects written to the source bucket are copied to the destination bucket automatically. Use this for disaster recovery, compliance requirements that mandate geo-redundant storage, or serving downloads from a region closer to your users.

resource "oci_objectstorage_replication_policy" "cross_region_backup" {
  namespace      = data.oci_objectstorage_namespace.ns.namespace
  bucket         = oci_objectstorage_bucket.app_data.name
  name           = "cross-region-dr-replication"
  destination_bucket_name      = var.dr_bucket_name
  destination_region_name      = var.dr_region
}

Replication is asynchronous. Objects typically appear in the destination bucket within minutes, but there is no guaranteed RPO. For workloads with strict RPO requirements, pair replication with OCI Block Volume cross-region backups or Autonomous Database Data Guard.

Step 7: Access Logging and Monitoring

resource "oci_logging_log" "bucket_access_log" {
  display_name = "app-data-bucket-access"
  log_group_id = var.log_group_id
  log_type     = "SERVICE"

  configuration {
    source {
      category    = "write"
      resource    = oci_objectstorage_bucket.app_data.name
      service     = "objectstorage"
      source_type = "OCISERVICE"
    }
    compartment_id = var.compartment_id
  }

  retention_duration = 90
  is_enabled         = true
}

resource "oci_monitoring_alarm" "bucket_storage_high" {
  compartment_id        = var.compartment_id
  display_name          = "bucket-storage-cost-alert"
  is_enabled            = true
  metric_compartment_id = var.compartment_id
  namespace             = "oci_objectstorage"
  query                 = "StoredBytes[1d]{bucketName = '${oci_objectstorage_bucket.app_data.name}'}.max() > 1099511627776"
  severity              = "WARNING"
  pending_duration      = "PT24H"
  destinations          = [var.ops_notification_topic_id]
  body                  = "Production app data bucket has exceeded 1TB. Review lifecycle policy configuration and data growth rate."
}

IAM Policy for Bucket Access

resource "oci_identity_policy" "object_storage_policy" {
  compartment_id = var.compartment_id
  name           = "object-storage-access-policy"
  description    = "Scoped access to production Object Storage buckets"

  statements = [
    # Application can read and write to the data bucket
    "Allow dynamic-group app-instances to manage objects in compartment id ${var.compartment_id} where target.bucket.name = '${oci_objectstorage_bucket.app_data.name}'",

    # Application can only read from the archive bucket
    "Allow dynamic-group app-instances to read objects in compartment id ${var.compartment_id} where target.bucket.name = '${oci_objectstorage_bucket.app_archive.name}'",

    # Ops team has full access to both buckets
    "Allow group ${var.ops_group_name} to manage object-family in compartment id ${var.compartment_id}"
  ]
}

Operational Notes

Always set a lifecycle rule to abort incomplete multipart uploads. It is easy to forget and expensive to overlook. A single large upload that fails and is never cleaned up can add meaningful storage costs over months.

Use prefix-based lifecycle rules rather than bucket-level rules wherever possible. A single bucket can hold logs, backups, reports, and application data, each with different retention requirements. Prefix-based rules let you apply the right policy to each data type without managing multiple buckets.

Enable versioning on any bucket that holds data written by automated processes. Application bugs, deployment errors, and runaway scripts delete or overwrite data regularly. Versioning gives you a recovery path. Pair it with a version cleanup rule so old versions do not accumulate indefinitely.

Pre-authenticated requests expire. When generating PARs for external systems, build the expiry timestamp into the URL metadata and monitor for PARs approaching expiry if the external dependency needs continuous access. A PAR that expires silently causes an integration failure with no obvious error message on the receiving end.

Regards,
Osama

#OCI #OracleCloud #ObjectStorage #Terraform #CloudNative #InfrastructureAsCode #IaC #OracleCloudInfrastructure #DataManagement #CloudArchitecture #PlatformEngineering #DevOps #TechBlog #CloudStorage #DataEngineering #Oracle #LifecyclePolicy #CloudCostOptimization #StorageTiers #EventDriven

Leave a comment

This site uses Akismet to reduce spam. Learn how your comment data is processed.