← Back to list

Building a Complete GitOps CI/CD Pipeline with OCI DevOps, Terraform, and OKE

Introduction

Pavan Madduri · 2026-03-17 01:34 · 0 claps · 10.3 min read
#oci #oke #oracle-cloud #ci-cd-pipeline #terraform
Open on Medium ↗
Wiki topics: ☁️ · DevOps & Cloud

Building a Complete GitOps CI/CD Pipeline with OCI DevOps, Terraform, and OKE

Introduction

GitOps is a simple idea with powerful consequences: your Git repository is the single source of truth for both infrastructure and application state. Every change goes through a pull request. Every deployment is traceable. Every rollback is a git revert.

Most GitOps tutorials use AWS or GCP with third-party tools like ArgoCD, Flux, or Jenkins. In this post, I will build the same pipeline using OCI-native services — OCI DevOps for CI/CD, Terraform with OCI Resource Manager for infrastructure, and OKE for the runtime. No third-party CI tools, no external dependencies.

The result is a pipeline where:

  1. Infrastructure changes in Terraform trigger automated plan and apply workflows
  2. Application code changes trigger container builds, security scans, and rolling deployments
  3. Every change is auditable, reversible, and requires approval

Architecture

┌─────────────────────────────────────────────────────────────────────────┐
│                        GitOps Pipeline Architecture                     │
│                                                                         │
│  ┌──────────────┐     ┌──────────────────────────────────────────────┐ │
│  │  OCI DevOps   │     │              Build Pipeline                  │ │
│  │  Code Repo    │────▶│                                              │ │
│  │              │     │  ┌────────┐  ┌─────────┐  ┌──────────────┐  │ │
│  │  /app        │     │  │ Build  │─▶│ Test    │─▶│ Push to OCIR │  │ │
│  │  /infra      │     │  │ Image  │  │ + Scan  │  │              │  │ │
│  │  /k8s        │     │  └────────┘  └─────────┘  └──────┬───────┘  │ │
│  └──────────────┘     └───────────────────────────────────┼──────────┘ │
│                                                            │           │
│                        ┌──────────────────────────────────┐│           │
│                        │       Deploy Pipeline            ││           │
│                        │                                  ▼│           │
│                        │  ┌──────────┐  ┌────────┐  ┌────────────┐   │
│                        │  │ Approval │─▶│ Deploy │─▶│ Verify     │   │
│                        │  │ Gate     │  │ to OKE │  │ Health     │   │
│                        │  └──────────┘  └────────┘  └────────────┘   │
│                        └──────────────────────────────────────────────┘ │
│                                                                         │
│  ┌──────────────────────────────────────────────────────────────────┐  │
│  │                    Infrastructure Pipeline                       │  │
│  │                                                                  │  │
│  │  ┌─────────────┐  ┌──────────────┐  ┌────────┐  ┌───────────┐  │  │
│  │  │ Terraform   │─▶│ OCI Resource │─▶│ Plan   │─▶│ Apply     │  │  │
│  │  │ Code Change │  │ Manager      │  │ Review │  │ (Approved)│  │  │
│  │  └─────────────┘  └──────────────┘  └────────┘  └───────────┘  │  │
│  └──────────────────────────────────────────────────────────────────┘  │
│                                                                         │
│  ┌──────────────────────────────────────────────────────────────────┐  │
│  │                     OKE Cluster                                  │  │
│  │  ┌──────────┐  ┌──────────┐  ┌──────────┐  ┌────────────────┐  │  │
│  │  │ Staging  │  │Production│  │ Ingress  │  │ OCI Load       │  │  │
│  │  │ Namespace│  │Namespace │  │ NGINX    │  │ Balancer       │  │  │
│  │  └──────────┘  └──────────┘  └──────────┘  └────────────────┘  │  │
│  └──────────────────────────────────────────────────────────────────┘  │
└─────────────────────────────────────────────────────────────────────────┘

Prerequisites

  • OCI account with administrator access (or appropriate IAM policies)
  • OCI CLI configured with API key authentication
  • Terraform 1.5+ installed locally
  • kubectl installed
  • A domain name (optional, for HTTPS ingress)

Step 1: Infrastructure as Code with Terraform

Project Structure

infra/
├── environments/
│   ├── staging/
│   │   ├── main.tf
│   │   ├── variables.tf
│   │   └── terraform.tfvars
│   └── production/
│       ├── main.tf
│       ├── variables.tf
│       └── terraform.tfvars
├── modules/
│   ├── network/
│   │   ├── main.tf
│   │   ├── variables.tf
│   │   └── outputs.tf
│   ├── oke/
│   │   ├── main.tf
│   │   ├── variables.tf
│   │   └── outputs.tf
│   └── devops/
│       ├── main.tf
│       ├── variables.tf
│       └── outputs.tf
└── backend.tf

Network Module

# modules/network/main.tf
resource "oci_core_vcn" "main" {
  compartment_id = var.compartment_id
  cidr_blocks    = ["10.0.0.0/16"]
  display_name   = "${var.environment}-vcn"
  dns_label      = "${var.environment}net"
}
resource "oci_core_nat_gateway" "main" {
  compartment_id = var.compartment_id
  vcn_id         = oci_core_vcn.main.id
  display_name   = "${var.environment}-nat-gw"
}
resource "oci_core_internet_gateway" "main" {
  compartment_id = var.compartment_id
  vcn_id         = oci_core_vcn.main.id
  display_name   = "${var.environment}-igw"
  enabled        = true
}
resource "oci_core_service_gateway" "main" {
  compartment_id = var.compartment_id
  vcn_id         = oci_core_vcn.main.id
  display_name   = "${var.environment}-sgw"
  services {
    service_id = data.oci_core_services.all_services.services[0].id
  }
}
# Public subnet for load balancers
resource "oci_core_subnet" "public_lb" {
  compartment_id             = var.compartment_id
  vcn_id                     = oci_core_vcn.main.id
  cidr_block                 = "10.0.20.0/24"
  display_name               = "${var.environment}-public-lb"
  dns_label                  = "publb"
  prohibit_public_ip_on_vnic = false
  route_table_id             = oci_core_route_table.public.id
  security_list_ids          = [oci_core_security_list.public_lb.id]
}
# Private subnet for OKE API endpoint
resource "oci_core_subnet" "oke_api" {
  compartment_id             = var.compartment_id
  vcn_id                     = oci_core_vcn.main.id
  cidr_block                 = "10.0.0.0/28"
  display_name               = "${var.environment}-oke-api"
  dns_label                  = "okeapi"
  prohibit_public_ip_on_vnic = true
  route_table_id             = oci_core_route_table.private.id
  security_list_ids          = [oci_core_security_list.oke_api.id]
}
# Private subnet for worker nodes
resource "oci_core_subnet" "oke_workers" {
  compartment_id             = var.compartment_id
  vcn_id                     = oci_core_vcn.main.id
  cidr_block                 = "10.0.10.0/24"
  display_name               = "${var.environment}-oke-workers"
  dns_label                  = "okeworkers"
  prohibit_public_ip_on_vnic = true
  route_table_id             = oci_core_route_table.private.id
  security_list_ids          = [oci_core_security_list.oke_workers.id]
}
# Route tables
resource "oci_core_route_table" "public" {
  compartment_id = var.compartment_id
  vcn_id         = oci_core_vcn.main.id
  display_name   = "${var.environment}-public-rt"
  route_rules {
    destination       = "0.0.0.0/0"
    network_entity_id = oci_core_internet_gateway.main.id
  }
}
resource "oci_core_route_table" "private" {
  compartment_id = var.compartment_id
  vcn_id         = oci_core_vcn.main.id
  display_name   = "${var.environment}-private-rt"
  route_rules {
    destination       = "0.0.0.0/0"
    network_entity_id = oci_core_nat_gateway.main.id
  }
  route_rules {
    destination       = data.oci_core_services.all_services.services[0].cidr_block
    destination_type  = "SERVICE_CIDR_BLOCK"
    network_entity_id = oci_core_service_gateway.main.id
  }
}

OKE Module

# modules/oke/main.tf
resource "oci_containerengine_cluster" "main" {
  compartment_id     = var.compartment_id
  kubernetes_version = var.kubernetes_version
  name               = "${var.environment}-oke-cluster"
  vcn_id             = var.vcn_id
  endpoint_config {
    is_public_ip_enabled = false
    subnet_id            = var.api_subnet_id
    nsg_ids              = [oci_core_network_security_group.oke_api.id]
  }
  cluster_pod_network_options {
    cni_type = "OCI_VCN_IP_NATIVE"
  }
  options {
    service_lb_subnet_ids = [var.lb_subnet_id]
    add_ons {
      is_kubernetes_dashboard_enabled = false
      is_tiller_enabled               = false
    }
    admission_controller_options {
      is_pod_security_policy_enabled = false
    }
  }
}
resource "oci_containerengine_node_pool" "arm_workers" {
  compartment_id     = var.compartment_id
  cluster_id         = oci_containerengine_cluster.main.id
  kubernetes_version = var.kubernetes_version
  name               = "${var.environment}-arm-pool"
  node_shape = "VM.Standard.A1.Flex"
  node_shape_config {
    memory_in_gbs = var.node_memory_gb
    ocpus         = var.node_ocpus
  }
  node_config_details {
    size = var.node_count
    placement_configs {
      availability_domain = var.availability_domain
      subnet_id           = var.worker_subnet_id
    }
    node_pool_pod_network_option_details {
      cni_type          = "OCI_VCN_IP_NATIVE"
      max_pods_per_node = 31
      pod_subnet_ids    = [var.pod_subnet_id]
    }
  }
  node_source_details {
    image_id    = var.node_image_id
    source_type = "IMAGE"
  }
  initial_node_labels {
    key   = "environment"
    value = var.environment
  }
}
# Output the kubeconfig generation command
output "kubeconfig_command" {
  value = "oci ce cluster create-kubeconfig --cluster-id ${oci_containerengine_cluster.main.id} --file $HOME/.kube/config --region ${var.region} --token-version 2.0.0"
}

DevOps Module

# modules/devops/main.tf
resource "oci_devops_project" "main" {
  compartment_id = var.compartment_id
  name           = "${var.environment}-devops-project"
  notification_config {
    topic_id = oci_ons_notification_topic.devops.id
  }
}
# Code repository
resource "oci_devops_repository" "app" {
  project_id     = oci_devops_project.main.id
  name           = "application"
  repository_type = "HOSTED"
  default_branch = "refs/heads/main"
  description    = "Application source code and Kubernetes manifests"
}
# Build pipeline
resource "oci_devops_build_pipeline" "main" {
  project_id   = oci_devops_project.main.id
  display_name = "${var.environment}-build-pipeline"
  description  = "Build, test, and push container images"
}
# Build stage: Build and test
resource "oci_devops_build_pipeline_stage" "build" {
  build_pipeline_id = oci_devops_build_pipeline.main.id
  display_name      = "build-and-test"
  build_pipeline_stage_type = "BUILD"
  build_source_collection {
    items {
      connection_type = "DEVOPS_CODE_REPOSITORY"
      repository_id   = oci_devops_repository.app.id
      branch          = "main"
      name            = "primary"
    }
  }
  build_spec_file                    = "build_spec.yaml"
  image                              = "OL7_X86_64_STANDARD_10"
  stage_execution_timeout_in_seconds = 1800
  build_pipeline_stage_predecessor_collection {
    items {
      id = oci_devops_build_pipeline.main.id
    }
  }
}
# Build stage: Push to OCIR
resource "oci_devops_build_pipeline_stage" "push" {
  build_pipeline_id = oci_devops_build_pipeline.main.id
  display_name      = "push-to-ocir"
  build_pipeline_stage_type = "DELIVER_ARTIFACT"
  deliver_artifact_collection {
    items {
      artifact_id   = oci_devops_deploy_artifact.container_image.id
      artifact_name = "app-image"
    }
  }
  build_pipeline_stage_predecessor_collection {
    items {
      id = oci_devops_build_pipeline_stage.build.id
    }
  }
}
# Deploy pipeline
resource "oci_devops_deploy_pipeline" "main" {
  project_id   = oci_devops_project.main.id
  display_name = "${var.environment}-deploy-pipeline"
  description  = "Deploy to OKE with approval gates"
}
# Deploy stage: Approval
resource "oci_devops_deploy_stage" "approval" {
  deploy_pipeline_id = oci_devops_deploy_pipeline.main.id
  display_name       = "production-approval"
  deploy_stage_type = "MANUAL_APPROVAL"
  approval_policy {
    approval_policy_type         = "COUNT_BASED_APPROVAL"
    number_of_approvals_required = 1
  }
  deploy_stage_predecessor_collection {
    items {
      id = oci_devops_deploy_pipeline.main.id
    }
  }
}
# Deploy stage: OKE deployment
resource "oci_devops_deploy_stage" "oke_deploy" {
  deploy_pipeline_id = oci_devops_deploy_pipeline.main.id
  display_name       = "deploy-to-oke"
  deploy_stage_type = "OKE_DEPLOYMENT"
  oke_cluster_deploy_environment_id = oci_devops_deploy_environment.oke.id
  kubernetes_manifest_deploy_artifact_ids = [
    oci_devops_deploy_artifact.k8s_manifest.id
  ]
  rollback_policy {
    policy_type = "AUTOMATED_STAGE_ROLLBACK_POLICY"
  }
  deploy_stage_predecessor_collection {
    items {
      id = oci_devops_deploy_stage.approval.id
    }
  }
}
# Trigger: Run build pipeline on code push
resource "oci_devops_trigger" "push_trigger" {
  project_id   = oci_devops_project.main.id
  display_name = "on-push-to-main"
  trigger_source = "DEVOPS_CODE_REPOSITORY"
  repository_id  = oci_devops_repository.app.id
  actions {
    build_pipeline_id = oci_devops_build_pipeline.main.id
    type              = "TRIGGER_BUILD_PIPELINE"
    filter {
      trigger_source = "DEVOPS_CODE_REPOSITORY"
      events         = ["PUSH"]
      include {
        head_ref = "main"
      }
    }
  }
}

Step 2: Build Specification

The OCI DevOps build runner uses a build_spec.yaml file (similar to a Jenkinsfile or GitHub Actions workflow):

# build_spec.yaml
version: 0.1
component: build
timeoutInSeconds: 1800
shell: bash
env:
  variables:
    APP_NAME: "product-api"
  exportedVariables:
    - IMAGE_TAG
    - BUILD_HASH
steps:
  - type: Command
    name: "Set build variables"
    command: |
      export IMAGE_TAG="${OCI_BUILD_RUN_ID:0:8}"
      export BUILD_HASH=$(git rev-parse --short HEAD)
      echo "Building ${APP_NAME}:${IMAGE_TAG} (commit: ${BUILD_HASH})"
  - type: Command
    name: "Run unit tests"
    command: |
      go test ./... -v -coverprofile=coverage.out
      COVERAGE=$(go tool cover -func=coverage.out | grep total | awk '{print $3}')
      echo "Test coverage: ${COVERAGE}"
      # Fail if coverage is below 80%
      COVERAGE_NUM=${COVERAGE%\%}
      if (( $(echo "$COVERAGE_NUM < 80" | bc -l) )); then
        echo "ERROR: Test coverage ${COVERAGE} is below 80% threshold"
        exit 1
      fi
  - type: Command
    name: "Run security scan"
    command: |
      # Install and run govulncheck
      go install golang.org/x/vuln/cmd/govulncheck@latest
      govulncheck ./...
  - type: Command
    name: "Build container image"
    command: |
      docker build \
        --build-arg BUILD_HASH=${BUILD_HASH} \
        --build-arg BUILD_TIME=$(date -u +%Y-%m-%dT%H:%M:%SZ) \
        -t ${APP_NAME}:${IMAGE_TAG} \
        -t ${APP_NAME}:latest \
        .
  - type: Command
    name: "Run container structure tests"
    command: |
      # Verify the container starts and responds to health checks
      docker run -d --name test-container -p 8080:8080 \
        -e DB_DSN="mock" \
        ${APP_NAME}:${IMAGE_TAG}
      sleep 5
      # Check health endpoint
      HTTP_CODE=$(curl -s -o /dev/null -w "%{http_code}" http://localhost:8080/health)
      if [ "$HTTP_CODE" != "200" ]; then
        echo "ERROR: Health check failed with HTTP ${HTTP_CODE}"
        docker logs test-container
        exit 1
      fi
      docker stop test-container && docker rm test-container
      echo "Container health check passed"
outputArtifacts:
  - name: app-image
    type: DOCKER_IMAGE
    location: ${APP_NAME}:${IMAGE_TAG}

Step 3: Kubernetes Manifests with Kustomize

Using Kustomize allows environment-specific overlays without duplicating manifests:

k8s/
├── base/
│   ├── deployment.yaml
│   ├── service.yaml
│   ├── hpa.yaml
│   ├── ingress.yaml
│   └── kustomization.yaml
├── overlays/
│   ├── staging/
│   │   ├── kustomization.yaml
│   │   └── patches/
│   │       └── deployment-patch.yaml
│   └── production/
│       ├── kustomization.yaml
│       └── patches/
│           └── deployment-patch.yaml

Base Manifests

# k8s/base/deployment.yaml
apiVersion: apps/v1
kind: Deployment
metadata:
  name: product-api
  labels:
    app: product-api
    version: latest
spec:
  replicas: 2
  revisionHistoryLimit: 5
  strategy:
    type: RollingUpdate
    rollingUpdate:
      maxUnavailable: 0
      maxSurge: 1
  selector:
    matchLabels:
      app: product-api
  template:
    metadata:
      labels:
        app: product-api
      annotations:
        prometheus.io/scrape: "true"
        prometheus.io/port: "8080"
        prometheus.io/path: "/metrics"
    spec:
      serviceAccountName: product-api
      terminationGracePeriodSeconds: 30
      containers:
        - name: api
          image: product-api:latest
          ports:
            - name: http
              containerPort: 8080
              protocol: TCP
          resources:
            requests:
              cpu: "250m"
              memory: "256Mi"
            limits:
              cpu: "1000m"
              memory: "512Mi"
          readinessProbe:
            httpGet:
              path: /health/ready
              port: http
            initialDelaySeconds: 5
            periodSeconds: 10
            failureThreshold: 3
          livenessProbe:
            httpGet:
              path: /health/live
              port: http
            initialDelaySeconds: 10
            periodSeconds: 15
            failureThreshold: 3
          env:
            - name: ENVIRONMENT
              valueFrom:
                fieldRef:
                  fieldPath: metadata.namespace
            - name: POD_NAME
              valueFrom:
                fieldRef:
                  fieldPath: metadata.name
          envFrom:
            - secretRef:
                name: product-api-secrets
# k8s/base/kustomization.yaml
apiVersion: kustomize.config.k8s.io/v1beta1
kind: Kustomization
resources:
  - deployment.yaml
  - service.yaml
  - hpa.yaml
  - ingress.yaml

Production Overlay

# k8s/overlays/production/kustomization.yaml
apiVersion: kustomize.config.k8s.io/v1beta1
kind: Kustomization
namespace: production
resources:
  - ../../base
patches:
  - path: patches/deployment-patch.yaml
images:
  - name: product-api
    newName: iad.ocir.io/tenancy/product-api
    newTag: ${IMAGE_TAG}
replicas:
  - name: product-api
    count: 3
# k8s/overlays/production/patches/deployment-patch.yaml
apiVersion: apps/v1
kind: Deployment
metadata:
  name: product-api
spec:
  template:
    spec:
      containers:
        - name: api
          resources:
            requests:
              cpu: "500m"
              memory: "512Mi"
            limits:
              cpu: "2000m"
              memory: "1Gi"
      topologySpreadConstraints:
        - maxSkew: 1
          topologyKey: topology.kubernetes.io/zone
          whenUnsatisfiable: DoNotSchedule
          labelSelector:
            matchLabels:
              app: product-api

Step 4: Infrastructure Pipeline with OCI Resource Manager

OCI Resource Manager acts as a managed Terraform backend:

# Create a Resource Manager stack from Terraform code
STACK_ID=$(oci resource-manager stack create \
    --compartment-id "$COMPARTMENT_ID" \
    --display-name "production-infrastructure" \
    --description "Production OKE cluster and networking" \
    --config-source '{
        "configSourceType": "DEVOPS_CONFIG_SOURCE",
        "projectId": "'$DEVOPS_PROJECT_ID'",
        "repositoryId": "'$REPO_ID'",
        "branchName": "main",
        "workingDirectory": "infra/environments/production"
    }' \
    --terraform-version "1.5.x" \
    --variables '{
        "compartment_id": "'$COMPARTMENT_ID'",
        "environment": "production",
        "kubernetes_version": "v1.28.2",
        "node_count": 3,
        "node_ocpus": 2,
        "node_memory_gb": 16
    }' \
    --query 'data.id' --raw-output)
# Run Terraform plan
PLAN_JOB_ID=$(oci resource-manager job create-plan-job \
    --stack-id "$STACK_ID" \
    --display-name "plan-$(date +%Y%m%d-%H%M%S)" \
    --query 'data.id' --raw-output)
# Review the plan
oci resource-manager job get-job-logs \
    --job-id "$PLAN_JOB_ID" \
    --query 'data.items[].message' --output table
# Apply after review
APPLY_JOB_ID=$(oci resource-manager job create-apply-job \
    --stack-id "$STACK_ID" \
    --execution-plan-strategy "FROM_PLAN_JOB_ID" \
    --execution-plan-job-id "$PLAN_JOB_ID" \
    --display-name "apply-$(date +%Y%m%d-%H%M%S)" \
    --query 'data.id' --raw-output)

Step 5: Setting Up the Trigger Chain

The final piece connects everything together — a push to main triggers the build, and a successful build triggers the deployment:

# Create the build-to-deploy trigger
oci devops trigger create-devops-code-repo-trigger \
    --project-id "$DEVOPS_PROJECT_ID" \
    --display-name "build-and-deploy-on-push" \
    --repository-id "$REPO_ID" \
    --actions '[
        {
            "type": "TRIGGER_BUILD_PIPELINE",
            "buildPipelineId": "'$BUILD_PIPELINE_ID'",
            "filter": {
                "triggerSource": "DEVOPS_CODE_REPOSITORY",
                "events": ["PUSH"],
                "include": {
                    "headRef": "main",
                    "fileFilter": {
                        "filePaths": ["app/**", "Dockerfile"]
                    }
                }
            }
        }
    ]'
# Create a separate trigger for infrastructure changes
oci devops trigger create-devops-code-repo-trigger \
    --project-id "$DEVOPS_PROJECT_ID" \
    --display-name "infra-plan-on-push" \
    --repository-id "$REPO_ID" \
    --actions '[
        {
            "type": "TRIGGER_BUILD_PIPELINE",
            "buildPipelineId": "'$INFRA_PIPELINE_ID'",
            "filter": {
                "triggerSource": "DEVOPS_CODE_REPOSITORY",
                "events": ["PUSH"],
                "include": {
                    "headRef": "main",
                    "fileFilter": {
                        "filePaths": ["infra/**"]
                    }
                }
            }
        }
    ]'

This creates two independent pipelines triggered by the same repository:

  • Changes to app/** or Dockerfile → Build and deploy pipeline
  • Changes to infra/** → Terraform plan and apply pipeline

Pipeline Execution Flow

Here is what happens when a developer pushes code:

1. Developer pushes to main branch
   └─▶ OCI DevOps Trigger fires
2. Build Pipeline starts
   ├─▶ Stage 1: Run unit tests (go test ./...)
   ├─▶ Stage 2: Security vulnerability scan (govulncheck)
   ├─▶ Stage 3: Build Docker image
   ├─▶ Stage 4: Container health check test
   └─▶ Stage 5: Push to OCI Container Registry
3. Deploy Pipeline starts automatically
   ├─▶ Stage 1: Deploy to staging namespace
   ├─▶ Stage 2: Run integration tests against staging
   ├─▶ Stage 3: Manual approval gate (Slack notification)
   └─▶ Stage 4: Rolling deploy to production namespace
4. Post-deployment verification
   ├─▶ Health check passes → Pipeline succeeds
   └─▶ Health check fails → Automatic rollback

Monitoring the Pipeline

OCI DevOps Dashboard Metrics

# Get recent build runs
oci devops build-run list \
    --build-pipeline-id "$BUILD_PIPELINE_ID" \
    --query 'data.items[*].{
        id: id,
        status: "lifecycle-state",
        commit: "commit-info"."commit-hash",
        started: "time-created",
        duration: "build-run-progress"."time-finished"
    }' --output table
# Get deployment history
oci devops deployment list \
    --deploy-pipeline-id "$DEPLOY_PIPELINE_ID" \
    --query 'data.items[*].{
        id: id,
        status: "lifecycle-state",
        type: "deployment-type",
        created: "time-created"
    }' --output table

Set Up Notifications

# Create notification topic for pipeline events
TOPIC_ID=$(oci ons topic create \
    --compartment-id "$COMPARTMENT_ID" \
    --name "devops-notifications" \
    --query 'data."topic-id"' --raw-output)
# Subscribe Slack webhook
oci ons subscription create \
    --compartment-id "$COMPARTMENT_ID" \
    --topic-id "$TOPIC_ID" \
    --protocol "HTTPS" \
    --endpoint "https://hooks.slack.com/services/YOUR/WEBHOOK"
# Create event rule for build failures
oci events rule create \
    --compartment-id "$COMPARTMENT_ID" \
    --display-name "build-failure-alert" \
    --is-enabled true \
    --condition '{"eventType": ["com.oraclecloud.devops.buildrunfailed"]}' \
    --actions '{
        "actions": [{
            "actionType": "ONS",
            "topicId": "'$TOPIC_ID'",
            "isEnabled": true
        }]
    }'

Cost Analysis

ResourceConfigurationMonthly CostOCI DevOpsBuild pipelines, deploymentsFree (included)OKE Control PlaneManaged KubernetesFreeWorker Nodes (3x ARM)VM.Standard.A1.Flex, 2 OCPU, 12 GB each~$0 (Always Free*)Container Registry10 GB storage~$1Resource ManagerTerraform state, runsFreeNotifications10,000 messages/month~$0Total~$1/month

*Always Free tier includes 4 ARM OCPUs and 24 GB RAM total, enough for a small cluster.

Compare this to GitHub Actions + EKS + ECR: $150–300/month for similar infrastructure.

Lessons Learned

What worked well:

  • OCI DevOps triggers with file filters — Separating application and infrastructure pipelines by file path keeps things clean. No accidental infrastructure changes from app code pushes
  • Resource Manager as Terraform backend — State management, locking, and drift detection are handled automatically. No S3 bucket + DynamoDB table configuration
  • Approval gates — The manual approval stage with Slack notifications created a natural checkpoint. Our team caught two configuration issues during approval review

What to watch out for:

  • Build runner limitations — OCI DevOps build runners are x86 only. For ARM images, use Docker buildx with --platform linux/arm64 in the build spec
  • Secret management — Store database credentials and API tokens in OCI Vault, not in build spec variables. Use Vault dynamic secrets for OKE pod credentials
  • Deployment artifact versioning — Always use specific image tags (commit hash or build ID), never latest. The deploy pipeline needs to know exactly which image to deploy

Conclusion

A complete GitOps pipeline on OCI — from infrastructure provisioning to application deployment — can be built entirely with OCI-native services. No Jenkins, no GitHub Actions, no external CI/CD tools. The integrated nature of OCI DevOps with OKE, Container Registry, and Resource Manager eliminates the glue code and service integrations that consume engineering time on other platforms.

The Always Free tier makes this accessible for learning, prototyping, and even small production workloads. For teams already on Oracle Cloud, adopting OCI DevOps is the path of least resistance to GitOps.


메타데이터
post_id
bc061a53efac
slug
building-a-complete-gitops-ci-cd-pipeline-with-oci-devops-terraform-and-oke-bc061a53efac
url
https://medium.com/@pavan4devops/building-a-complete-gitops-ci-cd-pipeline-with-oci-devops-terraform-and-oke-bc061a53efac
canonical_url
https://medium.com/@pavan4devops/building-a-complete-gitops-ci-cd-pipeline-with-oci-devops-terraform-and-oke-bc061a53efac
author_url
https://medium.com/@pavan4devops
status
ok
fetched_at
2026-06-12 22:02:08