Kokil Thapa - Professional Web Developer in Nepal
Freelancer Web Developer in Nepal with 15+ Years of Experience

Kokil Thapa is an experienced full-stack web developer focused on building fast, secure, and scalable web applications. He helps businesses and individuals create SEO-friendly, user-focused digital platforms designed for long-term growth.

Terraform CI/CD with GitHub Actions

By Kokil Thapa | Last reviewed: September 2026

Your Terraform pull request pipeline is only as good as the checks that run before terraform plan. A common gap I see on real client projects is skipping lint entirely, or running TFLint locally but never standardising tflint install on Linux and machine-readable output format inside CI. This guide covers both: how to install the TFLint static binary on Ubuntu runners, which output formats GitHub Actions can parse in 2026, and how those checks fit into a full Terraform CI/CD with GitHub Actions workflow with OIDC auth and remote state.

How do you install TFLint on Linux with machine-readable output in 2026?

TFLint catches provider-specific misconfigurations that terraform validate misses. Examples include invalid AWS instance types, deprecated Azure resource arguments, and unused declarations. Before you wire it into GitHub Actions, get the Linux install and output format right on a bare Ubuntu runner.

The most reliable tflint install Linux static binary method for CI is a pinned release from the official GitHub repository. Do not rely on apt packages; they lag behind and break reproducibility across runner images.

# Pin a version — check github.com/terraform-linters/tflint/releases
TFLINT_VERSION="0.54.0"
curl -sSL "https://github.com/terraform-linters/tflint/releases/download/v${TFLINT_VERSION}/tflint_linux_amd64.zip" \
  -o tflint.zip
unzip -o tflint.zip
sudo install -m 755 tflint /usr/local/bin/tflint
tflint --version

For arm64 runners, swap the archive name to tflint_linux_arm64.zip. On GitHub-hosted ubuntu-latest images, amd64 is the default. I've used this pattern on shared EC2 deploy pipelines where the server has no package manager access beyond apt.

Official install script alternative

curl -s https://raw.githubusercontent.com/terraform-linters/tflint/master/install_linux.sh | bash
tflint --version

The script works well for ad-hoc servers. For production CI, prefer the explicit curl-and-unzip approach above. Pinning the version prevents surprise failures when a new TFLint release changes rule behaviour.

Machine-readable output formats

TFLint supports several machine-readable output format options. Pick one based on what your CI platform consumes.

Format flagBest forGitHub Actions integration
--format=defaultLocal debuggingHuman-readable logs only
--format=compactQuick terminal scansShort log lines, not structured
--format=jsonCustom parsers, dashboardsParse with jq or upload as artifact
--format=junitTest report UINative via publish-unit-test-result-action
--format=sarifSecurity tab, CodeQL-style reviewUpload with github/codeql-action/upload-sarif
--format=checkstyleJenkins, SonarQubeLegacy Java-oriented CI systems

Run lint and emit JSON in one step:

tflint --init
tflint --format=json --force > tflint-report.json
echo "Exit code: $?"

The --force flag returns exit code 0 even when issues are found. That lets you upload the report before failing the job. Without it, TFLint exits non-zero on warnings, which is fine if you want a hard gate immediately.

For SARIF — the best choice for GitHub's Security tab in 2026:

tflint --format=sarif > tflint.sarif

Parse JSON on the runner with a JSON formatter tool during local debugging. In CI, use jq to count severities and fail only on errors:

ERROR_COUNT=$(jq '[.issues[] | select(.rule.severity == "error")] | length' tflint-report.json)
if [ "$ERROR_COUNT" -gt 0 ]; then
  echo "TFLint found $ERROR_COUNT errors"
  exit 1
fi
TFLint on Linux in CIInstallStatic binaryPinned versionInit Rulestflint --init.tflint.hcl configLintScan .tf filesProvider pluginsOutputJSON / SARIFJUnit XMLGitHub Actions consumes machine-readable reportsSARIF to Security tab | JUnit to Checks | JSON to custom gatesFail PR before terraform plan touches cloud APIs
TFLint install on Linux with static binary and machine-readable output format wired into Terraform CI/CD pipelines

Minimal .tflint.hcl for AWS or Azure

plugin "terraform" {
  enabled = true
  preset  = "recommended"
}

plugin "aws" {
  enabled = true
  version = "0.30.0"
  source  = "github.com/terraform-linters/tflint-ruleset-aws"
}

Run tflint --init after any plugin version change. Commit .tflint.hcl to the repo so local runs match CI exactly.

How do you configure Terraform CI/CD with GitHub Actions securely?

Security is the primary constraint for any Terraform CI/CD with GitHub Actions setup. A pattern I see repeatedly in production audits is long-lived AWS or Azure access keys stored as repository secrets. In 2026, the standard is OpenID Connect (OIDC). GitHub Actions assumes a cloud IAM role temporarily. No static keys sit in your repo settings.

Configure an Identity Provider for token.actions.githubusercontent.com on AWS. Create a role with a trust policy scoped to your repository and environment. In the workflow, use the official aws-actions/configure-aws-credentials action with role-to-assume only. See the GitHub OIDC documentation for AWS for the full trust-policy template.

- name: Configure AWS Credentials via OIDC
  uses: aws-actions/configure-aws-credentials@v4
  with:
    role-to-assume: arn:aws:iam::123456789012:role/TerraformGithubActionsRole
    aws-region: ap-south-1

Never commit terraform.tfstate to Git. Use S3 with DynamoDB locking, Azure Blob with lease locking, or Terraform Cloud. State files expose resource IDs and sometimes secrets. Enable encryption at rest and restrict bucket access to the OIDC role. For deeper backend setup, read about Terraform remote state management and safe state handling practices.

GitHub ActionsRunnerRequest JWTOIDC ProviderGitHub token issuerSign tokenCloud IAMAWS / Azure / GCPAssume roleNo long-lived credentials in repository secrets
Secure OIDC authentication for Terraform CI/CD with GitHub Actions without static cloud access keys

Store sensitive variables as GitHub Environment Secrets, not repository secrets. Scope them to staging or production environments. Non-sensitive values like instance sizes belong in committed .tfvars files. For secret handling patterns beyond Terraform, see CI/CD secrets management best practices and pipeline secret hygiene.

What workflow steps should every Terraform GitHub Actions pipeline include?

A reliable pipeline separates validation from execution. Plan runs on every pull request. Apply runs only after merge, gated by environment protection. TFLint belongs early in the chain — before init and plan consume cloud API quota.

  1. Format check: Run terraform fmt -check -recursive. It fails fast and costs nothing.
  2. TFLint: Install the static binary, run tflint --init, emit SARIF or JSON, upload results, fail on errors.
  3. Validate: Run terraform validate after terraform init -backend=false for syntax checks without state.
  4. Security scan: Add Checkov or tfsec. See IaC security scanning with tfsec and Checkov and Checkov Terraform misconfiguration scans.
  5. Plan: Execute terraform plan -out=tfplan. Upload the binary plan as a workflow artifact.
  6. PR comment: Post plan output to the pull request so reviewers never leave GitHub.
  7. Protected apply: On merge, download the saved plan and run terraform apply tfplan. Never regenerate the plan at apply time.

Complete workflow excerpt combining TFLint install and machine-readable output:

name: Terraform CI/CD
on:
  pull_request:
    paths: ['infra/**']
  push:
    branches: [main]
    paths: ['infra/**']

permissions:
  id-token: write
  contents: read
  security-events: write
  pull-requests: write

jobs:
  lint-and-plan:
    runs-on: ubuntu-latest
    steps:
      - uses: actions/checkout@v4

      - name: Install TFLint static binary
        run: |
          TFLINT_VERSION="0.54.0"
          curl -sSL "https://github.com/terraform-linters/tflint/releases/download/v${TFLINT_VERSION}/tflint_linux_amd64.zip" -o tflint.zip
          unzip -o tflint.zip && sudo install -m 755 tflint /usr/local/bin/tflint

      - name: TFLint with SARIF output
        working-directory: infra/staging
        run: |
          tflint --init
          tflint --format=sarif > tflint.sarif || true

      - name: Upload TFLint SARIF
        uses: github/codeql-action/upload-sarif@v3
        with:
          sarif_file: infra/staging/tflint.sarif

      - uses: hashicorp/setup-terraform@v3
        with:
          terraform_version: "1.9.0"

      - name: Terraform Init and Plan
        working-directory: infra/staging
        run: |
          terraform init
          terraform plan -out=tfplan -no-color | tee plan.txt

      - uses: actions/upload-artifact@v4
        with:
          name: tfplan-staging
          path: infra/staging/tfplan
          retention-days: 3

Alternatively, use the official terraform-linters/setup-tflint@v4 action. It wraps the static binary install and accepts a tflint_version input. Both approaches satisfy the tflint install Linux static binary machine readable format 2026 requirement that teams search for when debugging CI failures.

For teams building broader automation, my CI/CD pipeline setup approach always places lint gates before any deploy step. The same rule applies whether you run Terraform, Laravel, or WordPress pipelines.

How do you manage multi-environment Terraform deployments in GitHub Actions?

Most production systems need separate staging and production paths. Directory-based separation beats workspace-based separation for distinct environments. Each environment gets its own state backend, variable file, and OIDC role.

CriteriaDirectory-based (/infra/prod)Workspace-based (terraform workspace)
State isolationComplete — separate backendsShared backend, prefixed keys
Config divergenceEasy — different .tfvars per envHard — needs conditionals
Permission granularityHigh — separate OIDC rolesLow — same role, same state
TFLint scopePer-directory .tflint.hclSingle config, harder to diverge
Recommended forProduction, compliance workloadsEphemeral dev sandboxes
Staginginfra/staging/TFLint JSON gateAuto-apply on mergeS3 state: stagingProductioninfra/prod/TFLint SARIF gateManual approvalS3 state: prodShared Modulesmodules/vpc/modules/rds/modules/app/
Directory-based multi-environment Terraform CI/CD with TFLint machine-readable gates and production approval

Use GitHub Environments to enforce approval gates on production. Reference the environment in your apply job. GitHub pauses until an authorised reviewer confirms. I've implemented this on legal-tech portals where audit trails are mandatory. Sister sites on shared Deployer pipelines follow the same promotion model from staging to production.

apply-prod:
  needs: lint-and-plan
  runs-on: ubuntu-latest
  environment: production
  permissions:
    id-token: write
    contents: read
  steps:
    - uses: actions/download-artifact@v4
      with:
        name: tfplan-prod
    - run: terraform apply -auto-approve tfplan

Align multi-environment Terraform with broader DevOps standards. Read DevOps automation best practices and AWS deploy from GitHub Actions with OIDC for complementary patterns.

How do you handle Terraform state locking and concurrency in CI?

Concurrent Terraform operations against the same state file cause corruption. S3 plus DynamoDB, Azure Blob, or GCS backends acquire locks automatically. GitHub Actions adds a second concurrency layer that backend locking alone does not solve.

concurrency:
  group: terraform-${{ github.ref }}
  cancel-in-progress: false

Set cancel-in-progress: false for apply jobs. Cancelling a running apply mid-execution can leave infrastructure half-provisioned. For plan jobs, true is acceptable since plans are read-only. This matters for high-traffic e-commerce infrastructure where partial deploys cause outages.

Stale locks from crashed runners need manual cleanup via terraform force-unlock LOCK_ID. Add a scheduled workflow to alert on locks older than 30 minutes. Infrastructure ownership belongs with the full stack — see notes on full-stack development practices that include ops responsibility.

How do you optimise Terraform CI/CD performance and cost?

Slow pipelines waste reviewer time and GitHub Actions minutes. Focus on caching, scoped execution, and reusable workflows.

  • Cache provider plugins: Persist .terraform/providers with actions/cache. Saves 30–60 seconds per run.
  • Cache TFLint plugins: Cache ~/.tflint.d/plugins after tflint --init to skip repeated downloads.
  • Targeted plans: Use dorny/paths-filter to lint and plan only changed directories in monorepos.
  • Parallelism tuning: Reduce with -parallelism=5 if you hit API rate limits. Increase cautiously on small stacks.
  • Artifact retention: Keep plan files for 1–3 days. Delete after a successful apply.
  • Reusable workflows: Define the pipeline once and call it from app repos. See GitHub Actions reusable workflows and Terraform reusable modules.
Detectpaths-filterSkip if no TFMatrix envsLint & PlanRestore cacheTFLint SARIFterraform planUpload planReviewPR commentSARIF tabEnv approvalApplyDownload planterraform applyNotify team
Performance-optimised Terraform CI/CD with GitHub Actions, TFLint machine-readable output, and conditional execution

On a recent Laravel booking platform deployment, caching TFLint plugins and Terraform providers cut pipeline time by roughly 40%. The saving adds up when every pull request triggers two environment plans.

For managed pipeline design on Ubuntu servers, see Linux system administration services and enterprise application development. Both cover the ops side that Terraform automates.

Key Takeaways

  • Install TFLint on Linux via pinned static binary from GitHub releases — not apt — for reproducible CI in 2026.
  • Use --format=json, --format=junit, or --format=sarif for machine-readable output that GitHub Actions can gate on or display.
  • Run TFLint before terraform plan to catch provider-specific errors without cloud API calls.
  • Authenticate with OIDC and store state in encrypted remote backends — never commit tfstate or long-lived keys.
  • Save plan artifacts and apply the exact reviewed plan after environment approval, not a regenerated one.
  • Cache TFLint plugins and Terraform providers to cut pipeline minutes and cost on every pull request.

People Also Ask

What is the best TFLint output format for GitHub Actions?

SARIF is the best choice when you want findings in GitHub's Security tab. JUnit works well if your team already uses test-report actions. JSON gives maximum flexibility for custom failure thresholds with jq. Default text output is fine locally but useless for automated gates.

Should I use the setup-tflint action or install the binary manually?

Both work. The terraform-linters/setup-tflint action is cleaner for most workflows. Manual curl-and-unzip gives you full control over architecture, install path, and air-gapped mirrors. Pick manual install when compliance requires vendored binaries.

Does TFLint replace terraform validate?

No. terraform validate checks HCL syntax and internal consistency. TFLint applies provider-specific rules — like invalid instance types or deprecated arguments. Run both in sequence during CI.

How do I fail a GitHub Actions job on TFLint warnings only?

Parse JSON output and count severities. TFLint exits non-zero on any issue by default. Use --force to always exit 0, upload the report, then apply your own threshold logic before failing the step.

Build production-grade Terraform CI/CD with GitHub Actions

Reliable Terraform CI/CD with GitHub Actions starts with fast, machine-readable lint gates. Install TFLint as a pinned Linux static binary, emit JSON or SARIF, and wire the results into your pull request workflow before plan and apply touch production. Add OIDC authentication, remote state with locking, environment approval gates, and provider caching on top. That stack has held up across legal-tech portals, e-commerce platforms, and shared EC2 deploy pipelines I maintain.

Need help wiring TFLint into an existing pipeline or integrating Terraform with Laravel deploy workflows? Contact us to discuss your infrastructure setup, or reach out directly with your repository layout and cloud provider. You can also review related guidance in TFLint and tfsec pipeline integration and GitHub Actions vs GitLab CI comparison before you commit to a platform.

Frequently Asked Questions

It is an automated workflow where GitHub Actions runners execute terraform plan and apply commands upon code commits, ensuring infrastructure changes are versioned, reviewed via pull requests, and deployed consistently without manual CLI intervention.

GitHub Actions provides 2,000 free minutes monthly for private repos; typical Terraform runs consume negligible time, so most small-to-medium projects stay within the free tier, costing NPR 0 (~USD 0) unless exceeding limits or using larger runners.

Choose GitHub Actions when your repository already lives on GitHub to avoid cross-platform sync complexity; choose GitLab CI if you need native container registry integration or self-hosted runners on private Nepal-based infrastructure for compliance.

Never hardcode secrets in workflow files. Store AWS_ACCESS_KEY_ID, AWS_SECRET_ACCESS_KEY, or TF_VAR_* values as GitHub Encrypted Secrets under repository settings. Access them via ${{ secrets.SECRET_NAME }} syntax. For production, prefer OIDC federation with AWS IAM or Azure AD to eliminate long-lived static credentials entirely, reducing breach risk if a secret leaks.

Split into two distinct jobs: validate-and-plan triggered on pull requests, and apply triggered only on merge to main. The plan job outputs a binary plan file saved as an artifact. The apply job downloads that exact artifact rather than re-running terraform plan. This guarantees what was reviewed in the PR comment is exactly what gets applied, preventing drift between review and execution.

Use remote backends like S3+DynamoDB, GCS, or Terraform Cloud which provide native locking. Local state files cannot safely handle concurrent GitHub Actions runners. If two PRs trigger simultaneously against local state, corruption occurs. Configure backend blocks in your versions.tf before first init. For Nepal projects on budget, S3+DynamoDB costs roughly NPR 150/month (~USD 1.10) and prevents catastrophic state conflicts during team collaboration.

Version mismatches cause this. Pin exact terraform and provider versions in required_version and required_providers blocks. Use hashicorp/setup-terraform action with specific version input rather than latest. Also ensure identical environment variables, workspace selection, and backend configuration between local and CI. I have debugged many deployments where developers ran terraform 1.9 locally but CI pulled 1.10, causing subtle provider behavior differences that broke applies.

Use terraform show -no-color -json plan.out to generate machine-readable output, then pipe through a formatting tool or custom script. Post via actions/github-script or third-party actions like tfsec-pr-commenter. Include the full plan summary, resource count, and warning indicators. This lets reviewers approve infrastructure changes without checking out code locally. On legal-tech portals I have built, this review step caught accidental security group deletions before they reached production.

Yes, and you must. Pass variable values as environment variables mapped from GitHub Secrets using TF_VAR_ prefix convention. Alternatively, generate tfvars dynamically during workflow from secrets. Never commit .tfvars containing passwords, API keys, or PII. For multi-environment setups, maintain separate secret sets per environment and select via workflow dispatch inputs or branch naming conventions. This pattern keeps repositories clean while supporting dev, staging, and production configurations securely.

Use directory-based separation like envs/dev/, envs/staging/, envs/prod/ with shared modules/. Create matrix strategies in workflows to run plans across all environments on PR, but restrict applies to targeted paths using dorny/paths-filter action. Each environment maintains independent state and variables. Avoid workspaces for environment isolation as they share backend config and increase blast radius. Directory separation provides clearer ownership, simpler RBAC, and safer partial applies when managing Nepal client infrastructure across regions.

Insufficient IAM permissions for the CI service account. Terraform needs create, update, delete, and tag permissions for every resource type in your configuration. Run terraform plan first to identify required actions, then scope IAM policies minimally. Also verify the GitHub Actions runner has network access to cloud APIs; VPC-restricted resources require self-hosted runners or VPN tunneling. Check CloudTrail or audit logs for exact denied actions. I have seen this repeatedly when teams grant admin locally but restrict CI accounts properly.

Add hashicorp/setup-terraform with terraform_wrapper: false, then run terraform fmt -check -recursive and tflint in a dedicated validation job before plan. Fail the workflow immediately on formatting violations to enforce consistency. Integrate checkov or tfsec for security scanning. Cache plugin directories using actions/cache to speed up subsequent runs. These checks cost seconds but prevent merged code that violates team standards or introduces security misconfigurations. Automated gates remove subjective code review debates about style.

Terraform has no native rollback. Revert the git commit that introduced the change, push to main, and let the apply workflow re-run with previous desired state. Maintain immutable infrastructure patterns where resources are replaced not mutated. Keep plan artifacts for at least 30 days as audit trail. For databases or stateful services, rely on application-level backups taken before apply. Document rollback procedures in README. In my experience, teams without tested rollback playbooks panic during outages; rehearse reversions quarterly.

Yes. Set TFC_TOKEN as encrypted secret and configure cloud blocks in terraform settings. GitHub Actions then delegates execution to Terraform Cloud workers while retaining PR commenting and approval gates. This hybrid approach gives you centralized state management, policy-as-code via Sentinel, and cost estimation without managing CI runner dependencies. Useful when scaling beyond solo developer projects. Pricing starts around NPR 8,000/month (~USD 60) for team tier. Evaluate whether standalone GitHub Actions suffices before adding this operational dependency.

Enable plugin caching via TF_PLUGIN_CACHE_DIR and persist with actions/cache. Use targeted applies with -target flag for large stacks during development. Split monolithic configurations into smaller, independently deployable stacks. Run validation and lint in parallel jobs. Use ARM64 runners which are cheaper and faster for Terraform. Avoid unnecessary terraform init on every run by caching .terraform directory. Monitor usage in GitHub billing dashboard. Small optimizations compound; shaving 30 seconds per run saves meaningful minutes across hundreds of monthly executions.

Share this article

0 Comments

Leave a comment

Your email is not published. Comments appear once they have been read. Sign in to have your details filled in.

Quick Contact Options
Choose how you want to connect me: