Hedronite · Dev Lesson · Polyglot-Dev / HCL · Mon 2026-08-24

HCL configuration_aliases and Provider Schema — the nest the provider still has to answer

A parsed file is not a validated file. The plugin answers first.

Lesson Class: Dev (HCL depth)
Focus: required_providers · configuration_aliases · providers map · schema-permission for dynamic
Code Blocks: clean blocks, explanation in prose
Paired Ops: Terraform GCP Cloud SQL settings nest
Paired Cert: Pro Lab 14 providers through modules
Grounding: Brikman Ch.7 leftover · Lab 14 real · Lab 21 referenced as 07-25, not a redo
The table
required_providers names source addresses. It does not configure a process.
The socket
configuration_aliases is empty until the root passes the map.
The label
The dynamic label must already exist in the plugin schema.
A parsed file is not a validated file. The plugin answers first.

<!-- hal:authoritative:yaml -->

§I — Frame

The 07-25 HCL lesson taught three constructs that change how many things exist: count, for_each, and dynamic. The 08-03 lesson typed the contract those things sit behind. The 08-15 lesson built the object a for expression writes. All three lessons assumed the nest they wrote was legal.

Today the leftover is the permission.

A dynamic block is HCL. Whether that HCL is accepted is a provider-protocol question. Terraform Core does not keep a list of Cloud SQL nests. Core asks the plugin for a schema and compares your file to the answer. The child module that wants to stamp database_flags is a consumer of that answer. The child module that wants to invent tuning is a consumer who did not listen.

Call the failure the nest the provider still has to answer. An author who knows dynamic syntax and does not know the schema writes a beautiful block that dies at validate. The compiler of 07-25 is not enough. The types of 08-03 are not enough. The constructor of 08-15 is not enough. The plugin has to have declared the nest.

Brikman left this on the shelf in Chapter 7. 07-28 spent Working with One Provider and Multiple Copies of the Same Provider. The leftover headings are Creating Modules That Can Work with Multiple Providers and Working with Multiple Different Providers. Lab 14 is the Bootcamp drill for the first leftover. hashicorp/google plus hashicorp/google-beta is the second leftover wearing today's GCP shirt.

§II — Language Idiom: four surfaces at the consumer boundary

**Surface one. required_providers is the child's declaration of which plugins it can consume.**

A root module that writes provider "google" { project = var.project_id } has configured a plugin. A child module that writes the same block has configured a second plugin the root cannot see. The child's job is to declare the source address and the version bound, then wait.

terraform {
  required_providers {
    google = {
      source  = "hashicorp/google"
      version = ">= 5.38.0, < 7.0.0"
    }
    google-beta = {
      source  = "hashicorp/google-beta"
      version = ">= 5.38.0, < 7.0.0"
    }
  }
}

Two source addresses. Two binaries. Two schemas. The prefix on the resource types may still be google_. The process is not shared. Brikman, How Do You Install Providers, is the implicit-hashicorp warning: a provider "google" block without this table will still init on a lucky laptop because Terraform guesses hashicorp/google. A provider "google-beta" block without this table will guess hashicorp/google-beta the same way. Guessing two plugins and then passing only one through a module call is how a child silently talks to a default it never received.

**Surface two. configuration_aliases is the child's declaration that it needs more than one configuration of the same plugin.**

Lab 14's exam shirt is two AWS regions. The child does not write provider "aws". The child writes:

terraform {
  required_providers {
    aws = {
      source                = "hashicorp/aws"
      configuration_aliases = [aws.primary, aws.secondary]
    }
  }
}

Those names are empty sockets. They are not configurations. The root fills them:

module "sql" {
  source = "./modules/cloud-sql"
  providers = {
    aws.primary   = aws
    aws.secondary = aws.secondary
  }
}

The left-hand names are the child's sockets. The right-hand names are the root's configured plugins. Mix them up and the child compiles against the default plugin in the region you did not mean. Lab 14's broken main.tf never grows the module. That is the point of a correction lab. The README names the missing pieces: pass the aliased provider, declare configuration_aliases, keep configuration in the root.

Today's GCP refraction of the same socket is two plugins, not two regions:

module "sql" {
  source = "./modules/cloud-sql"
  providers = {
    google      = google
    google-beta = google-beta
  }
}

No configuration_aliases here. The sockets are different provider types. configuration_aliases is for two configurations of one type. google and google-beta are two types. Brikman's two leftover sections are two cases. Do not crush them into one alias.

Surface three. A nest is legal only if the schema named it.

HCL will parse settings { tuning { work_mem = "64MB" } }. Parsing is not validation. Validation asks the plugin. The plugin's schema for google_sql_database_instance includes settings.database_flags as a repeatable nest with name and value. It does not include settings.tuning. The 07-25 lesson would tell you to write:

dynamic "tuning" {
  for_each = var.flags
  content {
    work_mem = tuning.value
  }
}

That HCL is well-formed and dead. The leftover is the first word of the dynamic block. That word must be a nest the plugin published. dynamic "database_flags" is the legal sentence. Lab 21 trains the syntax on ingress. This lesson trains the permission.

Surface four. The lock file is the child's receipt, and the child cannot write it.

terraform init in the root writes .terraform.lock.hcl. Each provider source gets a version and a set of hashes. A child module has no lock file of its own when it is consumed. The root's lock is the receipt for every plugin the graph loaded. Pinning version = "~> 6.0" in the child and version = "~> 5.0" in the root is a constraint intersection. If the intersection is empty, init fails. If the intersection is a version whose schema dropped a nest you wrote, validate fails after a successful init. The lock is not a nest. The lock is why a nest that worked on Tuesday can die on Wednesday after an unpinned init.

Implicit inheritance is the silent default when the providers map is omitted. A child that declares one google socket and names no providers map will receive the root's default google configuration. That luck is how a first Cloud SQL module works on a laptop with one project. It is also how a second child, added later, talks to the default project after the root grew an aliased google.shared for the host project. Lab 14 exists because luck is not a wiring. Write the map the day you have two sockets. Write the map the day you have one socket and a second is coming. The map is documentation the graph can enforce.

Provider-defined functions (Terraform 1.8 and later) are the same consumer boundary one altitude up. provider::google::name_from_id is legal only if the loaded google plugin published that function. A function you remember from a blog and a plugin that has not shipped it yet is a validate error, same class as tuning. This fire does not teach the function catalog. It names the permission. If the plugin did not publish it, HCL will not save you.

§III — Code Worked Example: the child that consumes, the nest that is refused

A Cloud SQL child that accepts a flag map and, when asked, a beta-only nest. The child never configures a plugin.

terraform {
  required_providers {
    google = {
      source  = "hashicorp/google"
      version = ">= 5.38.0, < 7.0.0"
    }
    google-beta = {
      source  = "hashicorp/google-beta"
      version = ">= 5.38.0, < 7.0.0"
    }
  }
}

variable "flags" {
  type    = map(string)
  default = {}
}

variable "enable_data_cache" {
  type    = bool
  default = false
}

resource "google_sql_database_instance" "primary" {
  provider         = google
  name             = var.instance_name
  database_version = "POSTGRES_15"
  region           = var.region
  project          = var.project_id

  settings {
    tier = var.tier

    ip_configuration {
      ipv4_enabled    = false
      private_network = var.network_id
    }

    dynamic "database_flags" {
      for_each = var.flags
      content {
        name  = database_flags.key
        value = database_flags.value
      }
    }
  }
}

The provider = google meta-argument on the resource is optional when the child has only one google configuration. It becomes required the moment you add a second google configuration, or the moment you put a beta-only nest on a second resource that must use google-beta. Write it when the socket count is greater than one. Leave it off when the child has a single google plugin and you want the default.

The beta nest, when the caller asked for it, sits on a resource that names the other plugin:

resource "google_sql_database_instance" "primary_beta" {
  count    = var.enable_data_cache ? 1 : 0
  provider = google-beta

  name             = var.instance_name
  database_version = "POSTGRES_15"
  region           = var.region
  project          = var.project_id

  settings {
    tier = var.tier

    data_cache_config {
      data_cache_enabled = true
    }
  }
}

Two resources for one instance is the wrong shape in production. The honest production shape is one resource on the plugin that owns every nest you need. The example exists to make the socket visible. If data_cache_config has landed in hashicorp/google, the second resource disappears and the nest moves up. The leftover does not care which calendar that landing used. The leftover cares that you named the plugin that answers the nest.

A child that writes provider "google" { project = var.project_id } to "be explicit" has not been explicit. It has forked. The root can no longer pass impersonate_service_account, user_project_override, or request_timeout. Those fields live on the provider block, not on the resource. The nest on the resource cannot carry them. The only honest explicit is the providers map plus the provider = google meta-argument on the resource that must not use the default.

The root that consumes this child:

provider "google" {
  project = var.project_id
  region  = var.region
}

provider "google-beta" {
  project = var.project_id
  region  = var.region
}

module "sql" {
  source = "./modules/cloud-sql"
  providers = {
    google      = google
    google-beta = google-beta
  }
  instance_name = "prod-pg"
  flags = {
    work_mem            = "64MB"
    log_min_duration_statement = "500"
  }
}

flags is the 08-15 object. Do not rebuild it with twenty hand-typed database_flags blocks. Do not invent a tuning nest to make it prettier. The constructor writes a map. The dynamic block stamps a nest the plugin already named.

The Lab 14 shape, for the exam day that will not mention Cloud SQL, is the same map of sockets with one plugin type:

data "aws_region" "primary" {
  provider = aws.primary
}

data "aws_region" "secondary" {
  provider = aws.secondary
}

Those data sources compile only if the child declared configuration_aliases = [aws.primary, aws.secondary] and the root passed both. Lab 14's starter file configures two root providers and reads two regions at the root. The correction is to push the reads into a child and wire the sockets. The Cert lesson owns that correction. This lesson owns the HCL that makes the sockets real.

§IV — Connection to Today's Ops Lesson

Ops wrote google_sql_database_instance.settings. Three legal nests. One illegal tuning line. A connection_name that does not move when ipv4_enabled flips.

This lesson is why the legal nests compiled. settings, ip_configuration, backup_configuration, and database_flags are words in the google plugin's schema. tuning is a word in a DBA's mouth. HCL will hold either. Validate will not.

Ops also passed google and google-beta as two plugins. This lesson is the required_providers table and the providers = {} map that make that pass legal. Without the table, the child guesses. Without the map, the child uses a default the root did not pass. Brikman, Creating Modules That Can Work with Multiple Providers, is the sentence both lessons share: configuration lives in the root. The child consumes.

var.flags is the only object this lesson constructs, and it constructs it by receiving it. The 08-15 fire already taught { for k, v in x : k => ... }. Today's child does not rebuild that constructor. It stamps the nest the constructor filled.

§V — Prior-Lesson Reach

07-25 taught dynamic as a way to stamp nests. It used security-group ingress and similar AWS shirts. The permission question was assumed. Today's fire is the assumption. Keep 07-25's syntax. Add the schema check. If you cannot name the nest in the provider documentation, do not write the dynamic label.

08-03 taught optional() and type constraints on the module contract. var.flags is map(string) because the nest's name and value are both strings. A map(any) here is how an author smuggles a number into value and waits for the plugin to refuse it at plan. The type contract and the schema contract are two gates. 08-03 owns the first. Today owns the second.

08-15 taught the object constructor. A flag map is an object constructor with no computation. { for k, v in var.raw_flags : k => tostring(v) if v != null } is the 08-15 leftover applied to today's input. Write that local in the root if the caller sends nulls. Do not write it inside the resource block. The nest stamps. The local constructs.

07-28 taught aliases at the root. alias = "secondary". That is a configuration. configuration_aliases is a socket. Confusing them is the Lab 14 fail. A child that copies 07-28's provider "aws" { alias = "secondary" } has configured a plugin. A child that declares configuration_aliases = [aws.secondary] has asked to consume one.

08-21's terratest fire and 08-18's subprocess fire are the other two seats of the triad. They are not this directory. Do not import Go or Python to prove a nest is illegal. terraform validate is the proof.

§VI — Closing

The dynamic label is a nest name. The nest name is a schema entry. The schema entry is an answer the plugin gave Core over the protocol 07-28 named and this fire finally consumes. The child declares source addresses. The child declares sockets. The child does not configure the plugin. The root passes the plugins. The lock file is the receipt.

Name it when you see it. The nest the provider still has to answer. If validate refuses a block you are sure you typed right, you invented a nest. If plan talks to the wrong project, you forgot the providers map. If init pulled a binary you did not pin, you omitted required_providers.

Examine well. Pretty HCL is not a schema. A parsed file is not a validated file. The plugin is the one who answers first, and it answers with a schema document you did not author.

Related