OCI Container Registry: Image Management, Vulnerability Scanning, and Retention Policies with Terraform

Container images are software supply chain artifacts. They carry your application code, its dependencies, and the base OS layer into production. A registry that just stores and serves images without controls around who can push, what gets scanned, and how long images are retained is a liability. Unscanned images with known CVEs get deployed. Repositories accumulate hundreds of unused image versions. Engineers push directly to production repositories from their laptops.

OCI Container Registry (OCIR) is Oracle’s managed container registry integrated into OCI IAM, OCI Vulnerability Scanning, and OCI DevOps. It stores Docker and OCI-format images, supports image signing with Cosign, enforces repository-level access controls, and integrates natively with OKE for pull authentication. This post covers deploying repositories with Terraform, configuring vulnerability scanning, setting up retention policies, signing images, and integrating with OKE.

Step 1: IAM Policy for OCIR

resource "oci_identity_policy" "ocir_policy" {
  compartment_id = var.compartment_id
  name           = "container-registry-policy"
  description    = "Access controls for OCI Container Registry"

  statements = [
    # CI/CD pipelines can push images
    "Allow dynamic-group devops-build-pipelines to manage repos in compartment id ${var.compartment_id}",

    # OKE nodes can pull images
    "Allow dynamic-group oke-node-instances to read repos in compartment id ${var.compartment_id}",

    # Vulnerability scanning service can read images
    "Allow service vulnerability-scanning-service to read repos in compartment id ${var.compartment_id}",
    "Allow service vulnerability-scanning-service to read compartments in tenancy",

    # Developers can read images but not push to production repos
    "Allow group ${var.dev_group_name} to read repos in compartment id ${var.compartment_id}",

    # Ops team has full registry access
    "Allow group ${var.ops_group_name} to manage repos in compartment id ${var.compartment_id}"
  ]
}

Step 2: Create Repositories

resource "oci_artifacts_container_repository" "orders_api" {
  compartment_id = var.compartment_id
  display_name   = "production/orders-api"
  is_public      = false
  is_immutable   = false

  readme {
    content = base64encode(<<-EOT
      # orders-api
      Production container image for the Orders API service.
      Built by OCI DevOps pipeline. Do not push manually.
    EOT
    )
    format  = "text/plain"
  }

  defined_tags = {
    "Operations.Environment" = "production"
    "Operations.Application" = "orders"
    "Operations.ManagedBy"   = "terraform"
  }
}

resource "oci_artifacts_container_repository" "orders_api_staging" {
  compartment_id = var.compartment_id
  display_name   = "staging/orders-api"
  is_public      = false
  is_immutable   = false

  defined_tags = {
    "Operations.Environment" = "staging"
    "Operations.Application" = "orders"
    "Operations.ManagedBy"   = "terraform"
  }
}

output "registry_endpoint" {
  value = "${var.region_key}.ocir.io"
}

output "production_repo" {
  value = "${var.region_key}.ocir.io/${var.tenancy_namespace}/production/orders-api"
}

The repository display name uses a path prefix convention: production/orders-api and staging/orders-api. This mirrors the directory structure used in Docker Hub and makes it easy to apply IAM policies scoped to an environment prefix. A policy targeting production/* repositories can restrict push access to CI/CD pipelines only while developers retain read access.

Step 3: Authenticate Docker to OCIR

# Generate an OCI Auth Token for Docker authentication
# Do this under your user profile in the OCI Console: Identity > Users > Auth Tokens
# Then log in:

docker login ${REGION_KEY}.ocir.io \
  --username "${TENANCY_NAMESPACE}/${OCI_USERNAME}" \
  --password "${AUTH_TOKEN}"

# Tag and push an image
docker tag orders-api:1.2.3 \
  ${REGION_KEY}.ocir.io/${TENANCY_NAMESPACE}/production/orders-api:1.2.3

docker push \
  ${REGION_KEY}.ocir.io/${TENANCY_NAMESPACE}/production/orders-api:1.2.3

# Also push a mutable latest tag for reference
docker tag orders-api:1.2.3 \
  ${REGION_KEY}.ocir.io/${TENANCY_NAMESPACE}/production/orders-api:latest

docker push \
  ${REGION_KEY}.ocir.io/${TENANCY_NAMESPACE}/production/orders-api:latest

Always push a semantic version tag alongside the latest tag. The latest tag is mutable and gets overwritten on every push. Kubernetes deployments referencing latest cannot be rolled back deterministically because you cannot identify which image a running pod is using. Reference specific version tags in Kubernetes manifests and use latest only for local development.

Step 4: Vulnerability Scanning

resource "oci_vulnerability_scanning_container_scan_recipe" "production_scan" {
  compartment_id = var.compartment_id
  display_name   = "production-container-scan-recipe"

  image_scan_level = "STANDARD"

  defined_tags = {
    "Operations.Environment" = "production"
    "Operations.ManagedBy"   = "terraform"
  }
}

resource "oci_vulnerability_scanning_container_scan_target" "production_registry" {
  compartment_id = var.compartment_id
  display_name   = "production-registry-scan-target"
  container_scan_recipe_id = oci_vulnerability_scanning_container_scan_recipe.production_scan.id

  target_registry {
    compartment_id = var.compartment_id
    type           = "OCIR"
    repositories   = [
      "production/orders-api",
      "production/notification-service",
      "production/inventory-service"
    ]
    url = "${var.region_key}.ocir.io"
  }

  defined_tags = {
    "Operations.Environment" = "production"
    "Operations.ManagedBy"   = "terraform"
  }
}

# Alert when critical vulnerabilities are found
resource "oci_monitoring_alarm" "critical_cve_found" {
  compartment_id        = var.compartment_id
  display_name          = "container-image-critical-cve"
  is_enabled            = true
  metric_compartment_id = var.compartment_id
  namespace             = "oci_vss"
  query                 = "ContainerImageCriticalProblems[1d]{compartmentId = '${var.compartment_id}'}.sum() > 0"
  severity              = "CRITICAL"
  pending_duration      = "PT30M"
  destinations          = [var.security_notification_topic_id]
  body                  = "Critical CVEs detected in production container images. Review the Vulnerability Scanning report and update affected images before the next deployment."
}

The STANDARD scan level checks the OS packages inside the image against the CVE database. Every image pushed to the target repositories is scanned automatically. Scan results appear in the OCI Console under Vulnerability Scanning and are also accessible via the API.

Query scan results for a specific image via CLI:

# List container scan results for the production compartment
oci vulnerability-scanning container-scan-result list \
  --compartment-id ${COMPARTMENT_ID} \
  --query 'data.items[*].{image:"container-image-reference", critical:"problems-count".critical, high:"problems-count".high, status:"lifecycle-state"}' \
  --output table

# Get detailed CVE list for a specific scan result
oci vulnerability-scanning container-scan-result get \
  --container-scan-result-id ${SCAN_RESULT_ID} \
  --query 'data.vulnerabilities[?severity==`CRITICAL`].{cve:"cve-id", severity:severity, package:"package-name", version:"package-version"}' \
  --output table

Step 5: Image Signing with Cosign

Image signing proves that an image came from a trusted build pipeline and has not been tampered with since it was signed. OCI Container Registry stores Cosign signatures as OCI artifacts alongside the image.

# Install Cosign
brew install cosign  # macOS
# or: curl -fsSL https://github.com/sigstore/cosign/releases/latest/download/cosign-linux-amd64 -o cosign

# Generate a signing key pair (store private key in OCI Vault in production)
cosign generate-key-pair
# Creates cosign.key (private) and cosign.pub (public)

# Sign the image after pushing
IMAGE_DIGEST=$(docker inspect \
  --format='{{index .RepoDigests 0}}' \
  ${REGION_KEY}.ocir.io/${TENANCY_NAMESPACE}/production/orders-api:1.2.3)

cosign sign \
  --key cosign.key \
  ${IMAGE_DIGEST}

# Verify the signature before deployment
cosign verify \
  --key cosign.pub \
  ${REGION_KEY}.ocir.io/${TENANCY_NAMESPACE}/production/orders-api:1.2.3

In a CI/CD pipeline, signing happens automatically after the image is pushed and scanned. The private key lives in OCI Vault. The build pipeline retrieves it using its Dynamic Group policy at signing time. The public key is distributed to OKE clusters as a Kubernetes secret and enforced using a Gatekeeper or Kyverno admission controller policy that rejects unsigned images.

Step 6: Retention Policies

Without retention policies, repositories accumulate image versions indefinitely. A repository that receives 10 pushes per day accumulates 3650 image versions per year. At typical image sizes, this is tens to hundreds of gigabytes of storage that serves no operational purpose.

resource "oci_artifacts_container_image_signature" "retention_policy" {
  # Retention policies are configured at the tenancy level in OCIR
  # via the Console or API - Terraform support is through the artifacts provider
}

# Set retention via OCI CLI - keep last 10 images per repository
oci artifacts container image-signature-config update \
  --compartment-id ${COMPARTMENT_ID}

# Retention policy via REST API
curl -X PUT \
  -H "Authorization: Bearer ${TOKEN}" \
  -H "Content-Type: application/json" \
  "https://artifacts.${REGION}.oci.oraclecloud.com/20160918/retentionRules" \
  -d '{
    "compartmentId": "'${COMPARTMENT_ID}'",
    "retentionPolicy": {
      "durationType": "DAYS",
      "duration": 90,
      "policyUnit": "DAYS"
    }
  }'

# Using OCI CLI for image cleanup - delete images older than 90 days
oci artifacts container image list \
  --compartment-id ${COMPARTMENT_ID} \
  --repository-name "production/orders-api" \
  --query 'data.items[?"time-created" < `2026-04-29`].id' \
  --output json \
  | jq -r '.[]' \
  | xargs -I {} oci artifacts container image delete \
      --image-id {} \
      --force

Run the cleanup script as an OCI Function on a weekly schedule triggered by OCI Events. The Function uses Resource Principal authentication through a Dynamic Group policy, so no credentials are stored in the function configuration.

Step 7: OKE Integration

OKE nodes in the same tenancy can pull from OCIR using Resource Principal authentication when the node pool Dynamic Group has the correct policy. This eliminates the need for a Kubernetes image pull secret containing registry credentials.

# Dynamic Group for OKE node instances
resource "oci_identity_dynamic_group" "oke_nodes" {
  compartment_id = var.tenancy_ocid
  name           = "oke-node-instances"
  description    = "OKE worker nodes for OCIR pull access"
  matching_rule  = "All {instance.compartment.id = '${var.compartment_id}'}"
}

# Policy granting OKE nodes pull access to OCIR
resource "oci_identity_policy" "oke_pull_policy" {
  compartment_id = var.compartment_id
  name           = "oke-ocir-pull-policy"
  description    = "Allows OKE nodes to pull images from OCIR"

  statements = [
    "Allow dynamic-group oke-node-instances to read repos in compartment id ${var.compartment_id}"
  ]
}

With this policy in place, Kubernetes deployments on OKE reference OCIR images directly with no imagePullSecrets required:

apiVersion: apps/v1
kind: Deployment
metadata:
  name: orders-api
  namespace: orders
spec:
  replicas: 3
  selector:
    matchLabels:
      app: orders-api
  template:
    metadata:
      labels:
        app: orders-api
    spec:
      containers:
        - name: orders-api
          # Full OCIR path with specific version tag - never use latest in production
          image: me-jeddah-1.ocir.io/your-namespace/production/orders-api:1.2.3
          ports:
            - containerPort: 8080
          resources:
            requests:
              cpu: "250m"
              memory: "256Mi"
            limits:
              cpu: "500m"
              memory: "512Mi"

Step 8: Enforce Scan Results Before Deployment

Block deployments of images with critical CVEs by integrating vulnerability scan results into your CI/CD gate. The OCI DevOps deployment pipeline can call a Function that checks the scan result for the target image before allowing the deployment to proceed.

import oci
import logging

logger = logging.getLogger(__name__)

def check_image_scan_results(image_digest: str, compartment_id: str) -> bool:
    config = oci.config.from_file()
    vss_client = oci.vulnerability_scanning.VulnerabilityScanningClient(config)

    results = vss_client.list_container_scan_results(
        compartment_id=compartment_id,
        state="ACTIVE"
    ).data.items

    for result in results:
        if image_digest in result.container_image_reference:
            critical = result.problems_count.critical if result.problems_count else 0
            high     = result.problems_count.high if result.problems_count else 0

            if critical > 0:
                logger.error(f"Image {image_digest} has {critical} critical CVEs. Blocking deployment.")
                return False

            if high > 5:
                logger.warning(f"Image {image_digest} has {high} high CVEs. Review before deploying.")

            logger.info(f"Image {image_digest} passed scan gate: {critical} critical, {high} high CVEs")
            return True

    logger.warning(f"No scan result found for {image_digest}. Blocking deployment until scan completes.")
    return False

Operational Notes

Use specific version tags in all Kubernetes manifests, Helm values, and deployment configurations. The latest tag is for local development only. In production, you need to know exactly which image version is running on which node at any point in time. Specific tags make this possible. Mutable tags make it impossible.

Scan results take a few minutes after push. In a CI/CD pipeline, add a wait step after the push and before the deployment gate check. A scan that has not completed yet returns no result, and your gate logic should treat a missing result as a block, not a pass.

Separate repositories for production and staging environments. Apply tighter IAM restrictions on production repositories so only the CI/CD pipeline can push. Developers pushing directly to production repositories bypass the build pipeline and its scan, test, and signing steps entirely.

Regards,
Osama

#OCI #OracleCloud #ContainerRegistry #Docker #Terraform #CloudNative #IaC #OracleCloudInfrastructure #DevOps #PlatformEngineering #CloudSecurity #TechBlog #Oracle #Kubernetes #OKE #DevSecOps #VulnerabilityScanning #ImageSigning #Cosign #SupplyChainSecurity

Leave a comment

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