tfdo
The outer layer Terraform and OpenTofu are missing.
tfdo replaces the shell scripts, retry hacks, and copy-pasted CI workflows that every Terraform repo accumulates with a single CLI that handles init reliability, plan readability, run-directory orchestration, and CI scaffolding out of the box.
Project status
tfdo is in early development. Interfaces and behavior can change without notice. Plan rendering, apply, and rendering across multiple run directories (multi run-dir) are work in progress (WIP), along with other gaps the docs may not call out yet. Verify behavior on your stacks before you rely on it in production pipelines.
Full reference docs: espenalbert.github.io/tfdo.
Why tfdo
Pick any Terraform repo that's grown past one run directory. You'll find the same patterns being rewritten by hand:
- Flaky
init: Registry timeouts and plugin download races fail CI. "Just re-run" becomes the team policy. - 200-line plan dumps: Three resources change, buried in 247 lines of refresh output and
(known after apply). Nobody reads it. - Shell-script CI: A 180-line
run-terraform.shper repo, copy-pasted between teams, nobody wants to touch. - Blind provider upgrades: Bump
v5.xtov6.x, runplan, discover 12 breakages. - The 50-module PR: One-line change to a shared variable triggers
planin every run directory. 22-minute CI feedback.
tfdo ships solutions for the entire list as commands. None of them require restructuring your repo. cd into any run directory and terraform plan still works.
What tfdo owns
- Invocation reliability: Retry on transient
initerrors, sharedTF_PLUGIN_CACHE_DIRacross repos,--init-mode auto|always|never. - Run-directory orchestration: Multi-directory plan/apply with selectors (
--env,--team,--app,--tags), parallel waves, dependency DAG, and git-diff-based--changed. - Quality checks:
tfdo check --fixrunsfmt + validate + tflintruff-style and rewrites in place. - Schema intelligence:
tfdo schema diffandtfdo inspect resource-usagewalk cached provider schemas to flag breaking changes and unused attributes beforeplan. - CI scaffolding:
tfdo sync github --oidcprovisions the workflows, env secrets, AWS OIDC provider, and per-env IAM roles end to end. - Repo bootstrap:
tfdo boottakes an empty folder to a workingtfdo.yaml, backend, and module cache.
Terraform and OpenTofu still own everything they always did: graph execution, apply semantics, state, locking, and the provider protocol. tfdo wraps the binary; it never forks it.
Install
tfdo requires Python 3.13+ and a terraform (or tofu) binary on PATH.
uv tool install git+https://github.com/EspenAlbert/tfdo.git
# or, with pipx
pipx install git+https://github.com/EspenAlbert/tfdo.git
Use a specific binary or version (via mise):
tfdo --binary tofu plan
tfdo --tf-version 1.9.0 plan # rewrites to `mise x terraform@1.9.0 -- terraform`
Quickstart
From an empty directory to a CI-wired repo:
tfdo boot # backend + providers + tfdo.yaml + module cache
tfdo new run-dir # questionnaire-driven stack (e.g. envs/dev/project)
tfdo check --fix # fmt + validate + tflint across all run-dirs
tfdo sync justfile # repo-level just targets per env and run-dir
tfdo sync github --oidc # workflows, env secrets, IAM roles
After this you have:
tfdo.yamlat the repo root, env layer, and run-dir layer.envs/{env}/{run_dir}/with backend, providers, and module calls.- A
justfilewhose targets match the discovered tree. - GitHub Actions workflows per env, with OIDC trust roles so Actions reaches state without long-lived AWS keys.
Concepts
tfdo uses vocabulary that applies to any Terraform orchestrator, not just this one. The short version:
- Run directory: Any directory containing a
backend {}block. One backend equals one plan/apply scope. The atomic unittfdooperates on. - Module source: Reusable
.tfcode with no backend. Consumed by run directories viamodule {}blocks, never planned directly. - Lifecycle:
init,plan,apply,destroy.tfdoruns them as a sequence with auto-init and retry. - Selectors: Named dimensions (
env,team,app) plus free-form--tags key=value. Multiple--tagsflags AND, comma-separated values OR within a key. - Dependencies: A run directory lists its parents in
tfdo.yaml.tfdobuilds a DAG and runs in order, passing outputs through var-files. - Change detection:
tfdo run plan --changedusesgit diffto plan only the run directories affected by the current branch. - Config resolution: CLI flag, then env var (
TFDO_*), then nearesttfdo.yaml, then ancestortfdo.yamls, then user config, then default.tfdo infoprints what won.
Daily commands
Single run directory (cd into it, or pass --work-dir):
tfdo init: Retries on transient registry and network errors. InjectsTF_PLUGIN_CACHE_DIR.tfdo plan [-f vars.tfvars] [--json -o plan.json]: Wrapsterraform planwith var-file and JSON helpers.tfdo apply [--auto-approve]: Standard apply. With--init-mode auto, runsinitfirst when terraform reports an init-required error.tfdo destroy [--auto-approve]: Standard destroy.tfdo check [--fix] [--tflint]: ruff-stylefmt + validate(plus optional tflint).--fixrewrites files.tfdo info: Prints resolved settings, paths, and user config.
Across many run directories (tfdo run group):
tfdo run plan --env dev: Plan every run directory underenvs/dev/.tfdo run apply --tags team=infra --parallel 5: Tag-filtered apply, up to 5 concurrent.tfdo run plan --changed: Only run directories touched bygit diffvsHEAD.tfdo run plan --dry-run: Print the wave plan without running terraform.tfdo run apply --on-failure continue: Keep going past a failed run directory.
Other groups:
tfdo config init|show: Generate or print resolvedtfdo.yamllayers.tfdo new run-dir: Scaffold new stacks against modules selected duringboot.tfdo copy env: Copy a known-good env (e.g.dev) into a new env (e.g.prod).tfdo schema show|diff: Fetch provider schemas; diff between two versions or the localdevplugin.tfdo inspect resource-usage|hcl-paths|api-coverage: Walk HCL against provider schemas for coverage and gap reports.tfdo sync justfile|github: Regenerate repo glue when run directories change.
Configuration
tfdo.yaml stacks from the git root down to each run directory. Layers higher in the tree provide defaults; lower layers override.
# repo-root tfdo.yaml
backend:
type: s3
bucket: my-tf-state
key: "envs/{env}/{run_dir}/terraform.tfstate"
region: eu-west-1
dynamodb_table: my-tf-lock
run_dir_discovery: "envs/{env}/{run_dir}"
tags:
managed_by: tfdo
tags_inject: aws # rewrite aws_* resources to carry these tags
providers:
- name: mongodbatlas
constraint: ">= 1.20.0"
ci:
repo_org: EspenAlbert
repo_name: my-atlas-infra
oidc: true
Key concepts:
- Discovery pattern:
run_dir_discoveryis a path with named selectors. The first selector must be{env}. Selectors auto-populate CLI filters (--env,--app,--team). - Backend:
s3orlocal, defined once at the root and rendered into each run directory'sbackend "s3" {}block. - Layered overrides: An env-level
tfdo.yamlcan overridebinary,tf_version,tags, or pin different provider versions per env. - Var-file resolution:
var_filesandenv_var_filesresolve relative to each layer, so dev and prod can sharecommon.tfvarswhile overriding specific knobs. - Hooks:
hook_configsruns a shell command or Python entry-point on lifecycle events (pre_init,pre_plan,post_apply, ...). Input and output flow through env vars and JSON files. - Dependencies:
dependencies: [{ref: ../project}]pulls outputs from another run directory as.dep.tfvars.jsonso ordering is explicit.
tfdo config show prints the resolved layers for the current run directory.
How it differs from Terragrunt, Terramate, and Atmos
- vs Terragrunt: Same wrapper idea, flat YAML instead of HCL include chains. No
.terragrunt-cachetemp dirs; tfdo runs in place. Auto-init is explicit through--init-mode, not magic. - vs Terramate: Borrows the change-detection idea via
git diff, stays closer to the terraform CLI.tfdo run --changed planinstead ofterramate run -- terraform plan. No.tm.hclfiles, no stack UUIDs. - vs Atmos: YAML config too, no component/stack abstraction. Run directories map directly to root module directories. No mandatory Spacelift integration.
- Unique territory: Schema diffing, resource-usage analysis, and provider breaking-change detection. None of the orchestrators above inspect provider schemas.
Design principles
- Wrap, never fork:
tfdocallsterraformortofu(--binary,TFDO_BINARY). Any run directory still works with plainterraform plan. - One effective environment per invocation: A single run uses one coherent set of env vars. Multi-step orchestration spawns separate invocations when credentials differ.
- Committed Terraform preferred: Generated
.tflives in git, reviewable in PRs. The--debugflag (andtfdo config show) surfaces what was generated. - Config simplicity over power:
tfdo.yamlis flat YAML with pydantic validation. No custom functions, no include chains, no expression evaluation at config-load time.
Environment variables
All env vars use the TFDO_ prefix and override CLI flags only when the flag is not passed.
TFDO_BINARY: Terraform binary name or path. Defaultterraform.TFDO_TF_VERSION: When set, binary becomesmise x terraform@{version} -- {binary}.TFDO_WORK_DIR: Working directory for terraform commands. Defaultcwd.TFDO_INTERACTIVE:auto(detect TTY),always, ornever. Withnever, prompting commands require--auto-approve.TFDO_INIT_MODE:auto(init on init-error),always, ornever. Defaultauto.TFDO_TFLINT: Run tflint alongsidecheck.TFDO_VERBOSE_SHELL: Log every successful shell completion (default is errors only).TFDO_BACKENDS_DIRS,TFDO_ENV_VARS_DIRS,TFDO_PROVIDER_HINTS_PATH: Override the static directories shipped withtfdo.CACHE_DIR: Override the per-user cache base; bypasses platformdirs. See caching.
tfdo info prints the resolved values for the current shell.
Development
From the repo root:
just pre-commit # fmt + fix + lint
just pre-push # lint + fmt-check + test + vulture
just test # tests only
just docs-serve # local mkdocs preview
Internal architecture lives in CLAUDE.md. Public API and CLI reference live under docs/ and ship via mkdocs-material.
License
MIT.