Terraform Professional: Sharing Data Between Projects and Managing Provider and Module Versions
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.
On this page10 sections
- What are the ways to pass data between Terraform projects?
- How does terraform output work in scripts and pipelines?
- What does sensitive = true protect on an output?
- Why does HashiCorp warn about terraform_remote_state?
- How do you share values without sharing state?
- How do tfe_outputs and run triggers connect HCP Terraform workspaces?
- When is the external data source the right tool?
- How do provider and module version constraints work?
- What does the dependency lock file record, and how do you keep it working across platforms?
- How do you pin module versions by source type?
- 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:
| Approach | What the consumer needs | Coupled to | Main trade-off |
|---|---|---|---|
terraform_remote_state data source | Read access to the producer's whole state snapshot | The producer's backend and state location | Simple, 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 outputs | The producer's organization and workspace name | Outputs 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 source | An agreed key or name in the store | Access 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 API | Agreed tags or names on the real objects | No 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.
| Command | What it prints | Use it for |
|---|---|---|
terraform output | Every root output in Terraform-style syntax; sensitive ones appear as <sensitive> | Humans reading a terminal (it is not JSON) |
terraform output NAME | That one value in Terraform syntax, so strings keep their double quotes | Inspection; awkward in shell because of the quotes |
terraform output -raw NAME | A string, number or bool printed as-is, with no quotes, escaping or trailing newline | Passing a single primitive value to curl, export and similar tools |
terraform output -json NAME | Just that output's value as JSON | Lists, maps and objects, usually piped to jq |
terraform output -json | One 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 goes | Sensitive output shown? |
|---|---|
terraform plan / terraform apply output | Redacted |
terraform output (no name) | Redacted as <sensitive> |
terraform output NAME | Plain text — you asked for it by name |
terraform output -json and -raw | Plain text; -json marks the entry "sensitive": true |
| The state file | Plain text |
A consumer's terraform_remote_state | Readable, 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 nooutputs.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 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:
| Attribute | Contains | Sensitivity |
|---|---|---|
values | Every output of the source workspace | Marked sensitive as a whole, because the data source cannot know in advance which outputs are secret |
nonsensitive_values | Only outputs that are not sensitive in the source workspace | Not 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
queryas 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
splitortonumber. - 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.
| Operator | Meaning | Example |
|---|---|---|
= or none | Exactly 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
| Situation | What terraform init does |
|---|---|
| Provider already locked and the version still satisfies the constraints | Re-selects the locked version, even if newer releases exist |
| Locked version no longer satisfies a changed constraint | Fails and tells you to run terraform init -upgrade |
terraform init -upgrade | Picks the newest version allowed by the constraints and rewrites the entry |
| Provider no longer needed by configuration or state | Removes its entry; while state still holds its resources (for example before a destroy is applied) the entry stays |
| Modules | Not 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 anh1: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=.
| Source | How to pin | Notes |
|---|---|---|
| Public or private Terraform registry | version = "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 path | None (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.
- 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.