Terraform Professional: HCP Terraform Multi-Environment Workspaces, Version Upgrades and Policy as Code
Multi-environment configuration in HCP Terraform means running one infrastructure design as separate dev, staging and production workspaces that share code but keep their own state, variables, permissions and Terraform version. This lesson covers the three operational jobs the Terraform Infrastructure Engineer Professional exam groups together: wiring environments to workspaces (the cloud block, VCS settings, state sharing, run triggers and execution modes), upgrading Terraform across those workspaces without surprise changes, and testing and shipping Sentinel and OPA policy sets from a CI/CD pipeline.
On this page10 sections
- One working directory, several environment workspaces: the cloud block
- VCS-connected workspaces per environment: working directory, branch and variables
- Passing data and changes between environment workspaces
- Local execution mode: what HCP Terraform still does
- The workspace Terraform version setting
- Upgrading Terraform across workspaces safely
- Testing Sentinel policies in CI with sentinel test
- Testing OPA policies in CI with opa test
- Delivering policy sets from a pipeline
- Pinning the policy runtime: policy_tool_version and Agent mode
- Map one working directory to several environment workspaces with cloud block tags, and fill omitted settings with TF_CLOUD_* and TF_WORKSPACE
- Configure VCS-connected workspaces per environment by working directory or branch, and keep per-environment values in workspace variables
- Share data between environment workspaces with least privilege using remote state sharing scopes, tfe_outputs and run triggers
- Explain what Local execution mode keeps and drops, and how the CLI-versus-workspace version check behaves
- Choose exact versions or constraints for workspaces and run an upgrade in the right order: widen required_version, plan-only on the new version, change the setting, empty apply
- Test Sentinel policies with sentinel test and run-generated mocks, and OPA policies with opa test against correctly shaped input
- Deliver policy sets from a pipeline through VCS, the Policy Sets API or tfe_slug, and pin the Sentinel or OPA runtime
One working directory, several environment workspaces: the cloud block
The cloud block connects a working directory to HCP Terraform or Terraform Enterprise, and its workspaces argument decides whether that directory serves one workspace or a set of environment workspaces. Use name to pin exactly one workspace; use tags to map the directory to every workspace that carries the listed tags, so dev, stage and prod can all be planned from the same code.
| Setting | What it selects | CLI workspace commands |
|---|---|---|
workspaces { name = "network-prod" } | Exactly that one workspace | Not available: terraform workspace cannot list, select or create others |
workspaces { tags = { app = "payments" } } | Every workspace that has all the listed tags (a map of key-value tags, or a list of single-value tags) | Allowed: list, select and new work across the matched set |
workspaces { project = "Payments" } | Narrows where workspaces are looked up and created; it does not choose a set by itself | Combined with tags or name |
Tag matching is all-must-match, not any-match. If the block lists app = "payments" and env = "prod", only the workspace carrying both tags is in the set; to switch between environments from one directory, list only the tags the environments share (for example app) and keep the environment-specific tag off the block. name and tags are mutually exclusive, and prefix belongs to the older remote backend — the cloud block does not accept it.
With a tag mapping, terraform workspace new payments-sandbox creates the workspace in HCP Terraform, in the block's project (or the default project if none is given), and applies the block's tags, so the new environment immediately joins the directory's set.
Filling omitted settings from environment variables
The same root configuration can be pointed at different organizations, projects and hosts by leaving settings out of the block and supplying them as environment variables. Values written in the block always win over the variables.
| Variable | Fills |
|---|---|
TF_CLOUD_ORGANIZATION | organization |
TF_CLOUD_HOSTNAME | hostname (default app.terraform.io; set it to reach a Terraform Enterprise host) |
TF_CLOUD_PROJECT | workspaces.project |
TF_WORKSPACE | Selects a workspace: the single workspace when workspaces is omitted, or one workspace from a tag-matched set |
TF_WORKSPACE selects but never creates: the named workspace must already exist (and, with tags, be in the matched set), so a pipeline that adds an environment provisions the workspace first — for example with the tfe provider, carrying the right tags — and then selects it non-interactively. Variables prefixed TFE_ (TFE_HOSTNAME, TFE_ORGANIZATION, TFE_TOKEN) configure the tfe provider, not the cloud block. CLI workspaces, directory-per-environment layouts and Stacks are compared in the lesson on scaling projects and multiple environments.
VCS-connected workspaces per environment: working directory, branch and variables
A VCS-connected workspace runs Terraform from a working directory on one branch of its repository, and those two settings are how one repository serves several environment workspaces.
- Folder per environment — a repository with
envs/dev,envs/stageandenvs/prodroot configurations (calling shared modules) gets one workspace per folder, each with its working directory set to its folder. The working directory is relative to the repository root and defaults to the root. - Branch per environment — a single root configuration can be served by workspaces that track different branches: staging tracks
main, production tracksrelease, so promotion is a merge intoreleaseand the branch history records every promotion. Each workspace tracks the repository's default branch unless a branch is set (vcs_repo.branchintfe_workspace).
Keep the neighbouring settings apart: trigger patterns filter runs by file paths (not branches), Git-tag triggering starts runs from tags, and run triggers chain applies between workspaces. None of them assigns a branch to a workspace. Trigger filters themselves are covered with VCS-driven workflows.
When several workspaces share the same branch and directory, the code is identical, so per-environment values belong in each workspace's Terraform variables. They are edited in HCP Terraform under workspace permissions, so a person who can commit to the repository cannot change production's values. A committed terraform.tfvars, a set of *.auto.tfvars files (every one in the directory is loaded by every workspace) or a locals map keyed by environment all live in the repository and can be changed by any committer.
Passing data and changes between environment workspaces
Workspaces exchange data through outputs, controlled by the source workspace's remote state sharing setting, and they exchange timing through run triggers.
Remote state sharing scopes
| Option | Who can read the state during runs | Fits when |
|---|---|---|
| Share with specific workspaces | Only the listed workspaces | A few known readers, possibly in other projects; least privilege. Each new reader needs an edit on the source. |
| Share with all workspaces in this project | Every current and future workspace in the source's own project | All readers live in that project and new ones keep being added; other projects stay excluded. |
| Share with all workspaces in this organization | Every workspace in the organization | Rarely justified; broadest exposure. |
New workspaces default to specific workspaces with an empty list, so a consumer's terraform_remote_state fails with an access error until it is added. Workspaces created before April 2021 kept the older default of organization-wide sharing, which is why audits find old workspaces wide open; tightening them means listing the actual readers (project scope would cut off any reader in another project). Remote state sharing is a workspace setting about runs reading state — granting a team read-state permission does not give another workspace's runs access.
tfe_outputs instead of terraform_remote_state
HashiCorp recommends the tfe provider's tfe_outputs data source for cross-workspace data because it returns only the source workspace's outputs, without access to its full state (terraform_remote_state works but depends on full state access). Its values attribute is marked sensitive as a whole, preemptively, so anything derived from it is sensitive — and Terraform rejects sensitive values in for_each, because they would appear in resource addresses. Use nonsensitive_values, which holds only the outputs known to be non-sensitive, for for_each keys. A variable set is not a substitute: it holds static values someone typed and does not follow the source's outputs.
Run triggers chain environments
A run trigger is configured on the downstream workspace (the one that should run), naming up to 20 source workspaces in the same organization; a successful apply in a source queues a run downstream. It carries no values — pair it with tfe_outputs for the data. Creating one requires admin access to the downstream workspace and permission to read runs on each source.
Runs queued by run triggers do not auto-apply by default. The separate setting Auto-apply run triggers (auto_apply_run_trigger in tfe_workspace) controls them independently of the workspace's main Auto-apply, which is why a workspace can auto-apply commit runs while triggered runs wait at Needs Confirmation. Trigger patterns, by contrast, react to VCS file changes, not to other workspaces.
Local execution mode: what HCP Terraform still does
In Local execution mode, plans and applies run on the operator's machine and HCP Terraform acts only as the state backend: it stores each new state version and locks the workspace while a local operation runs, so two people cannot write state at once.
Everything that belongs to a remote run stops:
- HCP Terraform evaluates no workspace variables and no variable sets — not even project- or organization-owned sets — so credentials and inputs must come from the local machine (tfvars files, environment variables). A plan that suddenly prompts for a variable or fails to authenticate after switching modes is this behaviour;
- No policy sets are evaluated, and local plans and applies do not appear in the workspace's run list.
- The CLI-versus-workspace Terraform version check (see the upgrade section) is skipped.
Switching back to Remote (or Agent) mode restores variables, policies and run history. Because the switch changes how every run executes, it is a heavy tool for a one-off problem.
The workspace Terraform version setting
Each HCP Terraform workspace has a Terraform version setting that chooses the binary its runs use; it can be an exact version or a version constraint.
- Default: the release that was current when the workspace was created. It never moves by itself, so an organization accumulates different versions by workspace age; upgrading is a deliberate change.
- Exact version (
"1.12.4"): fixed until someone edits it — right for production, where a newly published patch must not change runs without review. - Constraint: the workspace uses the newest available release that satisfies it, and moves when a new matching release appears.
"~> 1.12.0"takes every 1.12 patch and never 1.13;"~> 1.12.4"still admits 1.12.5 and later;">= 1.12.0"admits new minor releases too.
| Need | Setting |
|---|---|
| An environment picks up new patches of its minor series with no edits | A pessimistic constraint on the minor series, e.g. "~> 1.12.0" |
| An environment changes only through a reviewed change | Exact, e.g. "1.12.4" |
The state file's recorded version does not choose the binary, and the configuration's required_version only checks the running binary — it never sets the workspace version (its semantics are covered with version constraints). Manage the setting as code with tfe_workspace's terraform_version: with one resource using for_each over an environment map, put a version in each map entry so dev can move first while stage and prod stay put, every move being a reviewed pull request. Setting one value for all and applying with -target leaves code and reality disagreeing; a UI edit bypasses review.
Terraform Enterprise: site-admin Terraform versions
On Terraform Enterprise, site administrators maintain the list of Terraform versions workspaces can choose. New releases are not added automatically, which matters for air-gapped installations: an admin adds a version with its number, a Linux 64-bit download URL (an internal mirror when air-gapped) and the binary's SHA256 checksum. Status controls availability:
| Status | Who can select it |
|---|---|
| Enabled | Everyone |
| Beta | Site administrators only — for trying a release before general use |
| Disabled | Nobody; allowed only when no workspace uses the version |
The list shows how many workspaces use each version, so retiring an old release means moving those workspaces first; a version cannot be disabled while any workspace still selects it. Don't edit the default version entries (they are overwritten) — add a new version instead.
Upgrading Terraform across workspaces safely
A safe upgrade proves the configuration on the new version before any run can apply with it, then rewrites state with the new version. Per workspace, the order is:
- Widen
required_versionso the configuration admits the new release (with"~> 1.12.0"in the code, every 1.13 run of any kind fails). While the workspace still runs its old release, this changes nothing else. - Run a plan-only (speculative) run on the new version. A plan-only run can select a Terraform version for that run alone; the workspace keeps its setting for every other run, and a speculative plan can never be applied.
- Change the workspace's Terraform version (HashiCorp's tutorial changes both the workspace version and the configuration constraint).
- Apply an empty run. State records the version that last wrote it, and HCP Terraform does not apply a no-change run by default, so the Allow empty apply run type (
allow-empty-applyin the Runs API) applies with no resource changes and writes state with the new version. A speculative plan never writes state.
Runs API rules worth knowing: terraform-version is valid only on plan-only runs (not on auto-apply or saved-plan runs), so a version override can never apply changes; allow-empty-apply runs on the workspace's own version; refresh-only cannot be combined with is-destroy. Staged rollout strategy and state-downgrade risk are covered with version management.
The CLI-versus-workspace version check
When the local CLI touches a remote-execution workspace's state (terraform state mv, state rm, import and similar), the cloud backend compares the CLI version with the workspace's version setting — not with the release that setting resolves to.
| Workspace setting | CLI must satisfy | Example: CLI 1.12.4 |
|---|---|---|
Exact release, 1.3 or later (e.g. 1.13.1) | The same minor series, ~> x.y.0 | Rejected for 1.13.1; accepted for 1.12.2 |
Constraint (e.g. >= 1.12.0, < 1.14.0) | The constraint as written | Accepted, even if remote runs use 1.13.1 |
The error reads like "The local Terraform version (…) does not meet the version requirements for remote workspace …". The safe fix is to run the command with a CLI in the accepted series. -ignore-remote-version downgrades the error to a warning, but a newer CLI may then write state the workspace's own runs cannot read. Local execution mode also skips the check, but it changes how every run executes.
Testing Sentinel policies in CI with sentinel test
sentinel test runs every test case it finds at test/<policy>/*.hcl (or *.json), where <policy> is the policy file's name without .sentinel; each file is one case. With that layout a CI job runs sentinel test with no arguments. (tests/*.tftest.hcl belongs to terraform test, and sentinel.hcl configures the policy set, not test cases.)
A test case file can contain:
mockblocks — replace an import such astfplan/v2,tfconfig/v2,tfstate/v2ortfrunwith mock data (inline or from a file viasource);paramandglobalblocks — supply parameter values and inject values into the policy's scope;- a
testblock — the expected results, e.g.test { rules = { main = false } }.
A case with no test block expects the policy to pass. A "fail" case built to be denied therefore reports FAIL even when the policy works, until it asserts main = false. Enforcement levels are policy set configuration and do not change a test's expectation.
Mock data from real runs
HCP Terraform generates Sentinel mocks from a run's plan, which gives realistic test data:
- UI: on the run page, expand the plan section and choose Download Sentinel mocks. The bundle holds mock files and a sample
sentinel.hclforsentinel apply. - API: create a plan export of type
sentinel-mock-bundle-v0, poll until it finishes, and download it within about one hour, after which it expires. - Only a successful plan is required — a run that later failed a policy or an apply still offers mocks, which makes a failing run the ideal source for a reproduction case.
- The caller needs the Download Sentinel mocks permission on the workspace; reading runs is not enough.
- HCP Terraform attempts to scrub sensitive values but does not guarantee it, so review mock files before committing them.
Store mocks in a separate testdata directory so policy detection does not pick them up. Mind the two path bases: the sample sentinel.hcl uses paths relative to the working directory (so sentinel apply works from the repository root), but paths inside a test case are relative to the test file. A case at test/<policy>/<case>.hcl sits two levels below the repository root, so it reaches the root's testdata/ by climbing two levels first.
Testing OPA policies in CI with opa test
HCP Terraform OPA policies are ordinary Rego, so they are unit-tested with opa test: rules named test_… evaluate the policy with mocked input (with input as …), and nothing contacts HCP Terraform. sentinel test cannot evaluate Rego, and opa eval against a live speculative plan would need HCP Terraform.
The mock must have the shape HCP Terraform builds at evaluation time, which combines two documents:
| Path | Contains |
|---|---|
input.plan | The Terraform JSON plan: resource_changes, planned_values, configuration and so on |
input.run | Run details: organization, project, workspace settings (name, auto_apply, execution_mode …), is_destroy, speculative |
A rule about a run or workspace property reads it under input.run (a rule that blocks destroy runs reads input.run.is_destroy), so its test mocks that path; a rule about resources reads input.plan.resource_changes.
Building input from a real plan
The JSON execution plan is downloadable through GET /api/v2/plans/:id/json-output or /runs/:id/plan/json-output. These endpoints:
- reject organization tokens and need a user or team token with admin access to the workspace (read-runs is not enough);
- answer with a temporary redirect (HTTP 307) to a URL valid for about a minute — request a fresh one for each download;
- return the raw Terraform plan, sensitive values included, without the
plan/runwrapper. (The sanitized plan is a separate endpoint for HYOK workspaces.)
Passed straight in as input, the file has no input.plan, so a correct policy returns an empty deny. Wrap it as {"plan": <plan JSON>} (adding a run object if the policy needs one). Never "fix" the test by rewriting the policy to read input.resource_changes — that breaks it in real runs.
Delivering policy sets from a pipeline
A policy set takes its policies from one source: a connected VCS repository, or versions uploaded through the API (directly or via the tfe provider). The choice decides how a pipeline ships tested policies.
| Delivery | How it updates | Key settings |
|---|---|---|
| VCS-connected | Automatically on pushes to the tracked branch | policies_path (ingest only one sub-path of the repository); policy_update_patterns (globs, relative to the repository root, that decide which changes create a new version) |
| API versions | POST /policy-sets/:id/versions returns an upload link (valid about an hour); PUT a tar.gz of the policies to it; the version goes from pending to ready | Set has no VCS repository |
tfe provider | tfe_policy_set with slug = data.tfe_slug.x; tfe_slug checksums a local source_path, so a new version is uploaded only when those files change | No vcs_repo block |
In a monorepo, a VCS-connected set uses policies_path to load only its folder and policy_update_patterns so application commits don't create policy versions. working_directory and trigger_patterns are the workspace equivalents and are not policy set arguments; policies_path and policy_update_patterns do nothing without a VCS connection. A slug cannot be combined with a VCS connection, and the API refuses uploads to a VCS-connected set with a 422 ("The policy set does not support uploading versions") — changes reach it only by merging into the tracked branch. Configuration versions are the workspace counterpart and carry Terraform code, not policies; policy_ids builds a set from individually managed tfe_policy resources instead of uploading a directory.
Pinning the policy runtime: policy_tool_version and Agent mode
The Sentinel or OPA release that evaluates a policy set is a policy set setting, policy_tool_version, not something inherited from the workspace or its agent. Pinning it makes policy results reproducible between CI tests and real runs.
Sentinel
| Platform environment mode (policy checks) | HCP Terraform Agent mode (policy evaluations) | |
|---|---|---|
| Sentinel version | Not selectable; supports Sentinel only up to 0.40.x, so newer language features are unavailable | A specific version chosen per set (agent_enabled = true plus policy_tool_version) |
| Cost estimation data | Available | Not available — cost-limit policies lose their input |
| Set-level override option | Not offered | Available |
An unset version defaults to latest (the Policy Sets API default; the tfe provider selects the latest available version when it is left out), so the runtime is not pinned to a release you chose; HashiCorp recommends choosing a specific version. Scoping (global, project or workspace selection) and mock generation do not depend on the mode. Enforcement levels, scoping and overrides themselves are covered with policy enforcement.
OPA
- OPA sets run in Agent mode by default (
agent_enabledtrue). policy_tool_version = "latest"is rejected for OPA sets; according to thetfeprovider documentation and the Terraform Enterprise release note that deprecated OPAlatest, sets that were created onlatestwere pinned to the most recent supported release.- The runtime changes only when
policy_tool_versionis set to a newer explicit version — uploading a new policy set version changes the policies, not the runtime.
Tip. Expect production scenarios built around a stated requirement — least privilege, no edits when workspaces are added, changes only through review, admins first, no surprise changes — that ask which setting, command, file layout or step order meets it. Near-twin options are common: cloud block name versus tags and TF_CLOUD_* versus TFE_* variables; working directory versus branch versus trigger patterns; the three remote state sharing scopes; tfe_outputs values versus nonsensitive_values; Auto-apply versus Auto-apply run triggers; exact versions versus constraints; plan-only versus saved-plan runs; exact-version versus constraint behaviour in the CLI version check; Enabled, Beta and Disabled Terraform Enterprise versions; Sentinel test-file versus sentinel.hcl paths; input.plan versus input.run in OPA mocks; VCS-connected versus API-uploaded policy sets; and Sentinel platform versus Agent mode. Some items quote an error message or an API attribute set and ask for the cause or the valid combination, and several are select-two.
- cloud block tags map a directory to every workspace carrying all listed tags and allow CLI workspace commands; name pins one workspace and disables them.
- TF_CLOUD_ORGANIZATION, TF_CLOUD_HOSTNAME and TF_CLOUD_PROJECT fill omitted cloud block settings; TF_WORKSPACE selects but never creates a workspace; TFE_* variables belong to the tfe provider.
- New workspaces share state with an empty specific list (pre-April-2021 ones default to organization-wide); tfe_outputs reads outputs without full state access, and its nonsensitive_values can drive for_each.
- Run triggers live on the downstream workspace, fire after a successful source apply, and auto-apply only when Auto-apply run triggers is on.
- Local execution mode keeps state storage and locking but drops workspace variables, variable sets, policies, run records and the CLI version check.
- A workspace version constraint resolves to the newest matching release; use exact versions where changes must be reviewed, and manage terraform_version per environment in tfe_workspace.
- Upgrade order: widen required_version, plan-only run on the new version, change the workspace version, then an empty apply to rewrite state; terraform-version is valid only on plan-only runs.
- sentinel test reads test/<policy>/*.hcl; a case without a test block expects a pass; run mocks need only a successful plan, expire after an hour via the API, and are not guaranteed scrubbed.
- OPA tests need input shaped as {plan, run}; the JSON plan endpoint returns the raw plan, needs workspace admin with a user or team token, and redirects to a short-lived URL.
- VCS-connected policy sets update on push (policies_path, policy_update_patterns) and refuse API uploads; unconnected sets take tar.gz versions or tfe_slug; pin runtimes with policy_tool_version (OPA rejects latest; Sentinel Agent mode has no cost data).
Frequently asked questions
Can terraform workspace commands work with HCP Terraform?
Yes, when the cloud block maps the working directory to workspaces by tags. terraform workspace list and select then work across every workspace that carries all the listed tags, and terraform workspace new creates a workspace in HCP Terraform with those tags in the configured project. When the block uses name, the directory is pinned to one workspace and the workspace commands are not available.
Does setting TF_WORKSPACE create an HCP Terraform workspace?
No. TF_WORKSPACE only selects a workspace that already exists (and, with a tag mapping, matches the tags). Pipelines that add an environment create the workspace first, for example with the tfe provider's tfe_workspace resource carrying the right tags, and then select it with TF_WORKSPACE.
Should I use tfe_outputs or terraform_remote_state between HCP Terraform workspaces?
HashiCorp recommends tfe_outputs, because it reads only the source workspace's outputs and does not require access to its full state. Its values attribute is marked sensitive as a whole, so use nonsensitive_values when an output must drive for_each or appear in resource addresses. The source workspace must still allow the consumer through its remote state sharing setting.
Why don't runs started by a run trigger auto-apply?
Runs created by run triggers are governed by a separate workspace setting, Auto-apply run triggers (auto_apply_run_trigger in the tfe provider), which is independent of the workspace's main Auto-apply setting. Until it is enabled, triggered runs stop at Needs Confirmation even if commit-driven runs apply automatically.
How do I upgrade the Terraform version of an HCP Terraform workspace safely?
Widen the configuration's required_version so it admits the new release, start a plan-only run that selects the new version (the only run type that can override the version), then change the workspace's Terraform version and start an Allow empty apply run. The empty apply writes the state with the new version even when there are no resource changes, which a normal no-change run would not do.
What does -ignore-remote-version do?
It turns the cloud backend's version-mismatch error into a warning when a local CLI runs state commands against a remote-execution workspace. The check normally requires the same minor series as an exact workspace version (or the constraint as written). Ignoring it lets a newer CLI write state that the workspace's own runs may not be able to read, so the safer fix is to use a CLI release the workspace accepts.
Where do Sentinel test cases go, and how do I get mock data?
sentinel test looks for test/<policy>/*.hcl or *.json, one file per case, and a case without a test block expects the policy to pass. Mock data can be downloaded from any run with a successful plan, using Download Sentinel mocks on the run page or a sentinel-mock-bundle-v0 plan export through the API. Keep mocks in a testdata directory, reference them relative to the test file, and review them for sensitive values before committing.
Can I upload a new policy set version to a VCS-connected policy set?
No. A policy set connected to VCS updates only when changes are pushed to its tracked branch, and the Policy Sets API refuses version uploads to it with a 422 error. Only policy sets without a VCS repository accept tar.gz versions through POST /policy-sets/:id/versions or the tfe provider's tfe_slug-based slug argument.
Source
This lesson covers the "HCP Terraform and Terraform Enterprise 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.