# How to integrate OpenTofu with CI/CD

Source: https://docs.quake.ai/docs/automation/how-to/cicd-integration
Markdown: https://docs.quake.ai/docs/automation/how-to/cicd-integration.md

---

# 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](/docs/automation/how-to/state-management))
- 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](/docs/tools/generate-app-credentials)) so CI pipelines authenticate without a user password. Add these as CI/CD variables or repository secrets:

| Variable | Purpose |
|---|---|
| `OS_AUTH_URL` | Quake AI Keystone endpoint |
| `OS_AUTH_TYPE` | Always `v3applicationcredential` |
| `OS_APPLICATION_CREDENTIAL_ID` | Application credential ID |
| `OS_APPLICATION_CREDENTIAL_SECRET` | Application credential secret |
| `OS_REGION_NAME` | Quake AI region |
| `AWS_ACCESS_KEY_ID` | Quake AI S3 access key (for remote state) |
| `AWS_SECRET_ACCESS_KEY` | Quake AI S3 secret key (for remote state) |


Store credentials as masked/protected secrets. Never commit them to the repository.


## 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](/docs/automation/how-to/multi-environment)).

## See also

- [How to deploy an application to a Quake AI VM from CI](/docs/automation/how-to/app-cicd-vm)
- [How to deploy to a Quake AI Kubernetes cluster from CI](/docs/kubernetes/how-to/deploy-from-ci)
- [How to manage OpenTofu state with Quake AI S3](/docs/automation/how-to/state-management)
- [How to manage multiple environments with OpenTofu](/docs/automation/how-to/multi-environment)
- [Infrastructure Templates](/resources/iac-templates)
