SaveMyCert
Log in
5 of 5 free questions left today·for 10 a day
Infrastructure lifecycle and operations

Terraform Professional: Sharing Data Between Projects and Managing Provider and Module Versions

16 min readTerraform Pro 002 · Infrastructure lifecycle and operationsUpdated

Sharing data between Terraform projects means getting values one project creates, such as VPC IDs or endpoints, into another project's configuration, and managing versions means controlling exactly which provider and module releases each project installs. At the professional level both are operational decisions: choosing between terraform output, terraform_remote_state, tfe_outputs, published config stores and direct data source lookups based on who may read state; understanding what sensitive really hides; chaining HCP Terraform workspaces with run triggers; and writing version constraints, lock files and module sources that keep every machine and pipeline on the same reviewed releases.

What you’ll learn
  • Use terraform output with -raw and -json correctly in scripts, including the shape of the all-outputs JSON
  • Explain what sensitive = true hides and where sensitive output values are still exposed in plain text
  • Choose between terraform_remote_state, tfe_outputs, a published config store and direct data source lookups based on access and coupling constraints
  • Combine tfe_outputs with HCP Terraform run triggers and the Auto-apply run triggers setting
  • Apply the external data source protocol and recognise when a managed resource is needed instead
  • Write provider version constraints for root and reusable modules and predict which version Terraform selects
  • Manage the dependency lock file across upgrades and platforms, and pin registry and Git modules

What are the ways to pass data between Terraform projects?

There are four practical ways for one Terraform project to use values another project produces, and they differ mainly in what the consumer must be allowed to read and how tightly it is coupled to the producer's state layout:

ApproachWhat the consumer needsCoupled toMain trade-off
terraform_remote_state data sourceRead access to the producer's whole state snapshotThe producer's backend and state locationSimple, but every reader can see every secret and attribute in that state
tfe_outputs data source (HCP Terraform / Terraform Enterprise)An HCP Terraform token allowed to read the source workspace's outputsThe producer's organization and workspace nameOutputs only, no full state access; the recommended choice inside HCP Terraform
Publish to a config store (SSM Parameter Store, Consul KV, Kubernetes ConfigMap, DNS, an object store)Read access to the specific published values, through a data sourceAn agreed key or name in the storeAccess is granted per value and the producer decides exactly what is shared
Provider data source lookup (for example aws_vpc filtered by tags)Read access to the cloud APIAgreed tags or names on the real objectsNo state coupling at all, but the filters become a contract that must match exactly one object

Choose by the constraint that matters most. Any approach that reads state requires state access, so it is out when consumers may not see the producer's state. An approach that names a backend, state key or workspace is coupled to that location, so prefer a published key or a direct lookup when the producer's layout is expected to change. A published store is only useful if consumers can already read it with the credentials they hold. Inside HCP Terraform with no other constraint, tfe_outputs is HashiCorp's recommendation.

Configuring the backend itself and the HCP Terraform remote state sharing setting are covered in the remote backends lesson; this lesson is about choosing and using the data path.

How does terraform output work in scripts and pipelines?

terraform output reads root module output values from the current state and prints them; its format flag decides whether a script can use the result directly.

CommandWhat it printsUse it for
terraform outputEvery root output in Terraform-style syntax; sensitive ones appear as <sensitive>Humans reading a terminal (it is not JSON)
terraform output NAMEThat one value in Terraform syntax, so strings keep their double quotesInspection; awkward in shell because of the quotes
terraform output -raw NAMEA string, number or bool printed as-is, with no quotes, escaping or trailing newlinePassing a single primitive value to curl, export and similar tools
terraform output -json NAMEJust that output's value as JSONLists, maps and objects, usually piped to jq
terraform output -jsonOne JSON object keyed by output name; each entry is {"sensitive", "type", "value"}Saving all outputs once and reading several later

-raw only supports strings, numbers and booleans. For a list, use -json and let jq unpack it:

terraform output -json private_subnets | jq -r '.[]'

# all outputs saved once, then read later
terraform output -json > outputs.json
jq -r '.app_url.value' outputs.json

The shape difference catches people out: with a name, -json prints the bare value; without a name, every value sits under .<name>.value. The argument is an output name, not an expression; select elements from the JSON instead.

Only outputs recorded in state can be read. A new output added to configuration appears after an apply (a normal apply, or one in refresh-only mode) writes it; a plan alone shows "Changes to Outputs" but does not record them.

What does sensitive = true protect on an output?

Marking an output sensitive = true only hides it in Terraform's human-readable display; the value is still stored in plain text in state and is printed in plain text whenever it is asked for explicitly.

Where the value goesSensitive output shown?
terraform plan / terraform apply outputRedacted
terraform output (no name)Redacted as <sensitive>
terraform output NAMEPlain text — you asked for it by name
terraform output -json and -rawPlain text; -json marks the entry "sensitive": true
The state filePlain text
A consumer's terraform_remote_stateReadable, and copied into the consumer's own state

Two consequences follow. Anyone who can run terraform output against a state can read its secrets, so CI read access to state is secret access. And a pipeline that publishes outputs as a build artifact must filter on the flag itself, because -json includes everything:

# keep only entries whose sensitive flag is false
terraform output -json > all.json
jq 'with_entries(select(.value.sensitive | not))' all.json

Backend encryption at rest protects the stored object from people without access; it does nothing against an authorised reader. The rules for how sensitivity propagates through expressions, and ephemeral values that never reach state, are covered in the configuration-design lessons.

Why does HashiCorp warn about terraform_remote_state?

terraform_remote_state exposes only the producer's root module outputs to the reading configuration, but to get them it must fetch the entire state snapshot, so the reader's credentials need access to everything in that state.

data "terraform_remote_state" "net" {
  backend = "s3"
  config = {
    bucket = "acme-tf-state"
    key    = "network/terraform.tfstate"
    region = "eu-west-1"
  }
}

# only root outputs are visible
subnet_ids = data.terraform_remote_state.net.outputs.private_subnet_ids

Four facts decide most questions about it:

  • Whole-object access. A state file is one object. There is no way to grant a reader just the outputs section in a bucket or blob policy, so each reader can see every resource attribute, including secrets in resources the producer never meant to share.
  • Copies spread. Whatever a data source returns is saved in the consumer's state. A password read through remote state now lives in plain text in every consumer's state too.
  • Root outputs only. A child module's output (module.vpc.private_subnet_ids) is invisible to readers until the producer declares a root output that passes it through and applies. There is no outputs.vpc.… path into child modules.
  • Latest applied snapshot. Readers see what the producer's last apply recorded, not what its configuration currently says.
# producer root module: pass the child value through, then apply
output "private_subnet_ids" {
  value = module.vpc.private_subnet_ids
}

Reading remote state does not lock it; it is a read of the latest snapshot. Because of the access problem, the terraform_remote_state documentation recommends publishing data explicitly instead, or using tfe_outputs in HCP Terraform.

How do you share values without sharing state?

There are two ways to share values without giving consumers any state access: the producer publishes the values to a store, or the consumer looks up the real objects with provider data sources.

Publish the values

Publishing data means the producer writes the specific values consumers need to a purpose-built store, and each consumer reads them with that store's data source, so access is granted per value rather than per state file.

# producer
resource "aws_ssm_parameter" "vpc_id" {
  name  = "/shared/network/prod/vpc_id"
  type  = "String"
  value = aws_vpc.main.id
}

# consumer
data "aws_ssm_parameter" "vpc_id" {
  name = "/shared/network/prod/vpc_id"
}

The terraform_remote_state documentation lists stores that work this way: Consul key/value, Kubernetes ConfigMaps, DNS records, cloud parameter stores and object storage. Pick the one the consumers can already reach with the credentials they hold. A service project that has only Kubernetes cluster credentials can read a kubernetes_config_map but not an SSM parameter; a project with AWS credentials but no state access can read SSM parameters.

The published keys become an interface: the producer can refactor its configuration, split into several projects or change backends without breaking consumers, as long as it keeps writing the same keys.

Look up the infrastructure directly

A consumer can skip the producer's Terraform entirely and read the real objects from the provider API with data sources, which removes every dependency on the producer's state, backend, workspace and project layout.

data "aws_vpc" "prod" {
  tags = {
    Environment = "prod"
    Role        = "shared"
  }
}

This is the most resilient choice when the producer is about to be split, renamed or migrated to another backend, and when consumers must not see any state. The cost is that the lookup criteria become the contract. Single-object data sources such as aws_vpc fail the plan when the filters match more than one object, and fail when they match none. Teams must keep identifying tags unique (or filter on more attributes) and treat changes to those tags as breaking changes.

How do tfe_outputs and run triggers connect HCP Terraform workspaces?

In HCP Terraform, tfe_outputs moves the data between workspaces and run triggers move the timing; they are independent, and a design usually needs both.

tfe_outputs

The tfe_outputs data source (in the hashicorp/tfe provider) reads a workspace's root outputs through the HCP Terraform API without full state access. Its two attributes behave differently:

AttributeContainsSensitivity
valuesEvery output of the source workspaceMarked sensitive as a whole, because the data source cannot know in advance which outputs are secret
nonsensitive_valuesOnly outputs that are not sensitive in the source workspaceNot sensitive, so it stays visible in plans
data "tfe_outputs" "db" {
  organization = "acme"
  workspace    = "db-prod"
}

endpoint = data.tfe_outputs.db.nonsensitive_values.db_endpoint # visible
password = data.tfe_outputs.db.values.db_password              # stays redacted

A root output built from values must itself be marked sensitive or the plan fails. Wrapping the whole values map in nonsensitive() would unmark the real secrets too, and a sensitive output is simply absent from nonsensitive_values.

Run triggers

A run trigger connects a downstream workspace to one or more source workspaces. When a run in a source workspace applies successfully, HCP Terraform queues a run in the downstream workspace. Commits, plans, and discarded or failed runs in the source do not trigger anything, because upstream infrastructure has not changed.

  • The trigger is configured on the downstream workspace, which names its sources.
  • Triggered runs wait for confirmation unless the downstream workspace enables Auto-apply run triggers. That setting is separate from the workspace's general auto-apply, so you can auto-apply runs caused by upstream changes while runs from VCS commits still need approval.
  • Run triggers share no data. The downstream configuration still reads the new values itself, typically with tfe_outputs.

When is the external data source the right tool?

The external data source (from the hashicorp/external provider) runs a local program and returns its result as a map of strings; HashiCorp describes it as an escape hatch for read-only lookups that no provider supports.

data "external" "rack" {
  program = ["python3", "${path.module}/rack.py"]
  query   = { host = var.host }
}
# data.external.rack.result["rack"]

The program must follow a strict protocol:

  • It reads the query as a JSON object on stdin.
  • It writes one JSON object whose values are all strings to stdout and exits 0. Numbers, lists and nested objects are rejected, so encode them as strings (for example a comma-separated list) and convert back with functions such as split or tonumber.
  • On failure it writes a message to stderr and exits non-zero; Terraform shows that message.
  • It must have no observable side effects. Terraform re-runs it every time it reads the data source, which happens on every plan, including speculative plans.

Anything that creates something — a ticket, a record, a credential — belongs in a managed resource of a provider, which records the object in state and creates it once. Nothing in the data source's configuration changes how often the program runs.

How do provider and module version constraints work?

A version constraint is a comma-separated list of conditions; Terraform selects the newest available version that satisfies every condition from every module that constrains the same dependency.

OperatorMeaningExample
= or noneExactly this version (the only way to select a pre-release)"6.0.0-beta2"
!=Excludes one version"!= 6.12.0"
> >= < <=Comparisons; >= alone has no upper bound">= 5.30"
~>Only the right-most written component may increase"~> 5.40" = >= 5.40, < 6.0; "~> 5.40.0" = >= 5.40.0, < 5.41.0

Pre-releases such as 6.0.0-beta2 never match >, >=, <, <= or ~>; you must name the exact version.

Constraints combine. The root module and every child module may declare required_providers versions for the same provider, and Terraform treats them equally. If no single release satisfies all of them, terraform init fails; one configuration installs exactly one version of each provider, so a module cannot get its own. Work an example with the google provider: root ~> 6.10, module A >= 6.8, < 6.14, module B != 6.12.0, releases 6.9.1, 6.11.3, 6.12.0, 6.13.2, 6.14.0 → 6.9.1 fails the root, 6.12.0 fails module B, 6.14.0 fails module A, leaving 6.11.3 and 6.13.2; with no lock file Terraform installs the newest, 6.13.2. A conflict with a module you do not own is fixed by moving to a module release whose constraint allows your provider version.

Where each style belongs, following HashiCorp's guidance:

  • Reusable modules declare only the minimum provider version they need (>= 5.30). An upper bound or pin in a shared module narrows every caller's choice.
  • Root modules use ~> for providers so they get both a floor and a ceiling, and let the lock file pin the exact release.
  • Third-party modules are pinned to an exact version so upgrades arrive only when you change the code.

The Terraform CLI's own required_version and staged upgrades of Terraform itself are covered in the version-upgrade lesson.

What does the dependency lock file record, and how do you keep it working across platforms?

.terraform.lock.hcl records the exact provider versions and package checksums terraform init selected, so every machine that uses the committed file installs the same, verified provider builds. It does not track modules.

Selection behaviour

SituationWhat terraform init does
Provider already locked and the version still satisfies the constraintsRe-selects the locked version, even if newer releases exist
Locked version no longer satisfies a changed constraintFails and tells you to run terraform init -upgrade
terraform init -upgradePicks the newest version allowed by the constraints and rewrites the entry
Provider no longer needed by configuration or stateRemoves its entry; while state still holds its resources (for example before a destroy is applied) the entry stays
ModulesNot locked: each install resolves the newest version that matches the module's version constraint

So when a constraint changes, run terraform init -upgrade where the change is made and commit the new lock file with it. Running -upgrade in CI, or deleting the lock file there, means CI installs versions nobody reviewed.

Checksums and platforms

Each entry carries hashes. zh: hashes are the registry's signed checksums of the official .zip packages, one per platform; h1: hashes are computed from an installed package.

  • A provider first installed from its origin registry gets zh: hashes for every platform the provider is published for, plus an h1: for the local package — so a lock file made on macOS usually verifies on Linux CI.
  • A provider first installed through a filesystem or network mirror gets only the installing platform's h1: hash, because a mirror offers no signed checksum list. Another platform then fails verification ("doesn't match any of the checksums previously recorded").
  • Fix that without weakening verification by pre-recording the other platforms, then commit:
terraform providers lock \
  -platform=linux_arm64 \
  -platform=windows_amd64

Changing the install method later does not remove hashes already recorded. A plugin cache only reuses packages; it does not add checksums.

How do you pin module versions by source type?

How you pin a module depends on its source: the version argument works only for registry sources, and Git sources pin with ?ref=.

SourceHow to pinNotes
Public or private Terraform registryversion = "4.2.1" (or a constraint)Ranges re-resolve on every fresh init, because modules are not in the lock file
Git (git::https://…, GitHub shorthand)?ref=v2.3.0 (branch, tag or commit SHA)Setting version makes terraform init fail (a version constraint applies only to registry modules); without ref the default branch is used
Local pathNone (version is rejected here too)Versioned with the calling repository
module "dns" {
  source = "git::https://code.internal.example/platform/dns-zone.git?ref=9b2e71c04d5a3f86e1b7c29d0a4f5e3b8c6d1a72"
}

Branches and tags are movable names: someone with repository rights can point them at different code. A full commit SHA identifies exactly one commit, so the module can change only when the calling configuration changes — the strongest pin when tags cannot be trusted. Only ref selects the revision; other URL parts control where in the repository Terraform looks and how it clones.

For third-party registry modules, an exact version turns each upgrade into a reviewed pull request; relying on ~> plus the lock file does not, because the lock file never holds module versions. Publishing modules to a private registry is covered in the module and registry lessons.

Tip. Expect production scenarios decided by one stated constraint: consumers may not read the producer's state, the solution must survive a project split or backend migration, a value must stay visible in plans while a secret stays redacted, every upgrade must be reviewed, or checksum verification must stay on across operating systems. Items often show a short terraform output or jq command, a version constraint with a list of available releases, or a module source string and ask what Terraform does or which variant works. Wrong options are real commands, flags and data sources that solve a neighbouring problem, and several items ask you to select two statements.

Key takeaways
  • terraform output -raw handles only strings, numbers and booleans; use -json (with jq) for lists, maps and objects
  • terraform output -json without a name returns {name: {sensitive, type, value}}; with a name it returns the bare value
  • sensitive = true hides values in plan, apply and the unnamed output listing only; state, named output, -json and -raw expose them in plain text
  • terraform_remote_state exposes only root outputs but requires read access to the whole state snapshot, and copies what it reads into the consumer's state
  • Child module values reach other projects only through a root output, and only after an apply records it in state
  • Publishing values to SSM, Consul, a ConfigMap or DNS grants access per value; direct data source lookups remove state coupling but must match exactly one object
  • In HCP Terraform use tfe_outputs (values is sensitive wholesale; nonsensitive_values is the safe subset) and run triggers, which fire on a successful apply and share no data
  • The external data source needs a JSON object of strings, reports errors via stderr and a non-zero exit, and runs on every plan, so it must have no side effects
  • Terraform combines every module's provider constraints and needs one version satisfying all; reusable modules set minimums, root modules use ~>, pre-releases need an exact version
  • The lock file pins providers only: commit it, upgrade with init -upgrade where the change is made, add platforms with terraform providers lock -platform, and pin modules exactly (Git by commit SHA)

Frequently asked questions

What is the difference between terraform output -raw and -json?

terraform output -raw NAME prints a single string, number or boolean output exactly as it is, with no quotes, which suits shell tools. It fails for lists, maps and objects. terraform output -json prints values as JSON: with a name you get just that value, and without a name you get an object keyed by output name where each entry holds sensitive, type and value.

Does marking a Terraform output sensitive keep it secret?

Only from casual display. A sensitive output is redacted in plan and apply output and in the unnamed terraform output listing, but it is stored in plain text in state and printed in plain text by terraform output NAME, -json and -raw. Anyone who can read the state, including through terraform_remote_state, can read the value.

Why does HashiCorp recommend alternatives to terraform_remote_state?

The terraform_remote_state data source exposes only root outputs to the reading configuration, but it must download the entire state snapshot to do so, so every consumer needs read access to every resource attribute and secret in that state. HashiCorp recommends publishing the specific values to a store such as a parameter store, Consul or a Kubernetes ConfigMap, or using tfe_outputs in HCP Terraform.

What is the difference between values and nonsensitive_values in tfe_outputs?

In the tfe_outputs data source, values contains every output of the source workspace and is marked sensitive as a whole, so anything derived from it is redacted and outputs that use it must be marked sensitive. nonsensitive_values contains only the outputs that are not sensitive in the source workspace, so those can be shown in plans. A sensitive output does not appear in nonsensitive_values.

Do HCP Terraform run triggers pass outputs between workspaces?

No. A run trigger only queues a run in the downstream workspace when a run in a source workspace applies successfully. The downstream configuration still has to read the new values itself, usually with the tfe_outputs data source. Triggered runs wait for confirmation unless the downstream workspace enables Auto-apply run triggers, which is separate from its general auto-apply setting.

Does the Terraform lock file pin module versions?

No. The dependency lock file currently tracks only providers. Each time Terraform installs modules it picks the newest version matching the module's version constraint, so a range like ~> 4.0 can change between runs. Pin registry modules with an exact version and Git modules with a ref, ideally a full commit SHA.

Why does terraform init fail on Linux CI with a lock file created on macOS?

If the provider was first installed through a filesystem or network mirror, Terraform recorded only the macOS package's h1 checksum, so the Linux package cannot be verified. Run terraform providers lock with a -platform option for each platform you use, such as linux_arm64 or windows_amd64, and commit the updated lock file. Registry installs avoid this because they record signed zh checksums for every platform.

How does the ~> operator work in Terraform version constraints?

The pessimistic constraint operator ~> lets only the right-most version component you wrote increase. ~> 5.40 means at least 5.40 and below 6.0, while ~> 5.40.0 means at least 5.40.0 and below 5.41.0. It does not match pre-release versions, which must be selected with an exact version.

Source

This lesson covers the "Infrastructure lifecycle and operations" domain of the official Terraform Pro 002 exam guide. Vendors revise their guides — check the source for the current version.

Sign up free to mark lessons complete, bookmark topics and track your exam readiness.

Spotted a mistake in this lesson?