Creates an Oracle Autonomous Database on Oracle Database@Azure, plus the user-initiated backups that belong to it (
azurerm_oracle_autonomous_database). Targetshashicorp/azurerm ~> 4.0.
- ποΈ Creates an Autonomous Database and, as
for_eachchildren, the user-initiated backups that belong to it. - πͺ The network posture is a one-way door. The provider documents that an explicitly empty
allowed_ipsmakes 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_requireddefaults totrueβ 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
backupsmap 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.
If this module saves you time, please consider supporting its continued development:
- β Star the repository on GitHub.
- π€ Connect on LinkedIn: linkedin.com/in/microsoftexpert
- β Buy me a coffee: buymeacoffee.com/microsoftexpert
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;
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;
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. |
| 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_ipsset 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_idandcustomer_contactsare force-new.national_character_setaccepts onlyAL16UTF16andUTF8, defaulting toAL16UTF16.long_term_backup_schedule.retention_period_in_daysranges from 90 to 2558 days β a 90-day floor, so this is the multi-year mechanism, not the day-to-day one.repeat_cadenceacceptsWeekly,Monthly,YearlyandOneTime, anchoring ontime_of_backup.license_modelmust 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_passwordis 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. lifecycleis not valid inside amoduleblock, so a caller cannot addignore_changes.
| 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 needsOracle.Database/*and the subnet join needsMicrosoft.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.
- The
Oracle.Databaseresource 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.
BringYourOwnLicenseasserts an entitlement this module cannot verify.
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
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.
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 |
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 thatallowed_ipsset to an explicitly empty list gives "secure public access to the database from any IP address" β and thatallowed_ipscannot 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 falseoutput "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_countis 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
forexpression that filtered everything out, a variable that defaulted to[]. A boolean spelledpublic_access_from_any_ip_enabled = trueis not.
π‘ It also means a code review sees words rather than an absence.
allowed_ips = []andallowed_ipsbeing 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_ipis 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
validationcondition 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_ipsreads the other three,public_access_from_any_ip_enabledreads the two placement fields,subnet_idreadsvirtual_network_id. Every condition names itself, and the reference graph stays acyclic.
6 Β· π mTLS defaults to required
mtls_connection_required = true # the defaultmtls_connection_required = false # β οΈ a password alone authenticatesπ
truemeans 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.
β οΈ Combinefalsewith 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 typefalseto 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 = truehere 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 ownresource_group_name,location,nameand full sizing.
β οΈ Rendering them asfor_eachchildren 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_cadenceanchors ontime_of_backupβ aWeeklycadence with a Tuesday timestamp runs every Tuesday. Legal values areWeekly,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_backupsis 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.
βΉοΈ
autoscalingis 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 == truerequires explicit recorded justification and cannot be undone;mtls == falserequires the same; andposture != "private"should have a stated reason.
π‘
network_postureis 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_workloadanddatabase_version, notdb_workloadanddb_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 theBringYourOwnLicenseentitlement exists were it asserted, or whether the retention numbers satisfy the policy that drives them. That last one belongs to whoever owns the policy.
| Input | Type | Default | Notes |
|---|---|---|---|
name / resource_group_name / location / display_name |
string |
β | Required. |
allowed_ips |
set(string) |
null |
Posture 2. |
public_access_from_any_ip_enabled |
bool |
false |
Posture 3. |
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. |
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. |
customer_contacts |
list(string) |
[] |
|
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.
}| 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_passwordissensitive = trueon input and appears in no output, and the module emits no wallet or connection string.
-
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 toallowed_ipsis rejected and the caller is pointed at the boolean. An empty collection is easy to arrive at accidentally β aforthat 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 missingallowed_ipslook nearly identical; this does not. -
The cross-field checks are placed, not gathered. Terraform requires each
validationcondition 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_requireddefaults totruebecause 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_scheduleand thebackupsmap 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
backupsmap'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_postureis a three-valued string, not only booleans, because the review question is which of the three applies, and a string answers it without inference.
| 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
backupsentry: confirm no retention obligation depends on it. - Before asserting
BringYourOwnLicense: confirm the entitlement with whoever owns the Oracle agreement.
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 showsnetwork_posturebecomingpublic-any-ipis a one-way change. Review it as such.β οΈ Read any plan that destroys abackupsentry 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_setandcustomer_contactsare force-new.
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_ipsis non-empty when set, within 1024 entries, and consists of plain IPv4 addresses β not CIDR blocks. This resource validates each entry withIsIPv4Address, so198.51.100.0/24is refused and so is a/32. Note the twoclone_from_*siblings in this family do accept CIDRs on their equivalent argument, which is why the distinction is easy to lose;subnet_idandvirtual_network_idare set together;admin_passwordis non-empty;compute_count> 0 anddata_storage_size_in_tbsis a positive whole number;national_character_setisAL16UTF16orUTF8;long_term_backup_schedulehas a legal cadence, a parseable timestamp and 90β2558 day retention;- each
backupsentry's retention is a positive whole number; - the module declares no
providerblock.
π‘ These conditions were proved by evaluating them in
terraform consoleinside the module β which does fire root-module variable validations, unliketerraform validateon 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.
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"withis_publicly_reachable_from_any_ip = falseandmtls_connection_required = trueis the shape to expect.allowed_ip_count = nullis correct on the private path β there is no list to size.
| 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. |
azurerm_oracle_autonomous_databaseβ provider documentation for this resource, including theallowed_ipsnote this module is built around.azurerm_oracle_autonomous_database_backupβ the owned child.- Create long-term backups on Autonomous Database β the 90-day floor and cadence semantics.
- Autonomous Database network access β what an access control list does and does not protect.
- Peer modules:
terraform-azurerm-oracle-autonomous-database-clone-from-backup,terraform-azurerm-oracle-autonomous-database-clone-from-database. - Sibling modules in this family:
terraform-azurerm-oracle-resource-anchor,terraform-azurerm-oracle-exadata-infrastructure. - This module's
SCOPE.md.
π "Infrastructure as Code should be standardized, consistent, and secure."