stack2tf
Run HCP Terraform Stacks configurations with plain Terraform / OpenTofu — no HCP Terraform, no extra orchestrator.
stack2tf reads a Terraform Stacks project (*.tfcomponent.hcl + *.tfdeploy.hcl)
and runs it on the open-source CLI. It builds the component dependency graph,
generates a standalone Terraform root module per component, executes them in
dependency order, and passes each component's outputs to its dependents — the
same model as Terraform Stacks, without the hosted platform.
┌──────────────── stack2tf ────────────────┐
│ parse stack → build DAG → generate TF → │
│ run terraform per component → wire outputs│
└───────────────────────────────────────────┘
│ drives
▼
terraform / tofu (does the real provisioning)
Table of contents
- Features
- How it works
- Requirements
- Installation
- Quick start
- CLI reference
- Concepts
- Authentication
- State and locking
- Whole-stack planning
- Cross-stack outputs
- CI/CD
- Comparison: stack2tf vs Terragrunt vs Terraform Stacks
- Limitations
- Project layout
- Contributing
- Releasing
- License
Features
- Dependency DAG + run queue — components run in dependency order; independent
ones can run concurrently (
--parallelism). - Output propagation —
component.x.outputis wired to dependents automatically. for_eachcomponents & per-account providers — expanded to one module per instance.- Deployment inputs evaluated by Terraform — any HCL function works (
merge,cidrsubnet, …), not a hard-coded subset. - OIDC auth —
assume_role_with_web_identity, hub + spoke roles, same as Stacks. - Remote state per component — optional S3 backend with a per-component key.
- Whole-stack planning — a single unified plan report across all components, with cross-component values derived from real plan artifacts.
- Cross-stack outputs — publish/consume outputs between stacks.
- No lock-in — pure Python + the
terraform/tofubinary you already use.
How it works
stack2tf.py CLI: discover deployments, dispatch commands
└── stackrun.py engine: build units, DAG run queue, generate TF,
run terraform, propagate outputs, aggregate the plan
├── stackparse.py parse stack files, for_each expansion, each.* substitution
└── hclexpr.py reconstruct HCL expressions, rewrite Stacks references
Each component instance becomes a standalone Terraform root module:
<stack>/.stack2tf/<deployment>/<component>/
main.tf provider (assume_role_with_web_identity) + module "this" + output "outputs"
deploy.tf deployment inputs as Terraform-evaluated locals (local._deploy.*)
variables.tf identity_token + dep_<component> variables
locals.tf ported stack locals (native TF functions)
backend.tf S3 (per-component key) when --state-bucket is set, else local
upstream.tf other stacks' published outputs (only with --upstream)
terraform.tfvars.json injected dependency outputs (written at run time)
Requirements
- Python 3.8+
terraformortofuonPATH(needed forvalidate/plan/apply/destroy;list/show/--dry-rundo not)- AWS credentials able to assume the deployment / spoke roles (for real plan/apply)
Installation
From PyPI (recommended)
stack2tf is published on PyPI:
pip install stack2tf
This puts a stack2tf command on your PATH (and installs the only runtime
dependency, python-hcl2). You still need terraform or tofu available, e.g.:
brew install hashicorp/tap/terraform # or: brew install opentofu
stack2tf --help
Pin a version if you prefer: pip install stack2tf==0.1.0.
From source
git clone https://github.com/quangnhut123/stack2tf.git
cd stack2tf
pip install . # or: pip install -r requirements.txt
Or install a specific tag straight from git:
pip install "git+https://github.com/quangnhut123/stack2tf@v0.1.0"
Quick start
Try it with the bundled no-cloud example (uses the built-in terraform_data
resource — no AWS, no credentials):
python3 stack2tf.py plan --chdir examples/local-stack
============================================================
WHOLE-STACK PLAN
============================================================
base +1 ~0 -0
consumer +1 ~0 -0
------------------------------------------------------------
TOTAL +2 ~0 -0
Against a real stack:
# 1. inspect (no AWS calls)
python3 stack2tf.py list --chdir ../my-stack
python3 stack2tf.py plan --chdir ../my-stack --deployment prod --dry-run
# 2. validate generated Terraform (init + validate, no provisioning)
python3 stack2tf.py validate --chdir ../my-stack --deployment prod
# 3. provide the OIDC token, then provision in dependency order
export AWS_WEB_IDENTITY_TOKEN="$(cat token.jwt)"
python3 stack2tf.py apply --chdir ../my-stack --deployment prod \
--state-bucket my-tf-state --state-region ap-southeast-1 --state-dynamodb-table tf-locks
# tear down (reverse order)
python3 stack2tf.py destroy --chdir ../my-stack --deployment prod
Tip: start with a single component via
--target <name>on a non-prod deployment before applying the whole stack.
CLI reference
python3 stack2tf.py <command> [options]
| Command | Description |
|---|---|
list |
Discover deployments + components; print the DAG run order |
show |
Print resolved per-component provider roles and deployment inputs |
validate |
init -backend=false + validate for every component (offline) |
plan |
Whole-stack plan in DAG order; unified report + stack-plan.json |
apply |
Provision the whole deployment in dependency order |
destroy |
Destroy in reverse dependency order |
| Option | Description |
|---|---|
--chdir DIR |
Stack directory (default: cwd) |
--deployment NAME |
Deployment name/file (default: all discovered) |
--tf terraform|tofu |
Binary to drive (default: terraform) |
--target NAME |
Operate on a single component instance |
--parallelism N |
Max components to run concurrently within a DAG level (default 1) |
--dry-run |
Generate modules + print run order, without invoking Terraform |
--identity-token-file PATH |
OIDC JWT file (else $AWS_WEB_IDENTITY_TOKEN[_FILE]) |
--state-bucket NAME |
Enable S3 remote state (per-component key) |
--state-region REGION |
Region of the S3 state bucket |
--state-dynamodb-table T |
DynamoDB table for state locking |
--state-key-prefix P |
S3 state key prefix (default stack2tf) |
--mocks FILE |
JSON mock outputs {component: {output: value}} for a first-time plan |
--upstream name=FILE |
Consume another deployment's published_outputs.json (repeatable) |
Concepts — Stacks feature mapping
| Terraform Stacks | stack2tf |
|---|---|
component "x" { source, inputs } |
a per-component Terraform root module calling that source |
depends_on / component.x.out |
DAG edge; component.x.out → var.dep_x.out, injected from x's outputs |
for_each component |
expanded to one module per instance (component.x["key"]) |
aggregate [for k,v in component.x : …] |
var.dep_x = map {key → outputs} (indexed + aggregate handled uniformly) |
provider "aws" "this" |
assume_role_with_web_identity → the deployment role |
provider "aws" "spoke" { for_each } |
assume_role_with_web_identity → each spoke account role |
deployment "<env>" { inputs } |
generated deploy.tf locals, evaluated by Terraform (local._deploy.*) |
stack locals |
ported to locals.tf (native TF functions) |
identity_token.aws.jwt |
var.identity_token (from --identity-token-file / env) |
| per-component state | S3 backend with per-component key (--state-bucket) or local |
publish_output / upstream_input |
published_outputs.json + --upstream name=path (upstream_input.name.* → local._upstream.name.*) |
Authentication
Providers use Terraform-native assume_role_with_web_identity, matching Stacks:
provider.aws.thiscomponents assume the deployment role.provider.aws.spoke[...]components assume the resolved spoke account role.
The OIDC JWT is passed as var.identity_token via TF_VAR_identity_token (kept
off disk). Provide it with --identity-token-file or AWS_WEB_IDENTITY_TOKEN /
AWS_WEB_IDENTITY_TOKEN_FILE.
State and locking
Each component has its own state (per component, like Terraform Stacks). By
default state is local; pass --state-bucket (with --state-region) to generate
an S3 backend per component, keyed
<prefix>/<deployment>/<component>/terraform.tfstate.
Locking uses exactly one mechanism — you don't need both S3 and DynamoDB:
| Flags | Locking |
|---|---|
--state-bucket (default) |
Native S3 lock file (use_lockfile = true) — S3 only, no DynamoDB. Requires Terraform ≥ 1.10 / OpenTofu ≥ 1.10. |
--state-bucket --state-dynamodb-table <t> |
DynamoDB lock (legacy) |
--state-bucket --state-no-lock |
No locking (not recommended) |
no --state-bucket |
Local backend (local file lock only) |
Locking is delegated to the Terraform backend and is per component (matching
Terraform Stacks' per-component state). There is no separate stack-wide lock;
--parallelism runs distinct components, each locking its own state.
validate always runs init -backend=false, so it works offline regardless.
Whole-stack planning
plan runs stack-wide in DAG order. For each component it captures a real plan
artifact and derives that component's outputs:
terraform plan -out=plan.bin # per component, own state
terraform show -json plan.bin # parse planned outputs + change summary
Known values are used directly; known after apply values become typed
placeholders. These feed dependents (var.dep_<component>), so downstream plans
use the upstream's actual planned outputs rather than static mocks. Results are
summarised into a single report plus machine-readable artifacts:
stack-plan.json— per-component and total add/change/destroy countspublished_outputs.json— each component's (planned or applied) outputs
For a brand-new stack you can seed values with --mocks <file>; anything
unresolved falls back to try(var.dep_x.y, null).
Cross-stack outputs
Every run writes published_outputs.json for the deployment. A downstream
deployment consumes it:
python3 stack2tf.py apply --chdir ../app-stack \
--upstream platform=../platform-stack/.stack2tf/prod/published_outputs.json
upstream_input.platform.* references are rewritten to local._upstream.platform.*.
CI/CD
stack2tf is a plain CLI, so it runs in any pipeline (GitHub Actions, CircleCI,
GitLab CI, …). validate, list, show, and plan --dry-run need no cloud
access and make great PR gates; apply needs remote state and credentials.
Two things to get right in CI:
- Use remote state for
apply. CI runners are ephemeral — pass--state-bucket/--state-region/--state-dynamodb-tableso state lives in S3. - Disable the Terraform wrapper. Set
terraform_wrapper: falseinsetup-terraform(or usesetup-opentofu); the default wrapper intercepts output and breaks the engine'sterraform show -jsonparsing. - Credentials. The generated providers use
assume_role_with_web_identity, and the target roles must trust the token you present. In CI either add your CI's OIDC provider to those roles' trust policies and pass the token via--identity-token-file, or supply base credentials (e.g. viaaws-actions/configure-aws-credentials).
GitHub Actions — PR check (no cloud access)
name: stack2tf
on: [pull_request]
jobs:
check:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- uses: actions/setup-python@v5
with: { python-version: "3.11" }
- run: pip install -r stack2tf/requirements.txt
- uses: hashicorp/setup-terraform@v3
with: { terraform_wrapper: false }
- name: tests + whole-stack plan (fixture)
run: |
python3 stack2tf/hclexpr.py
python3 stack2tf/stack2tf.py plan --chdir stack2tf/examples/local-stack
GitHub Actions — deploy on main (AWS OIDC)
deploy:
if: github.ref == 'refs/heads/main'
runs-on: ubuntu-latest
permissions: { id-token: write, contents: read }
steps:
- uses: actions/checkout@v4
- uses: actions/setup-python@v5
with: { python-version: "3.11" }
- run: pip install -r stack2tf/requirements.txt
- uses: hashicorp/setup-terraform@v3
with: { terraform_wrapper: false }
- uses: aws-actions/configure-aws-credentials@v4
with:
role-to-assume: arn:aws:iam::<acct>:role/ci-bootstrap
aws-region: ap-southeast-1
- run: |
python3 stack2tf/stack2tf.py apply --chdir my-stack --deployment prod \
--state-bucket my-tf-state --state-region ap-southeast-1 \
--state-dynamodb-table tf-locks --parallelism 4
Reusable GitHub Action
This repo ships a composite action (action.yml) so consumers integrate with a
single uses: step — it installs stack2tf, sets up Terraform/OpenTofu (with the
wrapper disabled), and runs the command:
- uses: actions/checkout@v4 # checkout the repo that holds your stack
- uses: quangnhut123/stack2tf@v0.2.0
with:
command: plan # list | show | validate | plan | apply | destroy
chdir: my-stack
deployment: prod
tf: terraform # or: tofu
args: "--state-bucket my-tf-state --state-region ap-southeast-1 --parallelism 4"
A full, ready-to-copy workflow (PR plan gate + apply on main with AWS OIDC) is
in examples/github-actions.yml.
Inputs: command, chdir (required), deployment, tf, args,
python-version, terraform-version, tofu-version. For real apply, add an
aws-actions/configure-aws-credentials step (and permissions: id-token: write)
before it.
CircleCI
The ready-to-use example examples/circleci-config.yml
needs no orb — it installs stack2tf from PyPI and runs it, so it works
immediately:
version: 2.1
jobs:
plan:
docker: [{ image: cimg/python:3.11 }]
steps:
- checkout
- run: |
pip install stack2tf
curl -fsSL https://releases.hashicorp.com/terraform/1.15.8/terraform_1.15.8_linux_amd64.zip -o /tmp/tf.zip
unzip -o /tmp/tf.zip -d /tmp && sudo mv /tmp/terraform /usr/local/bin/
- run: stack2tf plan --chdir examples/local-stack
workflows:
infra: { jobs: [plan] }
Optional: publish the CircleCI orb
For a DRY stack2tf/stack2tf job, a publishable orb lives in circleci/orb.yml.
It is not on the CircleCI registry yet — you publish it once to your own
namespace (requires a CircleCI account + API token: circleci setup):
circleci namespace create <namespace> --org-id <your-org-id> # once, if needed
circleci orb create <namespace>/stack2tf
circleci orb publish circleci/orb.yml <namespace>/stack2tf@1.0.0
Then use the orb form shown at the bottom of examples/circleci-config.yml.
The stack-plan.json written by plan is a convenient CI artifact — upload it
or surface its add/change/destroy totals on the pull request.
Comparison: stack2tf vs Terragrunt vs Terraform Stacks
All three orchestrate multiple Terraform/OpenTofu components; they differ in how they run and what they require.
Comparison basis: Terragrunt 1.x (the
run --allline, incl. the nativeterragrunt.stack.hclfeature; latest referenced v1.1.2) and HCP Terraform Stacks as of 2026. Based on official docs, not a pinned test run.
| Capability | stack2tf | Terragrunt | Terraform Stacks (HCP) |
|---|---|---|---|
| Component DAG + ordering | ✅ | ✅ (run --all) |
✅ |
| Output wiring between components | ✅ (var.dep_*) |
✅ (dependency blocks) |
✅ |
for_each / per-account providers |
✅ (expanded) | ✅ (units + generate) | ✅ |
| Deployment inputs (any HCL function) | ✅ | ✅ | ✅ |
| Per-component state | ✅ | ✅ | ✅ |
| Concurrent execution | ✅ (--parallelism) |
✅ (run queue) | ✅ |
| Unified whole-stack plan report | ✅ (aggregated from plan JSON) | ⚠️ per-unit output | ✅ |
| Cross-component deferred/unknown planning | ⚠️ placeholder approximation | ⚠️ static mock_outputs |
✅ (hosted engine) |
Reads Stacks files (*.tfcomponent/tfdeploy.hcl) directly |
✅ | ❌ (own terragrunt.hcl) |
✅ (native) |
| Extra tooling / binary required | ❌ (Python + terraform/tofu) |
✅ (the terragrunt binary) |
hosted service |
| Runs on open-source CLI, self-hosted | ✅ | ✅ | ❌ (hosted) |
How to read this: stack2tf runs your existing Stacks configuration as-is on
the plain CLI with no additional tool. Terragrunt is a general-purpose
orchestrator that uses its own configuration language and binary. Terraform
Stacks is the hosted product. On cross-component deferred planning, both
stack2tf and Terragrunt approximate (derived placeholders / static
mock_outputs) — only HCP's hosted engine does it natively (see below).
The one capability that cannot be fully reproduced on the open-source CLI is HCP's cross-component deferred planning (treating a not-yet-created upstream output as unknown during a whole-stack plan). The CLI plans one root module at a time with concrete inputs and has no way to accept a genuinely-unknown cross-root value. stack2tf approximates it with derived placeholders; the exact behavior is gated on a CLI feature (OpenTofu issue #812, unshipped). See Limitations.
Limitations
- Deferred planning. Downstream values are derived from each upstream's real
planned outputs (known values +
known after applyplaceholders). A resource keyed on a genuinely-unknown upstream output (count/for_each) is therefore not truly deferred. This is isolated to a singleUNKNOWNsentinel instackrun.py; if the CLI gains unknown plan inputs (OpenTofu #812), swap it for a real unknown and planning becomes truly deferred with no other change. - Deployment-input evaluation covers literals plus any expression Terraform
itself can evaluate in the generated
deploy.tflocals. publish_output/upstream_inputwiring is implemented but dormant unless those blocks are enabled in the source stack.
Project layout
stack2tf.py CLI (list / show / validate / plan / apply / destroy)
stackrun.py engine: DAG, module generation, runner, plan aggregation
stackparse.py stack-file parsing, for_each expansion, each.* substitution
hclexpr.py HCL expression reconstruction + reference rewriting (self-tests)
pyproject.toml packaging (pip install -> `stack2tf` command)
requirements.txt Python dependencies (python-hcl2)
CHANGELOG.md Keep a Changelog / SemVer history
action.yml reusable GitHub composite Action
.github/workflows/ ci.yml (fixture check) + release.yml (publish to PyPI)
circleci/orb.yml publishable CircleCI orb
examples/local-stack/ no-AWS, 2-component fixture for trying `plan`
examples/github-actions.yml consumer example workflow (uses the Action)
examples/circleci-config.yml consumer example for the orb
Run the built-in self-tests for the expression translator:
python3 hclexpr.py # -> ALL PASS
Contributing
Issues and pull requests are welcome — see CONTRIBUTING.md for setup and guidelines. In short:
- Keep changes focused and covered by the
examples/local-stackfixture where possible. - Run
python3 hclexpr.pyandpython3 stack2tf.py plan --chdir examples/local-stackbefore submitting. - Describe behavior changes and any new flags in the PR.
Releasing
Releasing is fully automatic — you never edit a version by hand. The package
version is derived from git tags (via setuptools-scm), and
.github/workflows/release.yml runs on every push to main: it computes the
next version from the commits since the last tag, creates that tag, and publishes
to PyPI via Trusted Publishing (OIDC) —
nothing is committed back to main.
Bump rules (Conventional Commits; patch is the default, so every merge bumps):
| Commit contains | Bump |
|---|---|
feat: (or feat(scope):) |
minor |
BREAKING CHANGE or type!: |
major |
anything else (fix:, docs:, chore:, …) |
patch |
[skip release] in the merge commit |
no release |
So the workflow is simply: open a PR, get it approved, merge to main — a new
version is tagged, published to PyPI, and a GitHub Release is created
automatically. Use a feat:/! commit for a minor/major bump, or [skip release]
to skip.
One-time setup — add a trusted publisher on PyPI (Project → Settings → Publishing → Add a pending publisher):
| Field | Value |
|---|---|
| Owner | your GitHub org/user |
| Repository | your repo name |
| Workflow name | release.yml |
| Environment | pypi |
License
Released under the MIT License. Update the copyright holder in
LICENSE (currently "stack2tf contributors") to your name or organization.
stack2tf is an independent project and is not affiliated with HashiCorp or the OpenTofu project. "Terraform" and "HCP Terraform" are trademarks of HashiCorp; "OpenTofu" is a trademark of the OpenTofu project.
Download files
Download the file for your platform. If you're not sure which to choose, learn more about installing packages.
Source Distribution
Built Distribution
Filter files by name, interpreter, ABI, and platform.
If you're not sure about the file name format, learn more about wheel file names.
Copy a direct link to the current filters
File details
Details for the file stack2tf-0.3.0.tar.gz.
File metadata
- Download URL: stack2tf-0.3.0.tar.gz
- Upload date:
- Size: 41.5 kB
- Tags: Source
- Uploaded using Trusted Publishing? Yes
- Uploaded via: twine/7.0.0 CPython/3.13.14
File hashes
| Algorithm | Hash digest | |
|---|---|---|
| SHA256 |
834ed3de79f5ccd6d0f291a4a5e335f99a509d50920b95d6b8fec80bee42dbd1
|
|
| MD5 |
7fad33a1ace974a933edab30a17021ba
|
|
| BLAKE2b-256 |
bd3d2c9fa5e89e6c4dc21b0268a6fb9de91d9b569154bb5eb1c9aa3bf873daec
|
Provenance
The following attestation bundles were made for stack2tf-0.3.0.tar.gz:
Publisher:
release.yml on quangnhut123/stack2tf
-
Statement:
-
Statement type:
https://in-toto.io/Statement/v1 -
Predicate type:
https://docs.pypi.org/attestations/publish/v1 -
Subject name:
stack2tf-0.3.0.tar.gz -
Subject digest:
834ed3de79f5ccd6d0f291a4a5e335f99a509d50920b95d6b8fec80bee42dbd1 - Sigstore transparency entry: 2339496701
- Sigstore integration time:
-
Permalink:
quangnhut123/stack2tf@f1cba50873a7fd7d6f1643ee219ea3603e5d734f -
Branch / Tag:
refs/heads/main - Owner: https://github.com/quangnhut123
-
Access:
public
-
Token Issuer:
https://token.actions.githubusercontent.com -
Runner Environment:
github-hosted -
Publication workflow:
release.yml@f1cba50873a7fd7d6f1643ee219ea3603e5d734f -
Trigger Event:
push
-
Statement type:
File details
Details for the file stack2tf-0.3.0-py3-none-any.whl.
File metadata
- Download URL: stack2tf-0.3.0-py3-none-any.whl
- Upload date:
- Size: 26.6 kB
- Tags: Python 3
- Uploaded using Trusted Publishing? Yes
- Uploaded via: twine/7.0.0 CPython/3.13.14
File hashes
| Algorithm | Hash digest | |
|---|---|---|
| SHA256 |
46df9907a0c205aefb4163ebc9b4c0ae6409ceb95c1e13ef596301a31eda1bd3
|
|
| MD5 |
5a44040766649e18d682cef8cef78623
|
|
| BLAKE2b-256 |
c21c593147d5f180bb25e84cbaaa887157423399c688415cfcb47f5f1b782b86
|
Provenance
The following attestation bundles were made for stack2tf-0.3.0-py3-none-any.whl:
Publisher:
release.yml on quangnhut123/stack2tf
-
Statement:
-
Statement type:
https://in-toto.io/Statement/v1 -
Predicate type:
https://docs.pypi.org/attestations/publish/v1 -
Subject name:
stack2tf-0.3.0-py3-none-any.whl -
Subject digest:
46df9907a0c205aefb4163ebc9b4c0ae6409ceb95c1e13ef596301a31eda1bd3 - Sigstore transparency entry: 2339496705
- Sigstore integration time:
-
Permalink:
quangnhut123/stack2tf@f1cba50873a7fd7d6f1643ee219ea3603e5d734f -
Branch / Tag:
refs/heads/main - Owner: https://github.com/quangnhut123
-
Access:
public
-
Token Issuer:
https://token.actions.githubusercontent.com -
Runner Environment:
github-hosted -
Publication workflow:
release.yml@f1cba50873a7fd7d6f1643ee219ea3603e5d734f -
Trigger Event:
push
-
Statement type: