Skip to content

How to integrate OpenTofu with CI/CD

How-to · Updated May 2026

How to integrate OpenTofu with CI/CD

Running OpenTofu in a CI/CD pipeline eliminates manual applies, ensures consistent infrastructure changes, and creates an audit trail. This guide shows GitHub Actions and GitLab CI configurations for Quake AI.

Prerequisites#

  • An OpenTofu project with remote state configured (see state management guide)
  • Quake AI OpenStack credentials and S3 credentials
  • A GitHub or GitLab repository

Secrets configuration#

Both CI platforms need your Quake AI credentials stored as secrets. Use application credentials (the same credentials produced by Generate app credentials) so CI pipelines authenticate without a user password. Add these as CI/CD variables or repository secrets:

VariablePurpose
OS_AUTH_URLQuake AI Keystone endpoint
OS_AUTH_TYPEAlways v3applicationcredential
OS_APPLICATION_CREDENTIAL_IDApplication credential ID
OS_APPLICATION_CREDENTIAL_SECRETApplication credential secret
OS_REGION_NAMEQuake AI region
AWS_ACCESS_KEY_IDQuake AI S3 access key (for remote state)
AWS_SECRET_ACCESS_KEYQuake AI S3 secret key (for remote state)

GitHub actions#

Plan on pull request, apply on merge#

This workflow runs tofu plan on every pull request and tofu apply when changes merge to main:

YAML
name: Infrastructure

on:
  pull_request:
    paths:
      - 'infrastructure/**'
  push:
    branches:
      - main
    paths:
      - 'infrastructure/**'

jobs:
  plan:
    if: github.event_name == 'pull_request'
    runs-on: ubuntu-latest
    defaults:
      run:
        working-directory: infrastructure
    env:
      OS_AUTH_URL: ${{ secrets.OS_AUTH_URL }}
      OS_AUTH_TYPE: v3applicationcredential
      OS_APPLICATION_CREDENTIAL_ID: ${{ secrets.OS_APPLICATION_CREDENTIAL_ID }}
      OS_APPLICATION_CREDENTIAL_SECRET: ${{ secrets.OS_APPLICATION_CREDENTIAL_SECRET }}
      OS_REGION_NAME: ${{ secrets.OS_REGION_NAME }}
      AWS_ACCESS_KEY_ID: ${{ secrets.AWS_ACCESS_KEY_ID }}
      AWS_SECRET_ACCESS_KEY: ${{ secrets.AWS_SECRET_ACCESS_KEY }}
    steps:
      - uses: actions/checkout@v4
      - uses: opentofu/setup-opentofu@v1
      - run: tofu init
      - run: tofu validate
      - run: tofu plan -no-color -out=plan.out
      - name: Post plan to PR
        if: github.event_name == 'pull_request'
        uses: actions/github-script@v7
        with:
          script: |
            const { execSync } = require('child_process');
            const plan = execSync('cd infrastructure && tofu show -no-color plan.out').toString();
            const body = `### OpenTofu Plan\n\n\`\`\`\n${plan.slice(0, 60000)}\n\`\`\``;
            github.rest.issues.createComment({
              owner: context.repo.owner,
              repo: context.repo.repo,
              issue_number: context.issue.number,
              body
            });

  apply:
    if: github.event_name == 'push' && github.ref == 'refs/heads/main'
    runs-on: ubuntu-latest
    defaults:
      run:
        working-directory: infrastructure
    env:
      OS_AUTH_URL: ${{ secrets.OS_AUTH_URL }}
      OS_AUTH_TYPE: v3applicationcredential
      OS_APPLICATION_CREDENTIAL_ID: ${{ secrets.OS_APPLICATION_CREDENTIAL_ID }}
      OS_APPLICATION_CREDENTIAL_SECRET: ${{ secrets.OS_APPLICATION_CREDENTIAL_SECRET }}
      OS_REGION_NAME: ${{ secrets.OS_REGION_NAME }}
      AWS_ACCESS_KEY_ID: ${{ secrets.AWS_ACCESS_KEY_ID }}
      AWS_SECRET_ACCESS_KEY: ${{ secrets.AWS_SECRET_ACCESS_KEY }}
    steps:
      - uses: actions/checkout@v4
      - uses: opentofu/setup-opentofu@v1
      - run: tofu init
      - run: tofu apply -auto-approve

Multi-environment workflow#

For projects with separate environment variable files, extend the workflow with a matrix:

YAML
jobs:
  deploy:
    runs-on: ubuntu-latest
    strategy:
      matrix:
        environment: [dev, staging, production]
      max-parallel: 1
    defaults:
      run:
        working-directory: infrastructure
    steps:
      - uses: actions/checkout@v4
      - uses: opentofu/setup-opentofu@v1
      - run: tofu init -backend-config="key=${{ matrix.environment }}/terraform.tfstate"
      - run: tofu apply -auto-approve -var-file=envs/${{ matrix.environment }}.tfvars

The max-parallel: 1 setting ensures environments deploy sequentially: dev before staging before production.

GitLab CI#

Plan on merge request, apply on merge#

YAML
stages:
  - validate
  - plan
  - apply

variables:
  TF_ROOT: infrastructure

.tofu_base:
  image: ghcr.io/opentofu/opentofu:latest
  before_script:
    - cd $TF_ROOT
    - tofu init

validate:
  extends: .tofu_base
  stage: validate
  script:
    - tofu validate

plan:
  extends: .tofu_base
  stage: plan
  script:
    - tofu plan -out=plan.cache
  artifacts:
    paths:
      - $TF_ROOT/plan.cache
  rules:
    - if: $CI_PIPELINE_SOURCE == "merge_request_event"
    - if: $CI_COMMIT_BRANCH == $CI_DEFAULT_BRANCH

apply:
  extends: .tofu_base
  stage: apply
  script:
    - tofu apply plan.cache
  dependencies:
    - plan
  rules:
    - if: $CI_COMMIT_BRANCH == $CI_DEFAULT_BRANCH
      when: manual
  environment:
    name: production

The when: manual gate on the apply stage requires a human click in the GitLab UI before changes apply to production.

Store secrets in GitLab CI/CD variables#

Navigate to Settings > CI/CD > Variables in your GitLab project. Add each environment variable listed in the secrets table above. Mark them as Masked and Protected (restrict to protected branches only).

Validation-only pipeline#

If you are not ready for automated applies, start with a validation-only pipeline that checks template syntax on every commit:

YAML
name: Validate Templates

on: [push, pull_request]

jobs:
  validate:
    runs-on: ubuntu-latest
    steps:
      - uses: actions/checkout@v4
      - uses: opentofu/setup-opentofu@v1
      - name: Validate all templates
        run: |
          for dir in iac/templates/*/; do
            if ls "$dir"*.tf &>/dev/null; then
              echo "Validating $dir..."
              cd "$dir"
              tofu init -backend=false
              tofu validate
              cd - > /dev/null
            fi
          done

This is the approach the Quake AI developer platform uses for its own template library.

Best practices#

  • Plan on PR, apply on merge. Never auto-apply on pull requests. Review the plan output before merging.
  • Serialize applies. Use max-parallel: 1 or resource_group to prevent concurrent state writes.
  • Pin provider versions. Use version = "~> 2.0" constraints in required_providers to avoid unexpected upgrades.
  • Cache provider downloads. The OpenStack and AWS providers are large. Cache the .terraform/providers directory between runs to speed up tofu init.
  • Separate state per environment. Use different state keys or backend configs to isolate environments (see multi-environment guide).

See also#

Usage Guidelines

The sample code, software libraries, command line tools, proofs of concept, templates, and other related technology on this page (including any of the foregoing that is provided by Quake AI personnel) is provided to you as Quake AI Content under the Quake AI Customer Agreement, or the relevant written agreement between you and Quake AI (whichever applies). Do not use this Quake AI Content in your production accounts, or on production or other critical data. You are responsible for testing, securing, and optimizing the Quake AI Content (such as sample code) as appropriate for production grade use based on your specific quality control practices and standards. Deploying Quake AI Content may incur Quake AI charges for creating or using Quake AI chargeable resources, such as running Compute instances or storing data in Object Storage. Your use is also subject to the Acceptable Use Policy.

For the full policy, see Usage Guidelines.

Last validated: 25.05.2026

Was this page helpful?