Coming from HCP Terraform, Scalr or OTF
Optional: hand this page to your coding agentThe steps work by hand too.Show the whole prompt
Read https://intentius.io/terragucci/guides/coming-from-hcp-terraform-scalr-or-otf/.
Run `npx terragucci import hcp --organization <org> --dry-run` (`import otf --hostname <host>`, or `import scalr --hostname <account>.scalr.io`) with the token in `TF_TOKEN_<host>`, and show me what it prints. Propose a directory per workspace for each directory it lists as run by several workspaces.
Once I agree, run it without `--dry-run`. Add what the concepts table maps that the import does not write: `policy` with `input: hcp` for an OPA policy set, `steps` for run tasks. Run `npx terragucci config check --json` and `npx terragucci init`, and open a pull request with terragucci.yml, the `terraform.tfvars` files and the generated pipeline.
List the sensitive variables the import named under `pass` that I must recreate as CI secrets, and each Sentinel policy that needs a Rego rewrite.
Do not change a `cloud` or `backend` block, do not run `init -migrate-state`, `state pull` or `state push`, and do not create secrets: a person moves the state.
Never apply, approve (a pull request review or `terragucci approve`), override a policy denial (`terragucci override`), use `--mode apply`, or merge; never touch `.chant/allowed_signers` or `chant/lifecycle`.Result
Section titled “Result”Each workspace becomes a root with its own state in your bucket. The jobs init writes run in your forge’s CI; there is no account and no server, and every record lands in your git and bucket.
Concepts
Section titled “Concepts”| HCP Terraform, Scalr, OTF | terragucci |
|---|---|
| Workspace | a root: one directory, one state. terragucci runs no CLI workspaces |
| Several workspaces on one working directory | a directory per workspace, each with its backend key and terraform.tfvars, calling a shared module |
| Workspace state | a key in your S3 bucket, moved by a backend move |
| Terraform variables | terraform.tfvars in the root, committed |
| Environment variables, variable sets | env for values; CI secrets named under pass for the rest |
| Dynamic provider credentials | oidc: a plan role and an apply role per cloud |
| Terraform version per workspace | version per root glob, or the root’s .terraform-version |
| VCS-driven runs, trigger patterns | the generated pipeline: a pull request plans the roots its files reach |
| Speculative plan | the plan note and terragucci/plan status on the pull request |
| Confirm and apply, auto-apply | gate: always, on-destructive (default) or never, then approve a wave |
Run triggers, tfe_outputs |
waves: a root that reads another through terraform_remote_state applies in a later wave; waves.after, which an import writes from run triggers, for an order the reads do not give |
| Sentinel policy set | policy in Rego, rewritten by hand |
| OPA policy set | policy.input: hcp with engine: opa; policies.hcl runs as is |
| Scalr OPA policy group | policy with engine: opa and input: plan, each policy’s input.tfplan changed to input; a check of input.tfrun is rewritten |
| Soft-mandatory override | terragucci override by a person policy.override lists |
| Run tasks | steps after plan, with on_failure: fail or approve |
| Workspace lock | root locks and the backend’s lock file |
| Agents, remote execution | jobs on your CI’s runners, your own ones by label with runner |
| Health assessments | drift, a cron schedule |
| Cost estimation | cost with Infracost |
| Notifications | notify: Slack, Teams or a signed webhook |
| Private registry | a module registry in your bucket |
| Ephemeral workspaces | ephemeral environments per pull request, with a TTL |
| Teams and permissions | your forge’s permissions and cloud IAM (access and identity) |
| Run history | the report per run and chant/lifecycle |
Import the workspaces
Section titled “Import the workspaces”terragucci import reads the workspaces over the platform’s API and writes terragucci.yml, with the same token Terraform uses for the host, in TF_TOKEN_<host> or credentials.tfrc.json:
export TF_TOKEN_app_terraform_io=... # a user or team token that reads the workspaces
npx terragucci import hcp --organization acme --dry-run--hostname names a Terraform Enterprise host.
npx terragucci import otf --hostname otf.example.com --organization acme --dry-runexport TF_TOKEN_acme_scalr_io=... # or SCALR_TOKEN
npx terragucci import scalr --hostname acme.scalr.io --dry-runIt reads every environment the token sees; --environment names one, by name or ID.
A workspace becomes a root when it is connected to this repo (the origin remote, or --repo owner/name), at its working directory, or when a cloud block or remote backend in the repo names it. Then, for each root:
| On the platform | What the import writes |
|---|---|
| Working directory | roots |
| Several workspaces on one directory | nothing: it lists the directory and its workspaces to split, and leaves it out of roots |
| Terraform version | version, a map of root to release when they differ; latest or a range is left to the root’s required_version |
| OpenTofu (Scalr) | binary: tofu when every workspace runs it |
| Terraform variable, not sensitive | <root>/terraform.tfvars, unless the file is already there |
| Environment variable, not sensitive | env, unless workspaces set it to different values |
| Sensitive variable | its name under pass.secrets: TF_VAR_<key> for a Terraform variable; the value is never read |
| Variable set (HCP Terraform, OTF) | as the workspace’s own variables, which win over the set’s |
| Run trigger (HCP Terraform) | waves.after, unless a terraform_remote_state read already gives the order |
| Policy group (Scalr) | nothing: it lists each group, its repo and each policy’s enforcement level, to port as below |
It prints every setting under the headings of the other imports: written, done with no key, not mapped. A workspace connected to another repo, or to none that no block in the repo names, is listed and skipped. Scalr’s API lists no run triggers, so for Scalr an order the reads do not give goes in waves.after by hand.
-
Run the import. Where it lists several workspaces on one working directory, give each its own directory, with the code in a module that each root calls:
modules/network/ the shared codeenvs/dev/network/ main.tf calls the module; backend.tf; terraform.tfvarsenvs/prod/network/ the same, with prod's key and values -
Move each workspace’s state into the bucket (state move), one root at a time, before its backend change merges.
-
Create the secrets the import listed under
pass, and copy any variable it did not write (variables). -
Write the pipeline and open a pull request:
Terminal window npx terragucci config checknpx terragucci initThe plan note on that pull request should show no change for each moved root.
-
Disconnect each moved workspace from its repository and lock it, so nothing runs against the old state.
State move
Section titled “State move”A backend move reads the workspace’s current state version over the platform’s API and writes it to your bucket, approved by digest like any migration. Change the cloud block to the s3 block and add the migration in one pull request, with the token in TF_TOKEN_<host> as a CI secret:
backends:
- root: envs/prod/network
from:
backend: cloud
config:
hostname: app.terraform.io
organization: acme
workspaces:
name: prod-networkMigration files lists the keys.
By hand
Section titled “By hand”With Terraform or OpenTofu (tofu in place of terraform). Neither binary migrates straight out of a cloud block, so the block becomes a remote backend first.
-
Stop new runs on the workspace: disconnect its repository and cancel queued runs.
-
Replace the root’s
cloudblock with aremotebackend that names the same workspace:terraform {backend "remote" {hostname = "app.terraform.io"organization = "acme"workspaces {name = "prod-network"}}}Platform hostnameHCP Terraform app.terraform.io, or your Terraform Enterprise hostScalr <account>.scalr.ioOTF your OTF host Terminal window terraform login app.terraform.ioterraform init -reconfigure -
Replace the
remotebackend with the bucket, and migrate:terraform {backend "s3" {bucket = "acme-state"key = "envs/prod/network.tfstate"region = "us-east-1"use_lockfile = true}}Terminal window terraform init -migrate-stateAnswer
yesto copy the state. -
Check the copy:
Terminal window terraform planIt should report no changes. Commit the
s3block; the old state stays on the platform until you delete the workspace.
Variables
Section titled “Variables”| Variable on the platform | Where it goes |
|---|---|
| Terraform variable, not sensitive | terraform.tfvars in the root |
| Environment variable, not sensitive | env in terragucci.yml, which every job gets |
| Variable set, not sensitive | env, or the same values in each root’s terraform.tfvars |
| Cloud credentials | oidc roles; no static keys |
| Sensitive value | see the tabs |
Sensitive values cannot be read back from the platform; copy them from where they came from.
A masked CI/CD variable named TF_VAR_<name> reaches every job, and TF_ variables pass to the binary as set.
Create a repository secret named as the variable, such as TF_VAR_db_password, and list it under pass; the jobs that plan, apply and check drift get it under that name. A repository variable goes under vars.
pass:
secrets: [TF_VAR_db_password]Policies
Section titled “Policies”Copy the set’s Rego and policies.hcl into the repo, or point policy.source at its repo:
policy:
engine: opa
path: policies
input: hcpinput.plan and input.run carry HCP Terraform’s field names; the run fields lists what each holds.
terragucci does not run Sentinel. Rewrite each policy in Rego over the plan JSON (input: plan), with a deny rule per check (writing a policy). A soft-mandatory policy becomes a denial that a person in policy.override can let through; an advisory one becomes a warn rule.
Scalr hands a policy the plan as input.tfplan and the run (workspace, environment, VCS, user, cost estimate) as input.tfrun. Set input: plan and change input.tfplan to input; terragucci has no tfrun, so rewrite a check of it against the run fields under input: hcp, or drop it. In scalr-policy.hcl, a hard-mandatory policy becomes a deny rule, a soft-mandatory one a deny rule a person in policy.override can let through, and an advisory one a warn rule. terragucci import scalr lists each policy group and its policies’ levels.
Run tasks
Section titled “Run tasks”A run task posts the plan to a service and waits for a pass or fail. A step runs a command in the job instead, with the plan file in TG_PLAN_FILE:
steps:
- name: scan
run: ./scripts/scan.sh "$TG_PLAN_FILE"
after: plan
on_failure: approve| Run task enforcement | Step |
|---|---|
| Mandatory | on_failure: fail, the default: the root fails |
| Advisory | on_failure: approve: the wave waits at its gate for a person |
Limits
Section titled “Limits”| On the platform | In terragucci today |
|---|---|
| State move | one workspace per entry of a backend move |
| Workspace settings | the import reads directories, versions, variables and run triggers; agent pools, auto-apply, run tasks and policy sets are listed, not written |
| Sentinel | not run; each policy is rewritten in Rego |
| HCP Terraform Stacks | no equivalent; a Stack’s components become roots ordered by terraform_remote_state |
| No-code provisioning, workspace UI | none; changes come through pull requests |
| Sensitive variables | not exported by the platform; recreated from their source |
Roots still on a cloud block |
planned, but migrations, export, state versions and ephemeral copies refuse them; on remote execution the job’s oidc roles do not reach the run |
| Run history | stays on the platform |
- Add terragucci to a repo for the forge settings.
- Scope state access to give each environment’s role only its keys.
- Coming from Spacelift or env zero for env zero’s remote backend, which moves the same way.
These docs count page views and clicks with PostHog. They set no cookies, store nothing in your browser, and send nothing when your browser asks not to be tracked.