Skip to content

You are viewing documentation for Instruqt 2.0 Labs. For Tracks documentation, please visit docs.instruqt.com.

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.

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
module "postgres" {
source = "acme/postgres"
version = "1.4.0"
}
module "postgres" {
source = "acme/postgres"
version = "~> 1.4"
variables = {
database_name = "orders"
max_conns = 50
}
disabled = false
depends_on = [resource.network.main]
}

Fields

FieldTypeRequiredDescription
sourcestring✓Module source path or URL
variablesany—Variable values to pass to the module
versionstring—Module version constraint

In addition to the fields above, every module block accepts depends_on – see Ordering with depends_on.

source is required, and it accepts exactly two forms. Anything else is refused.

FormWritten asExample
Local pathA relative path beginning with ./ or ../source = ”./modules/postgres”
Registry addressteam-slug/module-slugsource = “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, no git::, no //subdir syntax.
  • Registry addresses are two segments, not three – no registry/namespace/module.

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 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.

Constraints are resolved with Masterminds semver.

ConstraintEquivalent to
1.4.0Exactly 1.4.0 – an exact pin.
>= 1.4.01.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.0Both conditions – a comma is AND.
  • 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 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 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
}

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.

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”.

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.web

The 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.

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.

  • Source required: every module block must have a source.
  • Source form: only a relative path beginning with ./ or ../, or a team-slug/module-slug registry 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 bare module.<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.

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 # outputs

modules/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
}
module "postgres" {
source = "acme/postgres"
version = "1.4.0"
variables = {
database_name = "orders"
}
}
module "postgres" {
source = "acme/postgres"
version = ">= 1.2, < 2.0"
}
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"
}
}
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"
}
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
}
}
module "monitoring" {
source = "acme/monitoring"
version = "~> 2.0"
disabled = !variable.enable_monitoring
}
  1. Small interface: expose the fewest variables and outputs that do the job, and give every one a description
  2. Honest versioning: bump minor for additions and major for anything a consumer must react to – constraints in other people’s labs depend on it
  3. Pin when it matters: an exact version for content that must not move; a constraint when you want fixes automatically
  4. Bound the constraint: prefer >= 1.2, < 2.0 over an open >= 1.2, so a major release cannot arrive unannounced
  5. Document the secrets: a module’s secret resources resolve against the consuming team’s secrets, so name them in the module’s readme
  6. Local for structure, registry for reuse: split one lab with local modules; publish only what other labs should depend on
  7. Re-expose, don’t reach through: a nested module’s outputs are not visible to your consumers unless you expose them yourself