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
- You are standing up multiple ARO clusters (per environment, per region, per business unit) and want them provisioned identically, with the service-principal and network prerequisites wired up the same way every time.
- You need an OpenShift-supported platform — Operators, Routes, the developer console, Source-to-Image builds, OpenShift Pipelines — rather than vanilla Kubernetes, but you want the cluster itself codified rather than click-deployed in the portal.
- You require clusters that are private and compliant by default: a private API endpoint, private ingress, FIPS mode, and host-level disk encryption, so the cluster can survive a financial-services or government audit.
- You are running platform engineering / landing zones and want the cluster’s API and console URLs, the worker subnet, and the cluster resource group exposed as outputs for downstream DNS, private endpoint, Key Vault, and GitOps wiring.
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 config — live/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 config — live/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
- Keep the API server and ingress private. The module defaults
api_server_visibilityandingress_visibilitytoPrivate; pair that with private DNS zones (fed by theapi_server_ip/ingress_ipoutputs) and reach the cluster over ExpressRoute or VPN rather than exposing OpenShift to the internet. - Source the pull secret and SP secret from a vault, never state. Pass
pull_secretandservice_principal_client_secretfromazurerm_key_vault_secretdata sources; both are markedsensitive, but the real win is keeping them out of.tffiles and out of pull requests entirely. - Turn on FIPS and encryption-at-host from day one.
fips_enabledis immutable after creation, so decide up front — the module defaults it on, along withencryption_at_host_enabledon both node profiles, and supports adisk_encryption_set_idfor customer-managed keys when compliance demands CMK. - Right-size masters and workers deliberately. ARO only permits specific control-plane SKUs and a minimum of three workers; the module validates
node_count >= 3anddisk_size_gb >= 128, but pick worker SKUs (e.g.Standard_D8s_v5) and disk sizes that match real density to avoid both throttling and over-spend on always-on VMs. - Give the resource provider its network rights, but plan the long apply. The module grants
Network Contributorto both the cluster SP and theAzure Red Hat OpenShift RPon the VNet and sets generous 90-minute timeouts — ARO creates in ~35 minutes and destroys in ~30, so never wrap it in a tight pipeline timeout. - Name and tag predictably across the estate. Use an
aro-<workload>-<env>-<region>convention forcluster_name, set a meaningfulmanaged_resource_group_name, and rely on the mergedtags(which includeenvironmentandmanaged_by) so cost reports, policy assignments, and the two resource groups per cluster stay legible.