The terraform-docs GitHub Action: A Complete CI Setup Guide

terraform-docs github action workflow showing checkout, generate, and auto-commit steps

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-diff for 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_target instead of pull_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, never head.ref from an untrusted fork, for anything beyond running terraform-docs itself.

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