Stop Copy and Pasting Terraform CI with Reusable GitHub Actions Workflows
Collapsing two copied and pasted Terraform pipelines into one versioned composite action and two reusable GitHub Actions workflows, plus the relative-path gotcha that almost shipped broken.
Copying and pasting a CI pipeline from one repo to another is always the fast option, and it is always a mistake you make on purpose, telling yourself you will come back and fix it properly later. I did that twice, once for DNS and once for Authentik, each with its own terraform.yml, drift.yml, and a composite action called tf-prepare doing the same Vault-then-AWS-then-terraform init dance. Later never came, right up until I put the two files side by side and realised they were close enough to diff cleanly, with the same job names, the same steps, the same emoji, and the same comments. I had copied and pasted CI, and I had done it twice.
That is fine right up until you fix a bug in one copy and forget the other, which is close to what happened here. This post is about moving that pipeline into one shared, versioned home so every Terraform-backed repo gets it for free instead of copying and pasting it again, and a gotcha in GitHub’s reusable workflow model that broke the first version before it ever ran for real.
The Goal
- One composite action for the Vault → AWS →
terraform initsetup, callable from any repo - One reusable workflow for validate/plan/apply, one for scheduled drift detection
- Every consuming repo’s own workflow file shrinks to inputs, not implementation
- Versioned properly, so a bad change to the shared pipeline cannot silently break every repo’s CI mid-apply
- Proven working with a real CI run before anything gets trusted, not just eyeballed
Composite Action vs Reusable Workflow
Two terms show up a lot below, so it is worth defining them plainly before diving in. A composite action is a small, reusable set of steps that any workflow can call as if it were a single step, the kind of setup work every job needs but nobody wants to write out by hand each time. A reusable workflow is a whole pipeline, jobs, permissions, and all, that another repo can call and configure with its own inputs, much like calling a function with different arguments instead of copying that function everywhere it gets used. The composite action is one ingredient. The reusable workflow is the recipe it belongs to.
Part 1 - Two Repos, One Pipeline, Copied and Pasted
Diffing the two repos made the problem concrete.
authentikhadterraform.yml(128 lines),drift.yml(111 lines), andtf-prepare/action.yml(54 lines), 293 lines totaldnshadterraform.yaml(129 lines),drift.yaml(112 lines), andtf-prepare/action.yaml(65 lines), 306 lines total
dns differed mainly by a Python step that renders dns.yaml into a tfvars.json before terraform init runs. Everything else was identical enough to diff cleanly, from the Vault import and the AWS role assumption to the provider cache, the fmt/validate/tflint gates, the PR plan comment that updates in place instead of spamming the thread, and the drift job’s -detailed-exitcode dance.
Nearly 600 lines, in six files, across two repos, doing the same thing. And I had a reusable-workflows repo sitting there already, quietly linting YAML and Terraform for other repos over workflow_call, that had never once hosted the one pipeline that actually mattered.
Part 2 - A Composite Action That Lives Outside the Repo It Runs In
The setup work became terraform-prepare, a composite action that takes the bits that actually differ between repos as inputs, things like which Vault role to authenticate as, which secrets to pull, which AWS role to assume, and an optional pre-init hook for repos like DNS that need to render something before Terraform ever sees it.
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
name: Terraform Prepare
description: >-
Fetch secrets from Vault, optionally run a pre-init command, configure AWS
for the state backend, install Terraform, and run terraform init with
provider caching.
inputs:
vault-role:
required: true
vault-secrets:
required: true
aws-role-arn:
required: true
aws-region:
default: ap-southeast-2
working-directory:
default: terraform
pre-init-command:
default: ''
runs:
using: composite
steps:
- name: 🔐 Import secrets from Vault
uses: hashicorp/vault-action@v4
with:
url: https://vault.yourdomain.com:8200
method: jwt
role: $
secrets: $
- name: 🪄 Pre-init hook
if: inputs.pre-init-command != ''
shell: bash
run: $
- name: 🔐 Configure AWS credentials
uses: aws-actions/configure-aws-credentials@v6
with:
role-to-assume: $
aws-region: $
- name: 🧰 Setup Terraform
uses: hashicorp/setup-terraform@v4
with:
terraform_wrapper: false
- name: 🚀 Terraform Init
shell: bash
run: |
export TF_PLUGIN_CACHE_DIR="$HOME/.terraform.d/plugin-cache"
terraform init -input=false -reconfigure
working-directory: $
There is nothing in there that only makes sense for Authentik, and nothing that only makes sense for DNS. The vault-secrets value is a Vault path and variable name, never a literal secret, so it is safe to pass around as a plain string input with no secrets: plumbing between repos.
Part 3 - Reusable Workflows for the Whole Pipeline
The composite action only covers setup. The actual job shape (validate, plan on PRs, apply on merge, plus a separate scheduled drift check) became two workflow_call reusable workflows, terraform-ci.yaml and terraform-drift.yaml, living in the same repo as the action.
Every consuming repo’s workflow file collapses to inputs. Authentik’s terraform.yml went from 128 lines to this.
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
name: Terraform - Authentik
on:
push:
branches: [main]
pull_request:
branches: [main]
workflow_dispatch:
concurrency:
group: terraform-authentik
cancel-in-progress: false
jobs:
terraform:
permissions:
id-token: write
contents: read
pull-requests: write
uses: yourorg/reusable-workflows/.github/workflows/terraform-ci.yaml@v1
with:
working-directory: terraform
tfvars-file: environment/production.tfvars
vault-role: authentik
vault-secrets: |
secret/data/authentik/config authentik_api_token | TF_VAR_authentik_api_token ;
secret/data/authentik/app_secrets value | TF_VAR_app_secrets
aws-role-arn: arn:aws:iam::123456789012:role/TerraformStateAccess
plan-comment-title: '### 🔐 Terraform Authentik Plan'
DNS reads almost the same, plus two extra inputs to keep its dns.yaml render step alive through the shared pre-init-command hook.
1
2
3
4
5
6
7
8
9
10
with:
working-directory: terraform
tfvars-file: environment/production.auto.tfvars.json
vault-role: dns
vault-secrets: |
secret/data/dns cf_api_token | TF_VAR_cloudflare_api_token
aws-role-arn: arn:aws:iam::123456789012:role/TerraformStateAccess
python-version: '3.14'
pre-init-command: pip install pyyaml && python3 scripts/yaml_to_tfvars.py
plan-comment-title: '### 📘 Terraform Plan'
Every repo-specific quirk stayed exactly where it belonged, in the repo that has the quirk, instead of forking the whole pipeline to accommodate one Python step.
Part 4 - Relative Paths Do Not Cross Repos
The first version shipped with the composite action referenced like this, from inside the reusable workflow that lives in the same repo.
1
2
- name: 🛠️ Prepare Terraform
uses: ./.github/actions/terraform-prepare
That felt obviously correct. Same repo, relative path, done. It tagged clean, Authentik’s PR picked it up, and the very first real run failed instantly.
1
2
3
##[error]Can't find 'action.yml', 'action.yaml' or 'Dockerfile' under
'.../authentik/.github/actions/terraform-prepare'. Did you forget to run
actions/checkout before running your local action?
The reusable workflow’s own first step is actions/checkout, and by default that checks out the caller’s repository (Authentik, not reusable-workflows) because that is the repo actually holding the Terraform files that need validating. A relative-path action reference resolves against whatever the job’s workspace currently contains, which by that point was Authentik’s tree with no terraform-prepare action anywhere in it. GitHub does not automatically fetch the reusable workflow’s own repo into the workspace alongside it; it just runs the workflow’s YAML with the caller’s checkout sitting there instead.
The fix is to stop pretending the action is local at all and reference it the same way you would reference any other repo’s action.
1
2
- name: 🛠️ Prepare Terraform
uses: "yourorg/reusable-workflows/.github/actions/terraform-prepare@v1"
It looks obvious now, and it was a fifteen-minute fix once the actual failure was in front of me instead of assumed away. The part worth remembering is that this only breaks the moment a second repo actually calls the workflow. A same-repo test, or eyeballing the YAML, would never have caught it.
Part 5 - Versioning It Like a Dependency
The broken version had already been tagged v1 and merged before the fix landed, so v1 briefly meant “does not work for anyone.” Rather than pretend that never happened, the fix went out as v1.1, and v1 got force-moved to point at the same commit as v1.1, the same floating-major-tag convention actions/checkout@v4 uses, where @v1 always resolves to the newest working v1.x and a future breaking change gets its own v2.
1
2
git tag -f v1 v1.1^{}
git push origin v1 --force
Consumers pin @v1 and get fixes automatically without touching their own workflow files; anyone who wants an exact, never-moving reference can still pin @v1.1 directly. Moving a published tag is the kind of git operation I would normally avoid, but for a tag that had never had a single working consumer, it was the right call over letting a permanently broken v1 sit there as a trap for later. Let’s be honest, I would have been the one to learn that the hard way otherwise.
The Result
Authentik’s and DNS’s own workflow files now run about 30 lines each, with no local composite action, referencing one shared pipeline that lives in exactly one place. Adding the next Terraform-backed repo to the pile (and there is at least one more coming) means writing a ~30-line caller with the right Vault role and AWS ARN, not copying and pasting 293 lines and hoping I remember to keep both copies in sync.
vault-config, the repo that manages every other repo’s Vault policies and JWT roles, deliberately stayed out of this. Its CI runs on a vault-admin-scoped token, and its bootstrap avoids the same deadlock covered in my Vault post. Destroy the JWT backend by accident and the workflow that would normally fix it can no longer authenticate to do the job. Putting a repo like that on the same generic pipeline as everything else felt like the wrong kind of consistency, so it kept its own workflow file. It still got renovate, matching action versions, and its own local fmt/validate/tflint gates, just cleaned up on its own instead of folded into the shared pipeline.
The actual proof this worked was not the diff. It was watching real CI go green end to end.
terraform applyran and succeeded on Authentik’smainafter merge- A manually triggered drift run passed on both repos, exercising the issue create and close logic that normally only fires on a 6am schedule
- Every plan comment on every PR reported no changes, meaning the migration itself never touched a single live resource
That last point is the whole point of doing this as a refactor of the pipeline instead of the infrastructure. The destination looked exactly like the source, just written once.