The terraform-docs GitHub Action generates a Markdown table of every input, output, and variable in a Terraform module, then commits it straight into your README.md on every pull request. This assumes a working Terraform CLI setup already — see terraform CLI commands if you need that groundwork first. The official docs page covers one example and stops there — no full input list, no config-file setup, and nothing about the one failure mode that catches almost everyone the first time they wire this into a real repo.
This covers what the official page skips. That means every input the action actually supports, the difference between fail-on-diff and auto-commit mode, how to document more than one module at once, and why the auto-commit step silently fails on pull requests from a fork.
What the terraform-docs GitHub Action Actually Sets Up
The action wraps the terraform-docs CLI and runs it inside your workflow instead of on a developer’s laptop. On a pull_request trigger, it walks your working-dir, generates the docs, and either prints them, replaces the target file, or injects them between two HTML comment markers in your existing README.md.
That third mode — output-method: inject — is the default, and it’s the one worth understanding first. It looks for <!-- BEGIN_TF_DOCS --> and <!-- END_TF_DOCS --> markers in the file. When they’re there, the generated table replaces everything between them and leaves the rest of your README untouched. Without those markers yet, the action just appends the generated block to the end instead. And if the file doesn’t exist at all, it creates one using the template input, which must itself contain both markers or the next run has nowhere to inject into.
Every terraform-docs GitHub Action Input, Not Just the One in the Docs
The official page’s example sets four inputs and calls it done: working-dir, output-file, output-method, git-push. The action’s own README lists 18 total. Here’s the full set, pulled directly from the repository:
| Input | Default | What it does |
|---|---|---|
working-dir |
. |
Comma-separated list of directories to generate docs for |
output-file |
README.md |
File in the module directory where docs get written |
output-format |
markdown table |
terraform-docs output format (ignored if config-file is set) |
output-method |
inject |
print, replace, or inject |
config-file |
disabled |
Name of a terraform-docs config file to use instead of individual inputs |
atlantis-file |
disabled |
Parse an Atlantis config to find module directories automatically |
find-dir |
disabled |
Run a find under this directory to discover .tf files |
recursive |
false |
Update submodules recursively instead of just working-dir |
recursive-path |
modules |
Submodule path to walk when recursive is true |
fail-on-diff |
false |
Fail the job if generated docs differ from what’s committed (ignored if git-push is set) |
git-push |
false |
Commit and push the generated docs back to the branch |
git-commit-message |
terraform-docs: automated action |
Commit message for the auto-push |
git-push-user-name / git-push-user-email |
empty | Defaults to the github-actions[bot] identity if left blank |
git-push-sign-off |
false |
Add a Signed-off-by trailer to the commit |
template |
HTML comment markers | Used only when output-file doesn’t already exist |
indention |
2 |
Markdown heading indent level, 1 through 5 |
args |
"" |
Extra flags passed straight through to the terraform-docs binary |
Two of these change behavior in ways that aren’t obvious from the names. fail-on-diff is explicitly ignored the moment git-push is set to true — you get one mode or the other, not both, on the same run. And output-format does nothing once you set config-file, since the config file’s own formatter key takes over.
Getting Auto-Commit Working: The checkout Step Most Setups Get Wrong
The one line in the official example that quietly does the most work is this:
- uses: actions/checkout@v3
with:
ref: ${{ github.event.pull_request.head.ref }}
Without that ref override, actions/checkout defaults to a detached-HEAD merge commit — a synthetic ref GitHub builds to preview what the merge would look like. You can commit to it, but the commit goes nowhere: it’s not the actual PR branch, so git-push has nothing real to push to. Set ref to github.event.pull_request.head.ref and checkout lands on the actual branch behind the PR, which is the one git-push: true needs to be sitting on.
name: Generate terraform docs
on:
- pull_request
jobs:
docs:
runs-on: ubuntu-latest
permissions:
contents: write
steps:
- uses: actions/checkout@v3
with:
ref: ${{ github.event.pull_request.head.ref }}
- uses: terraform-docs/gh-actions@v1.4.1
with:
working-dir: .
output-file: README.md
output-method: inject
git-push: "true"
That permissions: contents: write block isn’t in the action’s own examples, but you need it. Without an explicit permissions key, some organizations’ default GITHUB_TOKEN scope is read-only, and the push fails with a plain permission error before it ever reaches the fork-specific problem below.
Why terraform-docs GitHub Action Fails on Forked Pull Requests
Important: if your repo takes external contributions, this is the failure you’ll actually hit, and it has nothing to do with anything you configured wrong.
GitHub’s own workflow-syntax docs are explicit about it: when a workflow runs on a pull_request event “from a forked repository,” the GITHUB_TOKEN‘s “permissions are adjusted to change any write permissions to read only” — regardless of what you set in your permissions: block. The docs put it plainly: you “can use the permissions key to add and remove read permissions for forked repositories, but typically you can’t grant write access.”
That’s the entire gotcha. Your workflow can have contents: write sitting right there in the YAML. git-push can be set to true, and the checkout ref can point at the exact right branch. None of it matters, because a fork PR’s token is downgraded before your workflow even starts. The action runs, generates the docs correctly, then fails on the push step with a 403.
There are two real ways around it, and each trades away something:
- Switch to
fail-on-difffor external PRs and let a human (or a maintainer-triggered re-run) commit the update. You lose the auto-commit convenience, but the check still catches stale docs. - Use
pull_request_targetinstead ofpull_request. This event does grant a full read/write token even on fork PRs — but it runs in the context of your base branch, not the fork’s, which means a malicious PR can’t smuggle in a modified workflow file and get it to run with elevated permissions. Only do this if the job checks out the base repo’s own code, neverhead.reffrom an untrusted fork, for anything beyond runningterraform-docsitself.
For a private repo with no outside contributors, none of this applies. Every PR comes from a branch in the same repo, so the token keeps its full permissions, and the plain pull_request setup above just works.
fail-on-diff or Auto-Commit: Which Mode Should You Actually Use?
fail-on-diff turns documentation drift into a CI failure instead of a silent commit. The job runs, compares the generated output against what’s in README.md, and fails if they don’t match — nothing gets pushed. That’s the better default for a public or multi-contributor repo. It forces a human to look at the diff and commit it themselves, instead of trusting a bot commit that a reviewer might skim past.
Auto-commit (git-push: true) fits an internal repo better, one with a small, trusted team. The extra commit noise from a docs bot is a fair trade for never having a stale README again. Since the two options are mutually exclusive on the same job — fail-on-diff is ignored the instant git-push is true — decide per repo rather than trying to run both.
Documenting Every Module at Once: recursive, atlantis-file, and find-dir
Real Terraform repos rarely have one root module. If this is going into a repo that’s already running Terraform plan/apply through Actions, it’s worth checking that setup against the Git and GitHub Actions guide too, since branch protection and reusable-workflow patterns there affect how a docs job like this one should be scoped. The action itself has three ways to handle more than one directory, and they solve different layouts:
working-dir as a comma-separated list works when you already know every path: working-dir: .,modules/vpc,modules/eks. It’s explicit, but you have to update the workflow file every time you add a module.
recursive: true walks a recursive-path (default modules) and generates docs for every submodule it finds underneath, without listing them by name. This is the right default for a standard modules/ layout, since adding a new module directory doesn’t require touching the workflow at all.
atlantis-file parses an existing atlantis.yaml and reuses whatever project list Atlantis already has configured for plan/apply automation. If your repo already runs Atlantis, this keeps the module list defined in exactly one place instead of two.
find-dir runs a plain find for .tf files under a given path. It’s the least structured of the three — useful for a repo that doesn’t follow any particular module convention yet.
Using a Config File Instead of Piling On Inputs
Past four or five non-default inputs, a .terraform-docs.yml config file is easier to read and version than a wall of with: keys. terraform-docs looks for it in the module directory first, then a .config/ subfolder, then the current directory. The formatter key is the only required field:
formatter: "markdown table"
output:
file: README.md
mode: inject
template: |-
<!-- BEGIN_TF_DOCS -->
{{ .Content }}
<!-- END_TF_DOCS -->
sort:
enabled: true
by: name
settings:
indent: 2
required: true
sensitive: true
Set config-file: .terraform-docs.yml on the action once this exists, and output-format stops doing anything — the config file’s own formatter key wins. That’s worth knowing before you spend time debugging why changing output-format in the workflow has no effect.
Frequently Asked Questions
Q: Does the terraform-docs GitHub Action work with Terraform 1.x?
A: Yes. The action pins terraform-docs v0.20.0 as of the v1.4.1 release. The project documents that version as tested against Terraform 0.11 and 0.12, and it has kept working unchanged through the 1.x line, since the docs table only reads variable and output definitions, not provider-specific syntax.
Q: Can I run this on a push to main instead of on pull requests?
A: Yes — change the on: trigger to push: branches: [main] and set the checkout ref to the branch name (ref: main) instead of github.event.pull_request.head.ref, since there’s no pull request context to pull a head ref from on a push.
Q: Why did my output-file get created without the delimiter comments?
A: Only happens if your template input was overridden to something that doesn’t include <!-- BEGIN_TF_DOCS --> and <!-- END_TF_DOCS -->. The next run then has no markers to inject into and appends a second block instead of replacing the first — check the template value if you see duplicate doc blocks stacking up.
Q: Is fail-on-diff compatible with output-method: print?
A: No — fail-on-diff needs an existing output-file to diff against, so it only makes sense with replace or inject. With print, the action never writes a file at all, so there’s nothing to compare.
Q: Does this replace pre-commit hooks for terraform-docs?
A: Not entirely — a pre-commit hook catches stale docs before a commit even happens, locally. The GitHub Action is the backstop for contributors who skip the hook or don’t have it installed, so most teams run both rather than picking one.
Quick Summary:
– The official terraform-docs GitHub Action page shows one example; the action itself has 18 configurable inputs, listed in full above
– fail-on-diff and git-push are mutually exclusive on the same job — pick a CI-gate or auto-commit strategy per repo, not both
– Auto-commit needs ref: ${{ github.event.pull_request.head.ref }} on the checkout step or the push has no real branch to land on
– A pull_request from a forked repo always gets a read-only GITHUB_TOKEN, regardless of your permissions: block — auto-commit silently fails there; use fail-on-diff or a carefully-scoped pull_request_target instead
– recursive, atlantis-file, and find-dir handle multi-module repos three different ways — pick based on whether you already run Atlantis or just have a standard modules/ folder
If your Terraform CI already runs plan/apply through GitHub Actions, this slots in as one more step in the same workflow — see how the full pipeline fits together in deploying infrastructure to AWS with Terraform and GitHub Actions. For the tool itself outside of CI — installation, config file basics, and manual usage — the terraform-docs guide covers that ground.
Related guides
- Getting Started with GitHub Actions: Your First CI/CD Pipeline
- Automating Terraform Deployments with GitLab CI/CD: GCP
- Deploying AWS Resources with GitLab CI/CD and Terraform
- Deploying Azure Resources with GitLab CI/CD and Terraform
- Deploying Infrastructure to AWS with Terraform and GitHub Actions
- Deploying Infrastructure to Azure with Terraform and GitHub Actions