Skip to content

Coming from HCP Terraform, Scalr or OTF

llms.txtlists every page for an agent
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`.

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.

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

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:

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

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.

  1. 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 code
    envs/dev/network/ main.tf calls the module; backend.tf; terraform.tfvars
    envs/prod/network/ the same, with prod's key and values
  2. Move each workspace’s state into the bucket (state move), one root at a time, before its backend change merges.

  3. Create the secrets the import listed under pass, and copy any variable it did not write (variables).

  4. Port the policies (policies) and run tasks (run tasks).

  5. Write the pipeline and open a pull request:

    Terminal window
    npx terragucci config check
    npx terragucci init

    The plan note on that pull request should show no change for each moved root.

  6. Disconnect each moved workspace from its repository and lock it, so nothing runs against the old state.

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:

migrations/move-network.yml
backends:
- root: envs/prod/network
from:
backend: cloud
config:
hostname: app.terraform.io
organization: acme
workspaces:
name: prod-network

Migration files lists the keys.

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.

  1. Stop new runs on the workspace: disconnect its repository and cancel queued runs.

  2. Replace the root’s cloud block with a remote backend that names the same workspace:

    terraform {
    backend "remote" {
    hostname = "app.terraform.io"
    organization = "acme"
    workspaces {
    name = "prod-network"
    }
    }
    }
    Platform hostname
    HCP Terraform app.terraform.io, or your Terraform Enterprise host
    Scalr <account>.scalr.io
    OTF your OTF host
    Terminal window
    terraform login app.terraform.io
    terraform init -reconfigure
  3. Replace the remote backend 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-state

    Answer yes to copy the state.

  4. Check the copy:

    Terminal window
    terraform plan

    It should report no changes. Commit the s3 block; the old state stays on the platform until you delete the workspace.

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.

Copy the set’s Rego and policies.hcl into the repo, or point policy.source at its repo:

policy:
engine: opa
path: policies
input: hcp

input.plan and input.run carry HCP Terraform’s field names; the run fields lists what each holds.

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

terragucci

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.