Skip to content

Latest commit

Β 

History

1 Commit

Folders and files

NameName
Last commit message
Last commit date
Β 
Β 
Β 
Β 
Β 
Β 
Β 
Β 
Β 
Β 
Β 
Β 
Β 
Β 
Β 
Β 

Repository files navigation

☁️ Azure Oracle Autonomous Database Terraform Module

Creates an Oracle Autonomous Database on Oracle Database@Azure, plus the user-initiated backups that belong to it (azurerm_oracle_autonomous_database). Targets hashicorp/azurerm ~> 4.0.

Terraform Provider Module Type Resources Posture

🧩 Overview

  • πŸ—„οΈ Creates an Autonomous Database and, as for_each children, the user-initiated backups that belong to it.
  • πŸšͺ The network posture is a one-way door. The provider documents that an explicitly empty allowed_ips makes the database reachable from any IP address β€” and that it cannot be updated afterwards.
  • πŸ›‘ So the module fails closed. A configuration stating no posture is rejected at plan time, with the three choices named in the error. There is no safe default to fall back on.
  • ✍️ The irreversible posture has its own typed-out boolean, so it cannot be reached by leaving a collection empty by accident.
  • πŸ”’ mtls_connection_required defaults to true β€” a password alone is not enough to connect.
  • 🧬 Shape note: the two ..._clone_from_* resources are peers, not children β€” each imports to the same ARM type this keystone uses.
  • πŸ—ƒοΈ Three different backup mechanisms exist here and are easy to confuse. Removing an entry from the backups map destroys that backup.

πŸ’‘ Why it matters: This suite's usual rule is that the empty call produces the safe resource. That rule has no answer here, because every network option is a deliberate posture and the laziest one is permanent. Refusing to create anything is the safe behaviour when the alternative is silently creating something publicly reachable that cannot be walked back β€” so this is the one module in the library that declines to guess.

❀️ Support this project

If this module saves you time, please consider supporting its continued development:


πŸ—ΊοΈ Where this fits in the family

flowchart LR
  rg["terraform-azurerm-resource-group"]
  vnet["terraform-azurerm-virtual-network: the subnet must be DELEGATED for Oracle Database@Azure"]
  kv["terraform-azurerm-key-vault: holds the admin_password, read at plan time"]
  nsg["terraform-azurerm-network-security-group: the only thing guarding the SCAN listener ports"]
  anchor["terraform-azurerm-oracle-resource-anchor: links the resource group to an OCI COMPARTMENT. No location input, it is computed."]
  exa["terraform-azurerm-oracle-exadata-infrastructure: the RACK. Every argument force-new except tags."]
  vault["terraform-azurerm-oracle-exascale-database-storage-vault: sized ONCE, there is NO grow operation"]
  cluster["terraform-azurerm-oracle-cloud-vm-cluster: the GI cluster. Use hostname_actual, not hostname, for connections."]
  dbsrv["azurerm_oracle_db_servers DATA SOURCE: OCI OCIDs that do not exist until the rack does"]
  adb["terraform-azurerm-oracle-autonomous-database: keystone plus for_each BACKUPS. Network posture is a ONE-WAY DOOR."]
  cfb["terraform-azurerm-oracle-autonomous-database-clone-from-backup: a POINT-IN-TIME restore into a NEW database"]
  cfd["terraform-azurerm-oracle-autonomous-database-clone-from-database: a clone of CURRENT state, optionally refreshable"]
  note["PEERS, NOT CHILDREN: both clones import to the SAME Oracle.Database/autonomousDatabases path the keystone uses, so each IS an autonomous database"]

  rg -->|"resource_group_name"| anchor
  rg -->|"resource_group_name, location"| exa
  rg -->|"resource_group_name, location"| vault
  rg -->|"resource_group_name, location"| cluster
  rg -->|"resource_group_name, location"| adb
  anchor -->|"REQUIRED FIRST, but NO module consumes its id, so use depends_on"| exa
  anchor -->|"same invisible dependency"| adb
  exa -->|"id as cloud_exadata_infrastructure_id"| cluster
  exa -->|"read AFTER it exists"| dbsrv
  dbsrv -->|"OCIDs as db_servers"| cluster
  vault -->|"NO Terraform link exists. Aligned by REGION and ZONE only."| cluster
  vnet -->|"id plus a delegated subnet id"| cluster
  vnet -->|"id plus subnet id, the PRIVATE posture"| adb
  nsg -->|"restricts the unencrypted SCAN port 1521"| cluster
  kv -->|"secret read as admin_password"| adb
  kv -->|"secret read as admin_password"| cfb
  kv -->|"secret read as admin_password"| cfd
  adb -->|"id as source_autonomous_database_id"| cfb
  adb -->|"id as source_autonomous_database_id"| cfd
  note -->|"why they are separate modules"| cfb
  note -->|"why they are separate modules"| cfd

  classDef me fill:#0078D4,stroke:#004578,color:#fff;
  classDef keystone fill:#004578,stroke:#001f3f,color:#fff;
  classDef sib fill:#eef2f7,stroke:#b8c4d0,color:#1b1b1b;
  class exa keystone;
  class anchor,vault,cluster,adb,cfb,cfd me;
  class rg,vnet,kv,nsg,dbsrv,note sib;
Loading

🧬 What this module builds

flowchart TB
  door["READ THIS FIRST: THE NETWORK POSTURE IS A ONE-WAY DOOR."]
  empty["the provider documents that setting allowed_ips to an EXPLICITLY EMPTY LIST makes the database reachable from ANY IP ADDRESS"]
  perm["AND that allowed_ips CANNOT BE UPDATED after provisioning the resource that way. There is no route back except REPLACING the database."]
  lazy["so the LAZY value is the DANGEROUS one, which inverts this suite's usual secure-default rule: there is no safe default available here"]
  fail["therefore THE MODULE FAILS CLOSED. A configuration stating no posture is REJECTED AT PLAN TIME, with the three choices named in the error."]
  three["THE THREE POSTURES: private, sets subnet_id AND virtual_network_id. Public with an allow-list, sets allowed_ips non-empty. Public from any IP, sets public_access_from_any_ip_enabled true."]
  typed["the irreversible one has its OWN TYPED-OUT BOOLEAN, so it cannot be reached by leaving a collection empty by accident, and a code review sees WORDS rather than an absence"]
  reject["and an empty allowed_ips is REJECTED, pointing the caller at that boolean instead"]
  placed["the cross-field checks are PLACED, not gathered: Terraform requires each condition to name its OWN variable AND rejects validations that reference one another, so the graph is kept acyclic with every condition naming itself"]
  mtls["mtls_connection_required DEFAULTS TO TRUE. False means a password alone authenticates, which matters most when the database is publicly reachable."]
  secret["admin_password is REQUIRED with NO managed-identity alternative, so the secret is unavoidable. Marked sensitive, never emitted, and it STILL lands in state in plaintext like any provider input."]
  children["OWNED CHILDREN: user-initiated backups, for_each over a keyed map. A backup takes autonomous_database_id and nothing else identifying, so it is a GENUINE child."]
  peers["NOT OWNED: both clone resources. Each takes its own resource_group_name, location and name, and imports to the SAME Oracle.Database/autonomousDatabases path this keystone uses, so each IS a database. PEERS, not children."]
  threebk["THREE BACKUP MECHANISMS, easily confused: backup_retention_period_in_days for Oracle's automatic backups, long_term_backup_schedule for a multi-year series with a 90-DAY FLOOR, and the backups map for specific NAMED artefacts this module owns."]
  evidence["and removing an entry from that map DESTROYS that backup. For one taken to satisfy a retention obligation, that is a DELETION OF EVIDENCE."]
  this["terraform-azurerm-oracle-autonomous-database"]
  keystone["azurerm_oracle_autonomous_database.this"]
  bk["azurerm_oracle_autonomous_database_backup for_each"]

  door -->|"why"| empty
  empty -->|"and"| perm
  perm -->|"so"| lazy
  lazy -->|"therefore"| fail
  fail -->|"the choices"| three
  three -->|"and"| typed
  typed -->|"so"| reject
  reject -->|"how it is enforced"| placed
  placed -->|"validation"| this
  mtls -->|"defence in depth"| this
  secret -->|"credentials"| this
  children -->|"owned"| this
  peers -->|"shape note"| this
  threebk -->|"and"| evidence
  evidence -->|"retention"| this
  this -->|"creates"| keystone
  this -->|"creates"| bk

  classDef me fill:#0078D4,stroke:#004578,color:#fff;
  classDef keystone fill:#004578,stroke:#001f3f,color:#fff;
  classDef sib fill:#eef2f7,stroke:#b8c4d0,color:#1b1b1b;
  class this me;
  class keystone,bk keystone;
  class door,empty,perm,lazy,fail,three,typed,reject,placed,mtls,secret,children,peers,threebk,evidence sib;
Loading

Resource inventory

Resource Count Notes
azurerm_oracle_autonomous_database.this 1 The keystone.
azurerm_oracle_autonomous_database_backup.this 0..n for_each over a keyed map. A genuine child.
long_term_backup_schedule block 0..1 Optional. 90-day retention floor.

βœ… Provider / Versions

Requirement Value
Terraform >= 1.12.0
hashicorp/azurerm ~> 4.0
Azure resource providers Oracle.Database (the database) and Microsoft.Network (the subnet join, private path only)
Provider block None in this module. The caller configures provider "azurerm", including the mandatory features {} block, and supplies authentication.

Schema notes that bite β€” confirmed against the live provider schema and its documentation:

  • allowed_ips set to an explicitly empty list means "reachable from any IP address", and it cannot be updated afterwards. The single most consequential fact about this resource. The service caps the list at 1024 entries and supports IPv4 only.
  • national_character_set, subnet_id, virtual_network_id and customer_contacts are force-new.
  • national_character_set accepts only AL16UTF16 and UTF8, defaulting to AL16UTF16.
  • long_term_backup_schedule.retention_period_in_days ranges from 90 to 2558 days β€” a 90-day floor, so this is the multi-year mechanism, not the day-to-day one.
  • repeat_cadence accepts Weekly, Monthly, Yearly and OneTime, anchoring on time_of_backup.
  • license_model must be null on dedicated Exadata infrastructure, where it is set at the infrastructure level. This module requires the field, so that deployment is not this module's shape.
  • admin_password is required with no managed-identity alternative.
  • The two ..._clone_from_* resources import to /providers/Oracle.Database/autonomousDatabases/<name> β€” the same ARM type as this keystone.
  • lifecycle is not valid inside a module block, so a caller cannot add ignore_changes.

πŸ”‘ Required Azure RBAC Roles / Permissions

Scope Role / permission Why
The resource group Contributor, or a custom role with Oracle.Database/autonomousDatabases/* Creating the database is a write against the Oracle.Database provider.
The subnet (private path only) Network Contributor, or Microsoft.Network/virtualNetworks/subnets/join/action The database joins the subnet. This one is a Microsoft namespace.
The Key Vault secret holding the admin password Key Vault Secrets User for the identity running Terraform Reading the password at plan time.

⚠️ Note the mixed namespaces: the Oracle resource needs Oracle.Database/* and the subnet join needs Microsoft.Network/*. A role covering only one fails at a confusing point in the apply.

πŸ”’ Whoever can apply this configuration can make the database publicly reachable, permanently. That is a control decision rather than a connectivity one, and it deserves the review a control decision gets.

Azure Prerequisites

  • The Oracle.Database resource provider registered on the subscription.
  • Oracle Database@Azure onboarding completed for the tenant.
  • An Oracle Resource Anchor in the target resource group.
  • A region where Oracle Database@Azure is offered.
  • A decided network posture. The module will not apply without one β€” see example 1.
  • An admin password provisioned out of band, with the identity running Terraform able to read it.
  • A licensing decision. BringYourOwnLicense asserts an entitlement this module cannot verify.

πŸ“ Module Structure

terraform-azurerm-oracle-autonomous-database/
β”œβ”€β”€ providers.tf   # required_version + the pinned azurerm provider. No provider block.
β”œβ”€β”€ variables.tf   # identity, the three-posture network inputs, admin_password (sensitive),
β”‚                  # sizing, licensing, characteristics, backup inputs, backups map, tags, timeouts
β”œβ”€β”€ main.tf        # the keystone database + for_each backups
β”œβ”€β”€ outputs.tf     # id, backup maps, sizing, and seven derived posture signals
β”œβ”€β”€ README.md      # this document
β”œβ”€β”€ SCOPE.md       # the cross-module contract, including the shape note
β”œβ”€β”€ LICENSE        # MIT
└── .gitignore

βš™οΈ Quick Start

provider "azurerm" {
  features {}
}

data "azurerm_key_vault_secret" "adb_admin" {
  name         = "oracle-adb-admin"
  key_vault_id = module.kv.id
}

module "adb" {
  source = "git::https://github.com/microsoftexpert/terraform-azurerm-oracle-autonomous-database.git?ref=v1.0.0"

  name                = "adb-orders-prod"
  resource_group_name = module.rg.name
  location            = module.rg.location
  display_name        = "Orders Production"

  # Posture 1 β€” private. Required, or the plan is rejected. See example 1.
  subnet_id          = module.vnet.subnet_ids["oracle"]
  virtual_network_id = module.vnet.id

  admin_password = data.azurerm_key_vault_secret.adb_admin.value

  compute_model            = "ECPU"
  compute_count            = 4
  data_storage_size_in_tbs = 1
  license_model            = "LicenseIncluded"

  db_workload   = "OLTP"
  db_version    = "19c"
  character_set = "AL32UTF8"
}

ℹ️ The caller configures the provider, its authentication, and the mandatory features {} block. This module declares none of them.

⚠️ Omit the two network inputs and the plan is rejected, not defaulted. That is deliberate β€” example 1.

πŸ”Œ Cross-Module Contract

Consumes

Input Type Source module
resource_group_name string terraform-azurerm-resource-group β†’ name
subnet_id / virtual_network_id string terraform-azurerm-virtual-network β†’ subnet id / id
admin_password string (sensitive) a Key Vault secret read β€” never a literal
compute_model / compute_count string / number caller decision
license_model string caller decision β€” an entitlement assertion
db_workload / db_version / character_set string caller decision

Emits

Output Description Consumed by
id The database's Resource ID. both clone modules β†’ source_autonomous_database_id
name / display_name Identity. review
backup_ids / backup_names Maps of the user-initiated backups owned here. evidence, review
compute_model / compute_count / data_storage_size_in_tbs Sizing. cost review
license_model The licensing model in force. licensing review
backup_retention_period_in_days Retention for Oracle's automatic backups. recovery review
network_posture Derived β€” private, public-with-allow-list, or public-any-ip. the assertion that matters most
is_publicly_reachable_from_any_ip Derived β€” true is irreversible. policy checks
mtls_connection_required Whether client certificates are required. security review
allowed_ip_count Derived β€” allow-list size, or null on the private path. security review
autoscaling Derived β€” whether compute and storage may exceed declared sizes. cost review
has_long_term_backup_schedule Derived β€” multi-year retention configured. compliance review
owns_user_initiated_backups Derived β€” named backups a destroy would remove. evidence review

πŸ“š Example Library

1 Β· πŸšͺ The one-way door, and why this module fails closed
# ❌ Rejected at plan. No posture stated.
module "adb" {
  name                = "adb-orders-prod"
  resource_group_name = module.rg.name
  location            = "eastus"
  display_name        = "Orders"
  admin_password      = data.azurerm_key_vault_secret.adb_admin.value
  # ...and no subnet_id, no allowed_ips, no public_access_from_any_ip_enabled
}
Error: Invalid value for variable

  A network posture must be stated explicitly. Choose one: (1) private β€” set both
  subnet_id and virtual_network_id; (2) public with an allow-list β€” set
  allowed_ips; (3) public from any IP β€” set public_access_from_any_ip_enabled =
  true, which is PERMANENT and cannot be changed after provisioning.

⚠️ Why this module refuses to guess. The provider documents that allowed_ips set to an explicitly empty list gives "secure public access to the database from any IP address" β€” and that allowed_ips cannot be updated after provisioning the resource with an empty list. The lazy value is the dangerous one, and it is permanent.

πŸ’‘ This suite's usual rule is that the empty call produces the safe resource. Here there is no safe default: every option is a deliberate posture. Refusing to create anything is the safe behaviour when the alternative is silently creating something publicly reachable that cannot be walked back.

ℹ️ This is the only module in the library that declines to apply without an explicit security decision. That is a considered exception, not an oversight.

2 Β· Posture 1 β€” private, and the one to prefer
subnet_id          = module.vnet.subnet_ids["oracle"]
virtual_network_id = module.vnet.id
# allowed_ips left null; public_access_from_any_ip_enabled left false
output "posture" {
  value = module.adb.network_posture # => "private"
}

πŸ”’ Reachable only inside the virtual network. No public address, so no allow-list to maintain and nothing to get wrong later.

⚠️ Both inputs must be set together β€” a subnet reference without its virtual network does not describe a placement, and the plan says so:

Error: Invalid value for variable

  subnet_id and virtual_network_id must be set together β€” a subnet reference
  without its virtual network does not describe a placement.

ℹ️ Both are force-new, so a database does not move networks.

3 Β· Posture 2 β€” public with an allow-list
allowed_ips = ["203.0.113.10", "198.51.100.14", "198.51.100.15"]
# NOT a CIDR: this resource validates each entry with IsIPv4Address, so "198.51.100.0/24"
# is refused - and so is a /32. List the addresses, or use the private path.
# subnet_id and virtual_network_id left null

ℹ️ The database is publicly addressable but only the listed addresses may connect. IPv4 only, and the service caps the list at 1024 entries β€” both validated.

πŸ’‘ Weaker than a private endpoint. Prefer posture 2 only for clients that genuinely cannot reach the virtual network, and treat the list as something that needs maintaining.

⚠️ Unlike posture 3, an allow-list can be changed later β€” it is only the empty-list case that is permanent. So this posture leaves you room to tighten.

ℹ️ allowed_ip_count is emitted so a review can see the list's size without reading it.

4 Β· ⚠️ Posture 3 β€” public from any IP, and permanent
public_access_from_any_ip_enabled = true
# renders allowed_ips = [], which the provider treats as "any IP"
# ❌ Rejected β€” do not try to express it by emptying the list.
allowed_ips = []
Error: Invalid value for variable

  allowed_ips must not be an empty set. An empty list is how the provider
  expresses "publicly reachable from any IP address", it CANNOT be changed after
  provisioning, and it therefore requires public_access_from_any_ip_enabled = true
  instead β€” leave allowed_ips null.

πŸ”’ The separate boolean exists so the permanent choice cannot be made by accident. An empty collection is easy to arrive at β€” a for expression that filtered everything out, a variable that defaulted to []. A boolean spelled public_access_from_any_ip_enabled = true is not.

πŸ’‘ It also means a code review sees words rather than an absence. allowed_ips = [] and allowed_ips being missing look almost identical in a diff; this does not.

⚠️ Before setting this on anything holding regulated or nonpublic data, get the decision reviewed by whoever owns that data's handling rules. A publicly-addressable database is a control decision, and this one cannot be walked back.

ℹ️ is_publicly_reachable_from_any_ip is emitted for exactly that policy check.

5 Β· The postures are mutually exclusive
# ❌ Rejected β€” contradictory.
allowed_ips                       = ["203.0.113.10"]
public_access_from_any_ip_enabled = true
# ❌ Rejected β€” private and public at once.
subnet_id                         = module.vnet.subnet_ids["oracle"]
virtual_network_id                = module.vnet.id
public_access_from_any_ip_enabled = true

ℹ️ Both combinations are caught at plan time, with the reason named rather than a generic type error.

πŸ’‘ How the checks are arranged is worth knowing if you read the module. Terraform requires every validation condition to reference its own variable, and rejects validations that reference one another β€” so the cross-field checks cannot all be gathered onto one variable. They are placed instead: allowed_ips reads the other three, public_access_from_any_ip_enabled reads the two placement fields, subnet_id reads virtual_network_id. Every condition names itself, and the reference graph stays acyclic.

6 Β· πŸ”’ mTLS defaults to required
mtls_connection_required = true # the default
mtls_connection_required = false # ⚠️ a password alone authenticates

πŸ”’ true means clients must present a certificate from the database's wallet, so possession of a password is not sufficient to connect. That is this module's default.

⚠️ Combine false with posture 3 and a leaked password is enough to reach the data from anywhere on the internet. The two settings compound, which is why the caller has to type false to get it.

πŸ’‘ If the source of a connection cannot handle a wallet, that is usually an argument for posture 1 rather than for turning mTLS off.

7 Β· πŸ”’ The admin password is unavoidable β€” handle it accordingly
data "azurerm_key_vault_secret" "adb_admin" {
  name         = "oracle-adb-admin"
  key_vault_id = module.kv.id
}

module "adb" {
  admin_password = data.azurerm_key_vault_secret.adb_admin.value
}
# ❌ Never.
admin_password = "Sup3rS3cret!"

πŸ”’ Provision it out of band and pass a reference. Read it from Key Vault at plan time, or supply it through a CI secret. It is marked sensitive = true here and this module never emits it.

⚠️ It will still be written to Terraform state in plaintext. That is a property of Terraform, not of this module β€” any value passed to a provider is recorded in state. Treat the state backend as a secret store: encrypted at rest, access-controlled, and never a local file in a repository.

ℹ️ The provider requires this field and offers no managed-identity alternative for it, so the secret cannot be designed away. Rotating it afterwards is an Oracle-side operation, not a Terraform one.

πŸ’‘ Validated as non-empty only. Oracle's complexity rules are service-side, and reproducing them here would risk rejecting a password the service accepts.

8 Β· 🧬 Shape note β€” the clones are peers, not children
# NOT owned here. Each of these creates a WHOLE NEW database.
# A clone is NOT a thin reference to its source: it carries the full database specification of its
# own, fourteen required arguments included. That is the clearest evidence these are separate modules.
module "adb_clone" {
  source = "git::https://github.com/microsoftexpert/terraform-azurerm-oracle-autonomous-database-clone-from-database.git?ref=v1.0.0"

  source_autonomous_database_id = module.adb.id
  clone_type                    = "FULL"

  name                = "adb-orders-reporting"
  resource_group_name = module.rg.name
  location            = module.rg.location
  display_name        = "Orders Reporting Clone"
  admin_password      = data.azurerm_key_vault_secret.clone_admin.value # NOT the source's

  compute_model           = "ECPU"
  compute_count           = 2
  data_storage_size_in_tb = 1
  license_model           = "LicenseIncluded"
  database_workload       = "DW"
  database_version        = "19c"
  character_set           = "AL32UTF8"
}

module "adb_restore" {
  source = "git::https://github.com/microsoftexpert/terraform-azurerm-oracle-autonomous-database-clone-from-backup.git?ref=v1.0.0"

  source_autonomous_database_id = module.adb.id
  clone_type                    = "FULL"

  name                = "adb-orders-pitr"
  resource_group_name = module.rg.name
  location            = module.rg.location
  display_name        = "Orders Point-In-Time Restore"
  admin_password      = data.azurerm_key_vault_secret.clone_admin.value

  compute_model           = "ECPU"
  compute_count           = 2
  data_storage_size_in_tb = 1
  license_model           = "LicenseIncluded"
  database_workload       = "DW"
  database_version        = "19c"
  character_set           = "AL32UTF8"
}

ℹ️ Three resource types carry this keystone's name as a prefix, so a reader has every reason to expect all three to be owned here. Only one is.

Resource Relationship
azurerm_oracle_autonomous_database_backup genuine child β€” takes autonomous_database_id and nothing else identifying
azurerm_oracle_autonomous_database_clone_from_backup peer β€” its own module
azurerm_oracle_autonomous_database_clone_from_database peer β€” its own module

πŸ’‘ The decisive evidence is the import path. Both clones import to /providers/Oracle.Database/autonomousDatabases/<name> β€” the same ARM type this keystone uses. Each clone is an autonomous database, taking its own resource_group_name, location, name and full sizing.

⚠️ Rendering them as for_each children here would mean a child that creates a sibling of its own keystone. That is why they live in their own modules.

9 · ⚠️ Three backup mechanisms, easily confused
# 1 Β· Oracle's automatic backups β€” retention only.
backup_retention_period_in_days = 30

# 2 Β· A scheduled long-term series. 90-DAY FLOOR.
long_term_backup_schedule = {
  enabled                  = true
  repeat_cadence           = "Monthly"
  time_of_backup           = "2026-01-24T00:09:00Z"
  retention_period_in_days = 365
}

# 3 Β· Specific NAMED artefacts this module owns.
backups = {
  pre_upgrade = { retention_period_in_days = 60 }
  quarter_end = { retention_period_in_days = 90 }
}

ℹ️ They are three different things. (1) governs the backups Oracle takes on its own. (2) schedules a long-term series β€” and its 90-day floor makes it the multi-year retention mechanism, not the day-to-day one. (3) makes a specific, named backup part of the declared infrastructure.

Error: Invalid value for variable

  long_term_backup_schedule.retention_period_in_days must be between 90 and 2558
  days. Long-term backups have a 90-day floor; use
  backup_retention_period_in_days for shorter retention.

πŸ’‘ Use (3) for a pre-upgrade or period-end snapshot that must demonstrably exist. It is not a substitute for (1).

⚠️ repeat_cadence anchors on time_of_backup β€” a Weekly cadence with a Tuesday timestamp runs every Tuesday. Legal values are Weekly, Monthly, Yearly, OneTime.

10 · ⚠️ Removing a backup from the map destroys it
backups = {
  pre_upgrade = { retention_period_in_days = 60 }
  # quarter_end removed...
}
# module.adb.azurerm_oracle_autonomous_database_backup.this["quarter_end"] will be destroyed

⚠️ For a backup taken to satisfy a retention obligation, that is a deletion of evidence. Treat a plan that destroys one the way you would treat any data deletion β€” because that is what it is.

πŸ’‘ Keyed by a stable identifier rather than by name, so renaming a backup's display name never re-indexes the map and never triggers an unintended destroy.

ℹ️ owns_user_initiated_backups is emitted so a review can tell at a glance whether a module instance has named artefacts a destroy would remove.

πŸ”’ Where a records-retention policy drives which backups must exist and for how long, that policy β€” not this module β€” is the authority. Confirm the numbers with whoever owns it.

11 Β· Auto-scaling defaults to off, deliberately
auto_scaling_enabled             = false # the default
auto_scaling_for_storage_enabled = false # the default

πŸ’‘ The predictable-cost default, not the resilient one β€” and that trade-off is stated rather than decided silently. With auto-scaling off, a load spike is throttled rather than billed; with it on, Oracle may scale compute well above the base allocation and the bill follows.

⚠️ Storage has the sharper edge. Running out of storage is a hard failure, whereas running out of compute is a slowdown. If you leave storage auto-scaling off, monitor headroom.

πŸ’‘ Turn compute auto-scaling on for a workload where a throttle would be worse than a cost surprise β€” which is most production OLTP. Leave it off where the budget is the harder constraint. Which is better genuinely depends on that, which is why the module states the consequence instead of picking for you.

ℹ️ autoscaling is emitted as a two-key object so a cost review sees both at once.

12 Β· What a review should assert
output "adb_posture" {
  value = {
    posture   = module.adb.network_posture                    # expect "private"
    any_ip    = module.adb.is_publicly_reachable_from_any_ip  # expect false β€” irreversible if true
    mtls      = module.adb.mtls_connection_required           # expect true
    acl_size  = module.adb.allowed_ip_count                   # null on the private path
    scaling   = module.adb.autoscaling
    long_term = module.adb.has_long_term_backup_schedule
    evidence  = module.adb.owns_user_initiated_backups
  }
}

πŸ”’ The rules worth encoding: any_ip == true requires explicit recorded justification and cannot be undone; mtls == false requires the same; and posture != "private" should have a stated reason.

πŸ’‘ network_posture is emitted as a three-valued string rather than only as booleans, because the review question is "which of the three is this?" and a string answers it directly.

ℹ️ Seven derived signals is a lot for one module, and that is proportionate: this is the module where a wrong value is least recoverable.

13 Β· πŸ—οΈ End-to-end composition
provider "azurerm" {
  features {}
}

data "azurerm_client_config" "current" {}

module "rg" {
  source = "git::https://github.com/microsoftexpert/terraform-azurerm-resource-group.git?ref=v1.0.0"

  name     = "rg-oracle-prod"
  location = "eastus"
}

module "oracle_anchor" {
  source = "git::https://github.com/microsoftexpert/terraform-azurerm-oracle-resource-anchor.git?ref=v1.0.0"

  name                = "anchor-oracle-prod"
  resource_group_name = module.rg.name
}

module "vnet" {
  source = "git::https://github.com/microsoftexpert/terraform-azurerm-virtual-network.git?ref=v1.0.0"

  name                = "vnet-oracle-prod"
  resource_group_name = module.rg.name
  location            = module.rg.location
  address_space       = ["10.10.0.0/16"]
  # ...with an "oracle" subnet
}

# The admin password lives here, not in configuration.
module "kv" {
  source = "git::https://github.com/microsoftexpert/terraform-azurerm-key-vault.git?ref=v1.0.0"

  name                = "kv-oracle-prod"
  resource_group_name = module.rg.name
  location            = module.rg.location
  tenant_id           = data.azurerm_client_config.current.tenant_id
}

data "azurerm_key_vault_secret" "adb_admin" {
  name         = "oracle-adb-admin" # provisioned out of band
  key_vault_id = module.kv.id
}

# 1 Β· The database. Private posture, mTLS required, predictable cost.
module "adb" {
  source = "git::https://github.com/microsoftexpert/terraform-azurerm-oracle-autonomous-database.git?ref=v1.0.0"

  name                = "adb-orders-prod"
  resource_group_name = module.rg.name
  location            = module.rg.location
  display_name        = "Orders Production"

  # Posture 1 β€” the private path.
  subnet_id          = module.vnet.subnet_ids["oracle"]
  virtual_network_id = module.vnet.id

  mtls_connection_required = true # the default, stated for the reviewer

  admin_password = data.azurerm_key_vault_secret.adb_admin.value

  compute_model            = "ECPU"
  compute_count            = 4
  data_storage_size_in_tbs = 1
  license_model            = "LicenseIncluded" # no entitlement to assert

  db_workload   = "OLTP"
  db_version    = "19c"
  character_set = "AL32UTF8"

  # Production OLTP: a throttle is worse than a cost surprise.
  auto_scaling_enabled = true

  # Oracle's automatic backups...
  backup_retention_period_in_days = 30

  # ...plus a long-term series for the multi-year requirement.
  long_term_backup_schedule = {
    enabled                  = true
    repeat_cadence           = "Monthly"
    time_of_backup           = "2026-01-24T00:09:00Z"
    retention_period_in_days = 365
  }

  # ...plus a named artefact that must demonstrably exist.
  backups = {
    pre_upgrade_2026q1 = { retention_period_in_days = 90 }
  }

  customer_contacts = ["oracle-platform@example.com"] # a team alias β€” force-new

  tags = { workload = "oracle-database", data_class = "internal" }

  depends_on = [module.oracle_anchor]
}

# 2 Β· A reporting clone β€” a PEER module, sized down, and no weaker than its source.
module "adb_reporting" {
  source = "git::https://github.com/microsoftexpert/terraform-azurerm-oracle-autonomous-database-clone-from-database.git?ref=v1.0.0"

  # NOTE the singular `_in_tb` here. The provider names this attribute
  # `data_storage_size_in_tb` on the CLONE resources and `data_storage_size_in_tbs`
  # on the base autonomous database, and each module mirrors its own resource -- so
  # the name that works above does NOT work here.
  data_storage_size_in_tb = 1

  source_autonomous_database_id = module.adb.id
  clone_type                    = "FULL"
  refreshable_model             = "Automatic"

  name                = "adb-orders-reporting"
  resource_group_name = module.rg.name
  location            = module.rg.location
  display_name        = "Orders Reporting Clone"

  subnet_id                = module.vnet.subnet_ids["oracle"] # same private posture
  virtual_network_id       = module.vnet.id
  mtls_connection_required = true

  admin_password = data.azurerm_key_vault_secret.adb_admin.value # a DIFFERENT secret in practice

  compute_model     = "ECPU"
  compute_count     = 2 # sized down β€” the main lever on clone cost
  license_model     = "LicenseIncluded"
  database_workload = "DW" # note: database_workload, not db_workload
  database_version         = "19c"
  character_set            = "AL32UTF8"
}

output "adb_posture" {
  value = {
    primary_posture = module.adb.network_posture                   # expect "private"
    primary_any_ip  = module.adb.is_publicly_reachable_from_any_ip # expect false
    clone_posture   = module.adb_reporting.network_posture         # expect "private"
    evidence        = module.adb.backup_names
  }
}

πŸ”’ What the composition gets right: both databases take the private posture, both require mTLS, the password comes from Key Vault rather than configuration, the clone is sized down, all three backup mechanisms are used for what each is for, and the notification address is a team alias because that field is force-new.

⚠️ Note the argument names change in step 2 β€” database_workload and database_version, not db_workload and db_version. The provider is inconsistent between the keystone and the clones, and the clone modules follow the provider rather than harmonising.

⚠️ What no plan will tell you: whether the subnet exists in a region offering Oracle Database@Azure, whether the BringYourOwnLicense entitlement exists were it asserted, or whether the retention numbers satisfy the policy that drives them. That last one belongs to whoever owns the policy.

πŸ“₯ Inputs

Input Type Default Notes
name / resource_group_name / location / display_name string β€” Required.
allowed_ips set(string) null Posture 2. ⚠️ Empty set rejected. Max 1024, IPv4.
public_access_from_any_ip_enabled bool false Posture 3. ⚠️ Irreversible.
subnet_id / virtual_network_id string null Posture 1. Set together. Force-new.
mtls_connection_required bool true πŸ”’ The locked-down default.
admin_password string β€” Required, sensitive. πŸ”’ Out of band.
compute_model / compute_count string / number β€” Required. Not validated / > 0.
data_storage_size_in_tbs number β€” Required. Positive whole number.
auto_scaling_enabled bool false Predictable-cost default.
auto_scaling_for_storage_enabled bool false Same, sharper edge.
license_model string β€” Required. ⚠️ An entitlement assertion.
db_workload / db_version / character_set string β€” Required. Not validated β€” service catalogues.
national_character_set string "AL16UTF16" AL16UTF16 / UTF8, validated. Force-new.
backup_retention_period_in_days number 30 Oracle's automatic backups.
long_term_backup_schedule object(...) null 90–2558 days. Cadence validated.
backups map(object(...)) {} Owned children. ⚠️ Removing one destroys it.
customer_contacts list(string) [] ⚠️ Force-new. Team alias.
tags map(string) {} Azure-side only.
timeouts object(...) null create / read / update / delete.
Full schemas
variable "allowed_ips" {
  type    = set(string)
  default = null
  # ⚠️ Do NOT express "public from anywhere" by passing an empty set. That posture is PERMANENT and
  #    has its own input: public_access_from_any_ip_enabled.
  validation {
    condition     = var.allowed_ips == null ? true : length(var.allowed_ips) > 0
    error_message = "allowed_ips must not be an empty set. An empty list is how the provider expresses ..."
  }
  # ...plus a 1024 cap, an IPv4 shape check, a mutual-exclusion check against
  # public_access_from_any_ip_enabled, and the posture-required check.
}

variable "public_access_from_any_ip_enabled" {
  type    = bool
  default = false
  # ⚠️ THIS IS IRREVERSIBLE. It renders allowed_ips = [], which the provider documents as public
  #    access from any IP address that CANNOT be updated after provisioning.
  # πŸ”’ A separate typed-out boolean so the posture cannot be reached by leaving a list empty.
  # Terraform requires a validation condition to reference its own variable, so the
  # public-versus-private conflict check lives here rather than alongside the others.
  validation {
    condition     = !(var.public_access_from_any_ip_enabled && (var.subnet_id != null || var.virtual_network_id != null))
    error_message = "public_access_from_any_ip_enabled cannot be combined with subnet_id or virtual_network_id ..."
  }
}

variable "admin_password" {
  type      = string
  sensitive = true
  # πŸ”’ Provision out of band and pass a reference β€” do not commit it.
  # ⚠️ It will STILL be written to state in plaintext, as any provider input is. Treat the state
  #    backend as a secret store.
  # Validated non-empty only: Oracle's complexity rules are service-side, and reproducing them here
  # would risk rejecting a password the service accepts.
}

🧾 Outputs

Output Description Sensitive
id The database's Resource ID. no
name / display_name / location Identity. no
backup_ids / backup_names The owned user-initiated backups. no
compute_model / compute_count / data_storage_size_in_tbs Sizing. no
license_model The licensing model. no
backup_retention_period_in_days Automatic-backup retention. no
network_posture Derived β€” the three-valued posture. no
is_publicly_reachable_from_any_ip Derived β€” irreversible when true. no
mtls_connection_required Whether client certificates are required. no
allowed_ip_count Derived β€” allow-list size, or null. no
autoscaling Derived β€” compute and storage ceilings. no
has_long_term_backup_schedule Derived β€” multi-year retention. no
owns_user_initiated_backups Derived β€” named artefacts exist. no

πŸ”’ No plaintext secret is emitted. admin_password is sensitive = true on input and appears in no output, and the module emits no wallet or connection string.

🧠 Architecture Notes

  • The module fails closed on network posture, and that is a considered exception to this suite's own rule. The rule says the empty call produces the safe resource; here no safe default exists, because every option is a deliberate posture and the laziest one is permanent. Rejecting a configuration that states none β€” with the three choices named in the error β€” is the safe behaviour when the alternative is silently creating something public that cannot be undone.

  • The irreversible posture has its own typed-out boolean. Rendering allowed_ips = [] is how the provider expresses it, so an empty list passed to allowed_ips is rejected and the caller is pointed at the boolean. An empty collection is easy to arrive at accidentally β€” a for that filtered everything out, a variable defaulting to [] β€” and a boolean spelled out in words is not. It also makes the diff legible: allowed_ips = [] and a missing allowed_ips look nearly identical; this does not.

  • The cross-field checks are placed, not gathered. Terraform requires each validation condition to reference its own variable and rejects validations that reference one another. Those two rules together mean the usual trick of collecting every cross-field check onto a single variable does not work, so each check sits on a variable it actually mentions, with the reference graph kept acyclic.

  • mtls_connection_required defaults to true because it compounds with the network posture. With mTLS off and posture 3, a leaked password is enough to reach the data from anywhere. Neither setting is alarming alone; together they are.

  • Both auto-scaling flags default to false, and the trade-off is stated rather than resolved. Off is the predictable-cost choice, not the resilient one. Which is better depends on whether the budget or the latency is the harder constraint β€” so the descriptions say what each choice does instead of picking silently and calling it a best practice.

  • The admin password's state exposure is disclosed rather than glossed. The provider offers no managed-identity alternative, so the secret cannot be designed away. The honest thing is to say where it should come from, that it lands in state regardless, and what that implies for the state backend.

  • The shape note is a first-class section, because three resource types carry this keystone's name as a prefix and only one is a child. The decisive evidence β€” both clones import to the same ARM type as the keystone β€” is recorded so the question does not need re-litigating.

  • The three backup mechanisms are distinguished explicitly. backup_retention_period_in_days, long_term_backup_schedule and the backups map are easy to mistake for variations of one thing, and the 90-day floor on the long-term schedule is the clearest signal that they are not.

  • The backups map's destroy semantics are framed as evidence deletion. For a backup taken to satisfy a retention obligation, removing a map entry is not a configuration tidy-up.

  • network_posture is a three-valued string, not only booleans, because the review question is which of the three applies, and a string answers it without inference.

🧱 Design Principles

Concern Secure default (empty call) Opt-out (caller must type it)
Network posture rejected β€” no posture is guessed, because the lazy one is permanent state one of three, explicitly
Irreversible exposure unreachable by accident; needs its own boolean, spelled out public_access_from_any_ip_enabled = true, reviewed
Accidental empty list rejected at plan, pointing at the boolean instead β€”
Client authentication mtls_connection_required = true false, typed
Posture visibility network_posture + is_publicly_reachable_from_any_ip emitted β€”
Cost predictability both auto-scaling flags off, with the trade-off stated enable, knowingly
Secrets none emitted; input sensitive; state exposure disclosed β€”
Retention clarity three mechanisms distinguished, 90-day floor enforced β€”
Evidence safety owns_user_initiated_backups emitted; destroy framed as deletion β€”
Licensing license_model recorded as a visible claim assert BYOL with a recorded entitlement
  • Before the first apply: decide the network posture. The module will not proceed without one.
  • Before choosing posture 3: get it reviewed. It cannot be undone.
  • Before turning mTLS off: consider whether posture 1 solves the problem instead.
  • Before committing a password: put it in Key Vault, and check the state backend is encrypted and access-controlled.
  • Before removing a backups entry: confirm no retention obligation depends on it.
  • Before asserting BringYourOwnLicense: confirm the entitlement with whoever owns the Oracle agreement.

πŸš€ Runbook

terraform init -backend=false
terraform validate
terraform fmt -check
  • Pin the source to a tag β€” ?ref=v1.0.0 β€” never a branch.
  • Plan-only from here. A human applies from CI.
  • ⚠️ A plan that shows network_posture becoming public-any-ip is a one-way change. Review it as such.
  • ⚠️ Read any plan that destroys a backups entry as a data deletion.
  • ℹ️ The posture checks run at plan time, so a missing or contradictory posture fails before anything is created.
  • ℹ️ subnet_id, virtual_network_id, national_character_set and customer_contacts are force-new.

πŸ§ͺ Testing

terraform validate and terraform fmt -check are the offline gate. They confirm:

  • the required inputs are present and typed correctly;
  • a network posture is stated β€” one of the three, and not two at once;
  • allowed_ips is non-empty when set, within 1024 entries, and consists of plain IPv4 addresses β€” not CIDR blocks. This resource validates each entry with IsIPv4Address, so 198.51.100.0/24 is refused and so is a /32. Note the two clone_from_* siblings in this family do accept CIDRs on their equivalent argument, which is why the distinction is easy to lose;
  • subnet_id and virtual_network_id are set together;
  • admin_password is non-empty;
  • compute_count > 0 and data_storage_size_in_tbs is a positive whole number;
  • national_character_set is AL16UTF16 or UTF8;
  • long_term_backup_schedule has a legal cadence, a parseable timestamp and 90–2558 day retention;
  • each backups entry's retention is a positive whole number;
  • the module declares no provider block.

πŸ’‘ These conditions were proved by evaluating them in terraform console inside the module β€” which does fire root-module variable validations, unlike terraform validate on a calling configuration. All three valid postures accept; all three unsafe combinations reject.

What only plan and apply exercise:

  • whether the resource group, subnet and virtual network exist;
  • whether the compute model, workload, version and character set are values the service accepts.

What no Terraform command checks at any stage:

  • whether the BYOL entitlement actually exists;
  • whether the retention numbers satisfy the policy that drives them;
  • whether the state backend is actually protecting the password it now contains.

πŸ’¬ Example Output

Outputs:

allowed_ip_count                  = null
autoscaling                       = {
  "compute" = true
  "storage" = false
}
backup_names                      = {
  "pre_upgrade_2026q1" = "pre_upgrade_2026q1"
}
backup_retention_period_in_days   = 30
compute_count                     = 4
compute_model                     = "ECPU"
has_long_term_backup_schedule     = true
id                                = "/subscriptions/00000000-.../providers/Oracle.Database/autonomousDatabases/adb-orders-prod"
is_publicly_reachable_from_any_ip = false
license_model                     = "LicenseIncluded"
mtls_connection_required          = true
network_posture                   = "private"
owns_user_initiated_backups       = true

πŸ’‘ network_posture = "private" with is_publicly_reachable_from_any_ip = false and mtls_connection_required = true is the shape to expect. allowed_ip_count = null is correct on the private path β€” there is no list to size.

πŸ” Troubleshooting

Symptom Cause Fix
Plan rejected with "a network posture must be stated" No posture supplied. The module will not guess. Choose one of the three (example 1).
Plan rejects allowed_ips = [] That is the permanent public posture. Use public_access_from_any_ip_enabled (example 4).
Plan rejects an allow-list plus the public boolean Contradictory postures. Pick one (example 5).
Plan rejects subnet_id on its own It must be set with virtual_network_id. Set both (example 2).
The database is publicly reachable and cannot be locked down Provisioned with an empty allow-list. Not reversible. Replace the database (examples 1, 4).
A leaked password reached the data mTLS off, and a public posture. The two compound (example 6).
The password appears in state Any provider input does. Protect the state backend (example 7).
Plan rejects long-term retention of 30 days The floor is 90. Use backup_retention_period_in_days instead (example 9).
A backup disappeared after a config tidy-up An entry was removed from the backups map. Treat that plan as a data deletion (example 10).
Expected the clones to be children of this module They are peers β€” each is a database. Use the clone modules (example 8).
A cost spike with no config change Auto-scaling is on. Check autoscaling (example 11).
Apply fails saying license_model must be null Dedicated Exadata infrastructure sets it at the infrastructure level. Not this module's shape.
Authorization failure partway through the apply The role covers one namespace but not both. Grant Oracle.Database/* and the subnet join.

πŸ”— Related Docs

πŸ’™ "Infrastructure as Code should be standardized, consistent, and secure."