IaC Azure

Terraform Module: Azure Azure Red Hat OpenShift — Jointly-Managed ARO Clusters with Private API, FIPS, and Zonal Worker Pools

Quick take — A reusable hashicorp/azurerm ~> 4.0 Terraform module for Azure Red Hat OpenShift (ARO): cluster/service-principal/network profiles, private API + ingress, FIPS, encryption-at-host, and validated master/worker sizing. New here? Jump to the Quickstart below to deploy it in minutes; read on for how it works and when to reach for it.

Quickstart (copy-paste)

Minimal, runnable configuration — drop this in a .tf file and fill in the "..." placeholders (each required input is commented):

provider "azurerm" {
  features {}
}

module "openshift" {
  source = "git::https://dev.azure.com/teknohut/kloudvin/_git/terraform-modules//terraform-module-azure-openshift?ref=v1.0.0"

  cluster_name                    = "..."  # ARO cluster name; 3-30 chars, must start with a letter.…
  location                        = "..."  # ARO-supported Azure region.
  resource_group_name             = "..."  # Existing resource group holding the cluster object.
  resource_group_id               = "..."  # RG resource ID, used to scope the SP `Contributor` assi…
  environment                     = "..."  # One of `dev`, `stg`, `prod`, `sandbox`; applied as a ta…
  openshift_version               = "..."  # Full ARO version, e.g. `"4.15.27"`. Validated to >= 4.1…
  domain                          = "..."  # Bare label (→ `*.<region>.aroapp.io`) or custom FQDN.
  pull_secret                     = "..."  # Red Hat pull secret JSON; must parse as JSON. Source fr…
  service_principal_client_id     = "..."  # Cluster service principal application (client) ID.
  service_principal_client_secret = "..."  # Cluster SP client secret; source from Key Vault.
  service_principal_object_id     = "..."  # Cluster SP object ID, used for role assignments.
  virtual_network_id              = "..."  # VNet ID containing the subnets; SP and ARO RP get `Netw…
  master_subnet_id                = "..."  # Dedicated control-plane subnet ID.
  worker_subnet_id                = "..."  # Dedicated worker subnet ID; must differ from `master_su…
}

Then terraform init && terraform apply. Every other input has a sensible default — see Inputs below to override behaviour.

What this module is

Azure Red Hat OpenShift (ARO) is a fully-managed OpenShift offering that Microsoft and Red Hat operate jointly — they run and SLA-back the control plane, the OpenShift operators, and the underlying VMs, while you consume a turnkey Red Hat OpenShift Container Platform cluster with its full ecosystem (Operators, Routes, OpenShift SDN/OVN, the OpenShift console, builds, and oc). Unlike AKS, ARO is not “just Kubernetes”: it ships an opinionated, Red Hat-supported platform, it bills the control plane and worker nodes as real VMs, and — critically for Terraform — azurerm_redhat_openshift_cluster has a very particular shape. It demands a service principal (ARO does not yet use managed identity for the cluster), a Red Hat pull secret, and two pre-created, delegated subnets (one for masters, one for workers) inside a VNet you own, with the Azure Red Hat OpenShift RP first-party app granted access to that network.

Getting those prerequisites subtly wrong — a single shared subnet, a missing Network Contributor role assignment for the resource provider, a public API server in a regulated environment, or a master VM SKU that ARO simply does not allow — is how an ARO terraform apply fails 30 minutes in, or how a cluster ships out of compliance. This module wraps azurerm_redhat_openshift_cluster into a single opinionated, var-driven unit. It defaults to the patterns you actually want in production: a private API server and private ingress (Visibility = "Private"), FIPS-validated cryptography and encryption-at-host on both node profiles, zonal worker placement, a managed-disk worker_profile sized for real workloads, and a pull secret sourced from a variable (never hard-coded) — while still exposing the knobs (OpenShift version, master/worker SKUs, pod/service CIDRs, PreconfiguredNSG, outbound type) that teams legitimately need to differentiate dev from prod.

When to use it

If you only need vanilla Kubernetes, AKS is cheaper and simpler — reach for the AKS module instead. Choose ARO (and this module) when you specifically need Red Hat OpenShift with a joint Microsoft/Red Hat SLA.

Module structure

terraform-module-azure-openshift/
├── versions.tf      # provider + Terraform version pinning
├── main.tf          # azurerm_redhat_openshift_cluster + RP role assignments
├── variables.tf     # var-driven inputs with validation
└── outputs.tf       # id/name, console + API URLs, worker subnet, cluster RG

versions.tf

terraform {
  required_version = ">= 1.6.0"

  required_providers {
    azurerm = {
      source  = "hashicorp/azurerm"
      version = "~> 4.0"
    }
    azuread = {
      source  = "hashicorp/azuread"
      version = "~> 3.0"
    }
  }
}

main.tf

locals {
  tags = merge(
    {
      managed_by  = "terraform"
      module      = "terraform-module-azure-openshift"
      environment = var.environment
    },
    var.tags,
  )

  # The Azure Red Hat OpenShift first-party application. ARO needs this RP
  # service principal to have rights over the VNet that holds the cluster subnets.
  aro_rp_application_name = "Azure Red Hat OpenShift RP"
}

# Look up the ARO resource-provider service principal so we can grant it access
# to the VNet. Its well-known application ID is f1dd0a37-a3d4-44 ... but resolving
# by display name keeps the module portable across clouds/tenants.
data "azuread_service_principal" "aro_rp" {
  display_name = local.aro_rp_application_name
}

# The cluster's own service principal must be Contributor on the resource group
# (so ARO can create the managed cluster resources). The caller passes its IDs;
# this assignment wires it to the RG scope.
resource "azurerm_role_assignment" "sp_contributor" {
  count = var.assign_service_principal_roles ? 1 : 0

  scope                = var.resource_group_id
  role_definition_name = "Contributor"
  principal_id         = var.service_principal_object_id
}

# Both the cluster SP and the ARO RP need Network Contributor on the VNet that
# contains the master/worker subnets, otherwise cluster creation fails.
resource "azurerm_role_assignment" "sp_network" {
  count = var.assign_service_principal_roles ? 1 : 0

  scope                = var.virtual_network_id
  role_definition_name = "Network Contributor"
  principal_id         = var.service_principal_object_id
}

resource "azurerm_role_assignment" "rp_network" {
  count = var.assign_service_principal_roles ? 1 : 0

  scope                = var.virtual_network_id
  role_definition_name = "Network Contributor"
  principal_id         = data.azuread_service_principal.aro_rp.object_id
}

resource "azurerm_redhat_openshift_cluster" "this" {
  name                = var.cluster_name
  location            = var.location
  resource_group_name = var.resource_group_name

  cluster_profile {
    # ARO requires the OpenShift version (e.g. "4.15.27"); pin it explicitly.
    version = var.openshift_version

    # Domain becomes part of the cluster's API/ingress FQDNs. A bare label gets
    # an *.<region>.aroapp.io domain; a full custom domain is also accepted.
    domain = var.domain

    # FIPS-validated cryptographic modules — required for many regulated estates.
    fips_enabled = var.fips_enabled

    # Red Hat pull secret (from cloud.redhat.com) enabling Red Hat content.
    pull_secret = var.pull_secret

    # The managed resource group ARO creates to hold cluster infra (VMs, LBs, NICs).
    managed_resource_group_name = var.managed_resource_group_name
  }

  # ARO uses a service principal (NOT managed identity) for the cluster itself.
  service_principal {
    client_id     = var.service_principal_client_id
    client_secret = var.service_principal_client_secret
  }

  network_profile {
    pod_cidr     = var.network_profile.pod_cidr
    service_cidr = var.network_profile.service_cidr

    # "Loadbalancer" (default) or "UserDefinedRouting" for egress-locked clusters.
    outbound_type = var.network_profile.outbound_type

    # When true, ARO will not manage the subnet NSGs — you bring your own.
    preconfigured_network_security_group_enabled = var.network_profile.preconfigured_nsg_enabled
  }

  main_profile {
    # Master VM SKU. ARO only allows specific control-plane sizes.
    vm_size                    = var.master_profile.vm_size
    subnet_id                  = var.master_subnet_id
    encryption_at_host_enabled = var.master_profile.encryption_at_host_enabled
    disk_encryption_set_id     = var.disk_encryption_set_id
  }

  worker_profile {
    vm_size                    = var.worker_profile.vm_size
    node_count                 = var.worker_profile.node_count
    disk_size_gb               = var.worker_profile.disk_size_gb
    subnet_id                  = var.worker_subnet_id
    encryption_at_host_enabled = var.worker_profile.encryption_at_host_enabled
    disk_encryption_set_id     = var.disk_encryption_set_id
  }

  api_server_profile {
    # "Private" keeps the API server off the public internet (recommended for prod).
    visibility = var.api_server_visibility
  }

  ingress_profile {
    # "Private" gives a private default ingress controller / router.
    visibility = var.ingress_visibility
  }

  tags = local.tags

  # ARO cluster creation/deletion is long (~35 min create, ~30 min destroy).
  timeouts {
    create = "90m"
    update = "90m"
    delete = "90m"
  }

  depends_on = [
    azurerm_role_assignment.sp_contributor,
    azurerm_role_assignment.sp_network,
    azurerm_role_assignment.rp_network,
  ]
}

variables.tf

variable "cluster_name" {
  description = "Name of the Azure Red Hat OpenShift cluster."
  type        = string

  validation {
    condition     = can(regex("^[a-zA-Z][a-zA-Z0-9-]{1,28}[a-zA-Z0-9]$", var.cluster_name))
    error_message = "cluster_name must be 3-30 chars, start with a letter, and contain only letters, digits, and hyphens."
  }
}

variable "location" {
  description = "Azure region for the cluster. Must be an ARO-supported region."
  type        = string
}

variable "resource_group_name" {
  description = "Name of the existing resource group that holds the cluster object."
  type        = string
}

variable "resource_group_id" {
  description = "Resource ID of the resource group, used to scope the SP Contributor role assignment."
  type        = string
}

variable "environment" {
  description = "Environment short name (e.g. dev, stg, prod). Applied as a tag."
  type        = string

  validation {
    condition     = contains(["dev", "stg", "prod", "sandbox"], var.environment)
    error_message = "environment must be one of: dev, stg, prod, sandbox."
  }
}

variable "openshift_version" {
  description = "OpenShift version to deploy, e.g. \"4.15.27\". Must be an ARO-available version (check `az aro get-versions`)."
  type        = string

  validation {
    condition     = can(regex("^4\\.(1[2-9]|[2-9][0-9])\\.[0-9]+$", var.openshift_version))
    error_message = "openshift_version must be a full ARO version >= 4.12.x, e.g. \"4.15.27\"."
  }
}

variable "domain" {
  description = "Cluster domain. A bare label (e.g. \"kv-prod\") yields an *.<region>.aroapp.io domain; a custom FQDN is also accepted."
  type        = string

  validation {
    condition     = can(regex("^[a-z0-9]([a-z0-9.-]*[a-z0-9])?$", var.domain))
    error_message = "domain must be a lowercase DNS label or FQDN (letters, digits, hyphens, dots)."
  }
}

variable "pull_secret" {
  description = "Red Hat pull secret JSON from console.redhat.com/openshift/install/pull-secret. Pass via a secret store, never hard-code."
  type        = string
  sensitive   = true

  validation {
    condition     = can(jsondecode(var.pull_secret))
    error_message = "pull_secret must be a valid JSON document (the Red Hat pull secret)."
  }
}

variable "managed_resource_group_name" {
  description = "Name ARO uses for the managed resource group that holds cluster infra (VMs, LBs, NICs, disks)."
  type        = string
  default     = null
}

variable "service_principal_client_id" {
  description = "Application (client) ID of the cluster service principal."
  type        = string
}

variable "service_principal_client_secret" {
  description = "Client secret for the cluster service principal. Source from Key Vault; never commit."
  type        = string
  sensitive   = true
}

variable "service_principal_object_id" {
  description = "Object (enterprise app) ID of the cluster service principal, used for role assignments."
  type        = string
}

variable "assign_service_principal_roles" {
  description = "Whether the module creates the Contributor (RG) and Network Contributor (VNet) role assignments for the SP and the ARO RP. Set false if a platform team pre-assigns them."
  type        = bool
  default     = true
}

variable "virtual_network_id" {
  description = "Resource ID of the VNet that contains the master and worker subnets. The ARO RP and SP get Network Contributor here."
  type        = string
}

variable "master_subnet_id" {
  description = "Resource ID of the dedicated subnet for control-plane (master) nodes. Must differ from the worker subnet."
  type        = string
}

variable "worker_subnet_id" {
  description = "Resource ID of the dedicated subnet for worker nodes. Must differ from the master subnet."
  type        = string

  validation {
    condition     = var.worker_subnet_id != var.master_subnet_id
    error_message = "worker_subnet_id must be a different subnet from master_subnet_id."
  }
}

variable "disk_encryption_set_id" {
  description = "Optional Disk Encryption Set resource ID for customer-managed-key (CMK) disk encryption on master and worker nodes."
  type        = string
  default     = null
}

variable "master_profile" {
  description = "Control-plane node configuration. ARO only permits specific master VM sizes."
  type = object({
    vm_size                    = optional(string, "Standard_D8s_v5")
    encryption_at_host_enabled = optional(bool, true)
  })
  default = {}
}

variable "worker_profile" {
  description = "Worker node pool configuration."
  type = object({
    vm_size                    = optional(string, "Standard_D4s_v5")
    node_count                 = optional(number, 3)
    disk_size_gb               = optional(number, 128)
    encryption_at_host_enabled = optional(bool, true)
  })
  default = {}

  validation {
    condition     = var.worker_profile.node_count >= 3
    error_message = "worker_profile.node_count must be at least 3 (ARO requires a minimum of three workers)."
  }

  validation {
    condition     = var.worker_profile.disk_size_gb >= 128
    error_message = "worker_profile.disk_size_gb must be at least 128 GB (ARO minimum)."
  }
}

variable "network_profile" {
  description = "Cluster pod/service CIDRs and egress configuration. CIDRs must not overlap the VNet or each other."
  type = object({
    pod_cidr                  = optional(string, "10.128.0.0/14")
    service_cidr              = optional(string, "172.30.0.0/16")
    outbound_type             = optional(string, "Loadbalancer")
    preconfigured_nsg_enabled = optional(bool, false)
  })
  default = {}

  validation {
    condition     = contains(["Loadbalancer", "UserDefinedRouting"], var.network_profile.outbound_type)
    error_message = "network_profile.outbound_type must be Loadbalancer or UserDefinedRouting."
  }

  validation {
    condition     = can(cidrhost(var.network_profile.pod_cidr, 0)) && can(cidrhost(var.network_profile.service_cidr, 0))
    error_message = "network_profile.pod_cidr and service_cidr must be valid CIDR ranges."
  }
}

variable "api_server_visibility" {
  description = "API server visibility: \"Private\" (recommended) or \"Public\"."
  type        = string
  default     = "Private"

  validation {
    condition     = contains(["Private", "Public"], var.api_server_visibility)
    error_message = "api_server_visibility must be Private or Public."
  }
}

variable "ingress_visibility" {
  description = "Default ingress (router) visibility: \"Private\" (recommended) or \"Public\"."
  type        = string
  default     = "Private"

  validation {
    condition     = contains(["Private", "Public"], var.ingress_visibility)
    error_message = "ingress_visibility must be Private or Public."
  }
}

variable "fips_enabled" {
  description = "Enable FIPS-validated cryptographic modules. Cannot be changed after cluster creation."
  type        = bool
  default     = true
}

variable "tags" {
  description = "Additional tags merged onto the cluster."
  type        = map(string)
  default     = {}
}

outputs.tf

output "id" {
  description = "Resource ID of the Azure Red Hat OpenShift cluster."
  value       = azurerm_redhat_openshift_cluster.this.id
}

output "name" {
  description = "Name of the ARO cluster."
  value       = azurerm_redhat_openshift_cluster.this.name
}

output "console_url" {
  description = "URL of the OpenShift web console for this cluster."
  value       = azurerm_redhat_openshift_cluster.this.console_url
}

output "api_server_url" {
  description = "API server endpoint URL — use this for `oc login` and kubeconfig generation."
  value       = azurerm_redhat_openshift_cluster.this.api_server_profile[0].url
}

output "api_server_ip" {
  description = "IP address of the API server (private IP when api_server_visibility is Private)."
  value       = azurerm_redhat_openshift_cluster.this.api_server_profile[0].ip_address
}

output "ingress_ip" {
  description = "IP address of the default ingress controller / router."
  value       = azurerm_redhat_openshift_cluster.this.ingress_profile[0].ip_address
}

output "version" {
  description = "OpenShift version the cluster is running."
  value       = azurerm_redhat_openshift_cluster.this.cluster_profile[0].version
}

output "managed_resource_group_name" {
  description = "Name of the ARO-managed resource group holding cluster infra (VMs, LBs, NICs, disks)."
  value       = azurerm_redhat_openshift_cluster.this.cluster_profile[0].resource_group_id
}

output "worker_subnet_id" {
  description = "Subnet ID hosting the worker nodes — handy for private endpoints and NSG rules."
  value       = azurerm_redhat_openshift_cluster.this.worker_profile[0].subnet_id
}

How to use it

data "azurerm_client_config" "current" {}

resource "azurerm_resource_group" "aro" {
  name     = "rg-aro-prod-weu"
  location = "westeurope"
}

# A VNet with two dedicated subnets — masters and workers must be separate.
resource "azurerm_virtual_network" "aro" {
  name                = "vnet-aro-prod-weu"
  location            = azurerm_resource_group.aro.location
  resource_group_name = azurerm_resource_group.aro.name
  address_space       = ["10.0.0.0/22"]
}

resource "azurerm_subnet" "master" {
  name                 = "snet-aro-master"
  resource_group_name  = azurerm_resource_group.aro.name
  virtual_network_name = azurerm_virtual_network.aro.name
  address_prefixes     = ["10.0.0.0/23"]
}

resource "azurerm_subnet" "worker" {
  name                 = "snet-aro-worker"
  resource_group_name  = azurerm_resource_group.aro.name
  virtual_network_name = azurerm_virtual_network.aro.name
  address_prefixes     = ["10.0.2.0/23"]
}

module "azure_red_hat_openshift" {
  source = "git::https://dev.azure.com/teknohut/kloudvin/_git/terraform-modules//terraform-module-azure-openshift?ref=v1.0.0"

  cluster_name        = "aro-payments-prod-weu"
  location            = azurerm_resource_group.aro.location
  resource_group_name = azurerm_resource_group.aro.name
  resource_group_id   = azurerm_resource_group.aro.id
  environment         = "prod"

  openshift_version = "4.15.27"
  domain            = "kv-payments-prod"

  # Networking prerequisites.
  virtual_network_id = azurerm_virtual_network.aro.id
  master_subnet_id   = azurerm_subnet.master.id
  worker_subnet_id   = azurerm_subnet.worker.id

  # Service principal (created out-of-band; secret sourced from Key Vault).
  service_principal_client_id     = azuread_application.aro.client_id
  service_principal_object_id     = azuread_service_principal.aro.object_id
  service_principal_client_secret = data.azurerm_key_vault_secret.aro_sp.value

  # Red Hat pull secret, also from Key Vault.
  pull_secret = data.azurerm_key_vault_secret.aro_pull_secret.value

  master_profile = {
    vm_size = "Standard_D8s_v5"
  }

  worker_profile = {
    vm_size      = "Standard_D8s_v5"
    node_count   = 4
    disk_size_gb = 256
  }

  # Private and compliant by default — these are the module defaults, shown for clarity.
  api_server_visibility = "Private"
  ingress_visibility    = "Private"
  fips_enabled          = true

  tags = {
    cost_center = "payments-platform"
    owner       = "vinod"
  }
}

# Downstream: create a private DNS A record for the OpenShift console using the
# cluster's ingress IP, so internal users reach the console over the private link.
resource "azurerm_private_dns_a_record" "console" {
  name                = "console-openshift-console.apps"
  zone_name           = azurerm_private_dns_zone.aro.name
  resource_group_name = azurerm_resource_group.aro.name
  ttl                 = 300
  records             = [module.azure_red_hat_openshift.ingress_ip]
}

With Terragrunt

Terragrunt keeps this module DRY across environments — define the backend and provider once in a root config, then a thin terragrunt.hcl per environment supplies only the inputs that differ.

1. Root configlive/terragrunt.hcl (inherited by every module):

remote_state {
  backend = "azurerm"
  generate = { path = "backend.tf", if_exists = "overwrite" }
  config = {
    # ...azurerm state bucket/container + key per path...
  }
}

2. Module configlive/prod/openshift/terragrunt.hcl:

include "root" {
  path = find_in_parent_folders()
}

terraform {
  source = "git::https://dev.azure.com/teknohut/kloudvin/_git/terraform-modules//terraform-module-azure-openshift?ref=v1.0.0"
}

inputs = {
  cluster_name = "..."
  location = "..."
  resource_group_name = "..."
  resource_group_id = "..."
  environment = "..."
  openshift_version = "..."
  domain = "..."
  pull_secret = "..."
  service_principal_client_id = "..."
  service_principal_client_secret = "..."
  service_principal_object_id = "..."
  virtual_network_id = "..."
  master_subnet_id = "..."
  worker_subnet_id = "..."
}

3. Deploy one environment, or roll out all modules together:

cd live/prod/openshift && terragrunt apply        # this module
terragrunt run-all apply                      # every module under live/prod

Why Terragrunt here: the backend and provider live in one place instead of being copy-pasted into every module; inputs is overridden per environment (dev / stage / prod) without forking the module; and run-all orchestrates dependencies across modules. Reach for it once you have more than one environment or more than a handful of modules — for a single stack, the plain Quickstart above is enough.

Inputs

Name Type Default Required Description
cluster_name string Yes ARO cluster name; 3-30 chars, must start with a letter. Validated.
location string Yes ARO-supported Azure region.
resource_group_name string Yes Existing resource group holding the cluster object.
resource_group_id string Yes RG resource ID, used to scope the SP Contributor assignment.
environment string Yes One of dev, stg, prod, sandbox; applied as a tag.
openshift_version string Yes Full ARO version, e.g. "4.15.27". Validated to >= 4.12.x.
domain string Yes Bare label (→ *.<region>.aroapp.io) or custom FQDN.
pull_secret string (sensitive) Yes Red Hat pull secret JSON; must parse as JSON. Source from a secret store.
managed_resource_group_name string null No Name for the ARO-managed infra resource group.
service_principal_client_id string Yes Cluster service principal application (client) ID.
service_principal_client_secret string (sensitive) Yes Cluster SP client secret; source from Key Vault.
service_principal_object_id string Yes Cluster SP object ID, used for role assignments.
assign_service_principal_roles bool true No Whether the module creates the SP/RP Contributor and Network Contributor assignments.
virtual_network_id string Yes VNet ID containing the subnets; SP and ARO RP get Network Contributor here.
master_subnet_id string Yes Dedicated control-plane subnet ID.
worker_subnet_id string Yes Dedicated worker subnet ID; must differ from master_subnet_id.
disk_encryption_set_id string null No Optional Disk Encryption Set ID for CMK disk encryption on both profiles.
master_profile object(...) {} No Master VM size and encryption-at-host (default Standard_D8s_v5, encryption on).
worker_profile object(...) {} No Worker SKU, count (>= 3), disk size (>= 128 GB), encryption-at-host.
network_profile object(...) {} No Pod/service CIDRs, outbound_type, and preconfigured-NSG toggle.
api_server_visibility string "Private" No API server visibility: Private or Public.
ingress_visibility string "Private" No Default ingress visibility: Private or Public.
fips_enabled bool true No Enable FIPS crypto; immutable after creation.
tags map(string) {} No Extra tags merged onto the cluster.

Outputs

Name Description
id Resource ID of the ARO cluster.
name Name of the ARO cluster.
console_url URL of the OpenShift web console.
api_server_url API server endpoint URL for oc login / kubeconfig.
api_server_ip API server IP (private when api_server_visibility = "Private").
ingress_ip IP of the default ingress controller / router.
version OpenShift version the cluster is running.
managed_resource_group_name ARO-managed resource group holding cluster infra.
worker_subnet_id Subnet ID hosting the worker nodes.

Enterprise scenario

A regulated insurer migrates a legacy Java estate onto OpenShift to keep its Source-to-Image builds and Operators while moving to Azure. The platform team instantiates this module once per region (westeurope, northeurope) inside a hub-and-spoke landing zone, each with api_server_visibility = "Private", ingress_visibility = "Private", fips_enabled = true, and a disk_encryption_set_id backed by a customer-managed key in Key Vault. Because the API and router are private, all developer and CI/CD access flows over ExpressRoute through the hub firewall, and the exported ingress_ip and api_server_ip feed private DNS zones so oc and the OpenShift console resolve internally — satisfying the auditor’s “no public control plane” control without anyone touching the portal.

Best practices

TerraformAzureAzure Red Hat OpenShiftModuleIaC
Need this built for real?

Vinod is a Senior Cloud Architect (22+ yrs) — available for Azure / AWS / GCP architecture, landing zones, and migrations.

Work with me

Comments

Keep Reading