Module
A module block pulls another piece of HCL configuration into this one. The module’s own resources, tabs, pages, activities, layouts, and dynamic values all become part of the content that declares the block, namespaced under it.
There are two kinds of module, and the source decides which:
- A registry module – a versioned module published to the Instruqt registry, addressed as
team-slug/module-slug, pinned by version or constraint. - A local module – a directory in the same repository, addressed as a relative path. It has no version, because it moves with the content that contains it.
For the registry, publishing, and using modules through the UI, see Modules.
Use Cases
Section titled “Use Cases”As a lab author, you can use modules to organise and reuse configuration:
- Reusable building blocks: Package a common pattern (e.g. a seeded database, a Kubernetes cluster) once and pin it from many labs
- Team-wide standards: Publish a module privately so every lab in the team builds on the same configuration
- Sharing across teams: Publish a module to the community registry for any Instruqt team to use
- Controlled updates: Pin an exact version to freeze a dependency, or give a constraint to pick up compatible releases automatically
- Splitting a large lab: Break a lab’s own configuration into local modules by functionality, without publishing anything
- Several instances of one thing: Add the same module more than once, at the same version or at different ones, with different variables
HCL Syntax
Section titled “HCL Syntax”Basic Syntax
Section titled “Basic Syntax”module "postgres" { source = "acme/postgres" version = "1.4.0"}Full Syntax
Section titled “Full Syntax”module "postgres" { source = "acme/postgres" version = "~> 1.4"
variables = { database_name = "orders" max_conns = 50 }
disabled = false
depends_on = [resource.network.main]}Fields
| Field | Type | Required | Description |
|---|---|---|---|
source | string | ✓ | Module source path or URL |
variables | any | — | Variable values to pass to the module |
version | string | — | Module version constraint |
In addition to the fields above, every module block accepts depends_on – see Ordering with depends_on.
Source
Section titled “Source”source is required, and it accepts exactly two forms. Anything else is refused.
| Form | Written as | Example |
|---|---|---|
| Local path | A relative path beginning with ./ or ../ | source = ”./modules/postgres” |
| Registry address | team-slug/module-slug | source = “acme/postgres” |
Both slugs of an address must be lowercase letters, digits, and hyphens, starting with a letter or a digit.
The leading ./ on a local path is required, and it is what keeps the two forms unambiguous: acme/postgres names a published module whether or not a directory called acme/postgres happens to sit beside the file.
There is no host, organisation, or protocol in an address. There is one Instruqt registry, so nothing needs discovering:
- Git URLs are not supported – no
github.com/org/repo, nogit::, no//subdirsyntax. - Registry addresses are two segments, not three – no
registry/namespace/module.
Containment
Section titled “Containment”A local path is judged by where it lands, not by whether it contains ... A path may climb to a sibling directory – ./a/../b is fine – but it may not leave the content it belongs to. ../../elsewhere is refused.
A published module is its own content, so it cannot read files above its own root at all, whatever the path says.
Version
Section titled “Version”version names which published version of a registry module this block takes.
- Required for a registry address. A registry module with no version cannot be resolved.
- Not used for a local source. There is nothing to version – the content is in the same repository.
The value is either an exact version, which pins outright, or a semver constraint, which is re-resolved every time the lab is opened in the editor and every time a session starts.
Version numbers are strict semver: MAJOR.MINOR.PATCH, no leading v, and no prerelease or build suffix. 1.4.0 is valid; v1.4.0, 1.4, and 1.4.0-rc.1 are not.
Constraint syntax
Section titled “Constraint syntax”Constraints are resolved with Masterminds semver.
| Constraint | Equivalent to |
|---|---|
1.4.0 | Exactly 1.4.0 – an exact pin. |
>= 1.4.0 | 1.4.0 and above. |
~> 1.2 | >= 1.2, < 1.3 |
~> 1.2.3 | >= 1.2.3, < 1.3.0 |
^1.2.3 | >= 1.2.3, < 2.0.0 |
1.2.x | >= 1.2.0, < 1.3.0 |
>= 1.2, < 2.0 | Both conditions – a comma is AND. |
Resolution rules
Section titled “Resolution rules”- An exact version pins outright. It resolves even if that version has been revoked, because revoking withdraws a version from being offered, not from being pinned.
- A constraint selects the highest published version that matches, skipping revoked and deleted versions.
- A deleted version never resolves. A lab pinning one fails to resolve rather than runs, and the reason recorded at deletion is surfaced on the failure.
- A constraint that matches nothing is an error, and the message lists the versions that are available.
Variables
Section titled “Variables”variables is a map of values for the variable blocks the module declares. The values are HCL expressions evaluated in the parent scope, so they can reference the parent’s own resources, variables, and locals.
module "postgres" { source = "acme/postgres" version = "~> 1.4"
variables = { database_name = variable.app_name max_conns = 50 network_id = resource.network.main.meta.id }}A key the module does not declare is not an error, but it does nothing.
Disabled
Section titled “Disabled”disabled turns the whole module off. It propagates: every resource, variable, local, output, and nested module inside the module is disabled with it, all the way down.
module "monitoring" { source = "acme/monitoring" version = "~> 2.0"
disabled = !variable.enable_monitoring}Ordering with depends_on
Section titled “Ordering with depends_on”depends_on takes a list of references, not strings, and each entry must be a resource or another module.
module "app" { source = "acme/app" version = "~> 1.0"
depends_on = [resource.network.main, module.postgres]}A variable, local, or output cannot be depended on: those hold values, and you depend on a value by reading it rather than by naming it here.
Module Reference
Section titled “Module Reference”A module exposes exactly one thing to whatever declared it – its outputs:
module.<block_name>.output.<output_name>resource "container" "app" { image { name = "myapp:latest" }
environment = { DB_HOST = module.postgres.output.host }}Nothing else inside a module is reachable from outside it. Its resources, variables, and locals are private to it, and a module that needs to expose something declares an output for it.
Naming a module without an output – module.postgres – is a valid dependency reference, and means “after everything in this module is complete”.
Namespacing
Section titled “Namespacing”Inside the composed configuration, every entity a module brought in is namespaced by the block that brought it. A container named web in the block postgres has the fully qualified ID:
module.postgres.resource.container.webThe same chain carries through to sandbox DNS. A resource’s record name includes the module blocks it sits in, innermost first, between its type and its network: on a network onprem, module.app.resource.container.web registers as web.container.app.onprem, and module.app.module.db.resource.container.web as web.container.db.app.onprem. Short names and aliases carry the chain too, so web in app answers to web.app.onprem. See Network for how names resolve.
This is what makes it safe to add the same module twice, or two versions of it side by side: postgres and postgres-2 namespace their contents separately and cannot collide. It is not, however, an addressing form you can write – references from outside a module go through its outputs.
Nesting
Section titled “Nesting”A module may declare module blocks of its own, up to 10 levels deep. Exceeding the limit is an error, as is a cycle – a module that is reachable from itself.
Nesting is one level of visibility at a time. A lab that depends on a module does not see that module’s own dependencies as its own, and cannot reach into them. A module that needs to pass something up from a nested module re-exposes it as an output of its own.
Two sibling modules referencing the same directory is a diamond, not a cycle: it is two instances of that module, and it resolves.
Validation Rules
Section titled “Validation Rules”- Source required: every
moduleblock must have asource. - Source form: only a relative path beginning with
./or../, or ateam-slug/module-slugregistry address. - Containment: a local path must resolve inside the content that references it.
- Version required for a registry address: a registry module with no version cannot be resolved.
- Version format: strict semver, no leading
v, no prerelease or build suffix. Each part must be at most 2147483647. - Version immutability: a published version’s content never changes, and a version number is never reissued at an address.
- Nesting depth: at most 10 levels.
- No cycles: a module that is reachable from itself is an error.
- References: only
module.<name>.output.<output>(and the baremodule.<name>as a dependency). Referencing a module’s resources directly is an error. - depends_on: a list of references to resources or modules. Strings are rejected, as are variables, locals, and outputs.
Local Modules
Section titled “Local Modules”A local module is a directory of .hcl files in the same repository, pulled in by relative path. It has no version and is never published, so it is the way to split one piece of content into parts – not a way to share between labs.
module "shared" { source = "./modules/shared"
variables = { environment = "staging" }}A minimal local module is just the HCL that defines it, plus whatever variable and output blocks form its interface:
modules/└── shared/ ├── main.hcl # resources ├── variables.hcl # inputs └── outputs.hcl # outputsmodules/shared/variables.hcl:
variable "environment" { description = "Which environment this instance represents" default = "development"}modules/shared/main.hcl:
resource "container" "web" { image { name = "nginx:latest" }
environment = { ENVIRONMENT = variable.environment }}modules/shared/outputs.hcl:
output "container_name" { description = "Runtime name of the web container" value = resource.container.web.container_name}Examples
Section titled “Examples”Registry module with a pinned version
Section titled “Registry module with a pinned version”module "postgres" { source = "acme/postgres" version = "1.4.0"
variables = { database_name = "orders" }}Registry module on a constraint
Section titled “Registry module on a constraint”module "postgres" { source = "acme/postgres" version = ">= 1.2, < 2.0"}Two versions of one module side by side
Section titled “Two versions of one module side by side”module "postgres" { source = "acme/postgres" version = "1.4.0"
variables = { database_name = "orders" }}
module "postgres-2" { source = "acme/postgres" version = "2.0.1"
variables = { database_name = "analytics" }}Consuming module outputs
Section titled “Consuming module outputs”module "postgres" { source = "acme/postgres" version = "~> 1.4"}
module "cache" { source = "acme/redis" version = "~> 2.1"}
resource "container" "app" { image { name = "myapp:latest" }
environment = { DB_HOST = module.postgres.output.host DB_PORT = module.postgres.output.port CACHE_HOST = module.cache.output.host }}
output "app_url" { value = "http://${resource.container.app.meta.name}:8080"}Chained modules
Section titled “Chained modules”module "network" { source = "./modules/network"
variables = { subnet_cidr = "10.0.0.0/16" }}
module "app" { source = "acme/app" version = "~> 3.1"
depends_on = [module.network]
variables = { network_id = module.network.output.network_id }}Conditionally disabled module
Section titled “Conditionally disabled module”module "monitoring" { source = "acme/monitoring" version = "~> 2.0"
disabled = !variable.enable_monitoring}Best Practices
Section titled “Best Practices”- Small interface: expose the fewest variables and outputs that do the job, and give every one a
description - Honest versioning: bump minor for additions and major for anything a consumer must react to – constraints in other people’s labs depend on it
- Pin when it matters: an exact version for content that must not move; a constraint when you want fixes automatically
- Bound the constraint: prefer
>= 1.2, < 2.0over an open>= 1.2, so a major release cannot arrive unannounced - Document the secrets: a module’s
secretresources resolve against the consuming team’s secrets, so name them in the module’s readme - Local for structure, registry for reuse: split one lab with local modules; publish only what other labs should depend on
- Re-expose, don’t reach through: a nested module’s outputs are not visible to your consumers unless you expose them yourself
