Snap CD vs GitLab CI / GitHub Actions for Terraform

Most teams start running Terraform from their existing CI system. GitHub Actions, GitLab CI, Jenkins, CircleCI — they all support it. You write a pipeline that runs terraform plan on a pull request and terraform apply on merge. It works.

Until it doesn't.

This guide looks at what happens as Terraform usage grows inside a general-purpose CI tool, where the friction starts, and when a purpose-built deployment system like Snap CD becomes worth the switch.

General-purpose CI vs purpose-built infrastructure deployment

CI tools are designed for building and testing software. They execute steps in order, pass artifacts between jobs, and report success or failure. They're very good at that.

Terraform deployments have a different shape:

  • Stateful operations — every apply mutates remote state. A failed run can leave state locked or partially applied. CI doesn't know what Terraform state is.
  • Cross-state dependencies — one Terraform root's outputs feed into another's inputs. CI has no concept of this relationship.
  • Approval requirements — you want humans to review a plan before it applies. CI can approximate this with manual jobs, but it's bolted on, not built in.
  • Drift detection — infrastructure can change outside Terraform. CI only knows about your code; it doesn't watch for divergence between state and reality.

CI tools can run terraform apply, but they don't understand what that command means for your infrastructure.

Cross-state dependencies

CI: manual DAG maintenance

Once you have more than two or three states, you start wiring outputs between CI jobs:

# GitHub Actions — networking outputs to compute
jobs:
  networking:
    steps:
      - run: terraform apply -auto-approve
      - run: terraform output -json > networking-outputs.json
      - uses: actions/upload-artifact@v4
        with:
          name: networking-outputs
          path: networking-outputs.json

  compute:
    needs: networking
    steps:
      - uses: actions/download-artifact@v4
        with:
          name: networking-outputs
      - run: |
          VPC_ID=$(jq -r '.vpc_id.value' networking-outputs.json)
          SUBNET_IDS=$(jq -r '.private_subnet_ids.value' networking-outputs.json)
          terraform apply -auto-approve \
            -var="vpc_id=$VPC_ID" \
            -var="private_subnet_ids=$SUBNET_IDS"

This is fragile. The jq parsing breaks silently if output names change. The needs: graph has to mirror your Terraform dependency graph manually. If you add a new output, you update Terraform and the pipeline and the downstream jobs. At five states the graph looks like this:

jobs:
  networking:
    # ...
  compute:
    needs: [networking]
  database:
    needs: [networking]
  dns:
    needs: [compute]
  monitoring:
    needs: [compute, database]

Every new dependency means editing the needs: list, adding artifact passing, and modifying the downstream job's variables.

Snap CD: declarative dependency wiring

In Snap CD, dependencies are declared as snapcd_module_input_from_output resources:

resource "snapcd_module_input_from_output" "vpc_id" {
  module_id        = snapcd_module.compute.id
  input_kind       = "Param"
  name             = "vpc_id"
  output_module_id = snapcd_module.networking.id
  output_name      = "vpc_id"
}

Snap CD builds the dependency graph from these declarations. When networking's outputs change, compute automatically re-plans. No manual DAG maintenance, no artifact passing, no jq.

Credential management

CI: shared secrets

CI tools store secrets as environment variables or masked variables. They're available to every job in the pipeline unless you carefully scope them with environment protections.

The problem: your networking state needs AWS credentials for Route53. Your compute state needs AWS credentials for EKS. Your database state needs AWS credentials for RDS plus a different set for the database root password. In CI, all of these secrets exist in the same project, and scoping them to specific jobs requires per-job environment configuration that's easy to misconfigure. And even when credentials are scoped, plan output posted to PRs can leak sensitive values — a problem any CI-based Terraform workflow shares.

Snap CD: per-Runner credential isolation

Snap CD's Runner model solves this architecturally. Each Runner is a separate process with its own credentials. A Runner for production Azure only has production Azure credentials. A Runner for development AWS only has development AWS credentials. The credentials never pass through the Snap CD Server — they live on the Runner, and the Runner only executes Modules it's been assigned to.

Plan review and approvals

CI: manual jobs

CI can approximate approval gates with manual pipeline steps:

# GitLab CI — manual approval before apply
plan:
  stage: plan
  script:
    - terraform plan -out=tfplan
  artifacts:
    paths:
      - tfplan

approve:
  stage: approve
  script:
    - echo "Approved"
  when: manual
  needs: [plan]

apply:
  stage: apply
  script:
    - terraform apply tfplan
  needs: [approve]

This gives you a button to click, but no structured review. The plan output is buried in CI logs. There's no quorum ("require two approvers"). There's no audit trail beyond "someone clicked the button." For a plan that touches fifty resources, you're scrolling through hundreds of lines of terminal output in a log viewer that wasn't designed for this.

Snap CD: built-in approval gates

Snap CD provides structured plan review:

  • Plans are displayed with resource-level detail.
  • Approval gates require a configurable number of approvals before apply proceeds.
  • Approvers can be scoped by role — only users with the Approver role on the relevant Stack, Namespace, or Module can approve.
  • All approvals are audited.

Drift detection

CI: custom scheduled pipelines

CI tools don't know about drift. They run when you push code, not when your infrastructure changes. If someone manually modifies a security group in the AWS console, CI won't notice until the next terraform plan — which might be days or weeks later.

Teams that need drift detection end up building it themselves:

# GitHub Actions — check for drift nightly
on:
  schedule:
    - cron: '0 2 * * *'

jobs:
  drift:
    strategy:
      matrix:
        state: [networking, compute, database, dns]
    steps:
      - run: |
          cd ${{ matrix.state }}
          terraform plan -detailed-exitcode
          if [ $? -eq 2 ]; then
            echo "DRIFT DETECTED in ${{ matrix.state }}"
            # ... send Slack notification, open issue, etc.
          fi

You've now written a custom drift detection system. It runs on a schedule (not in real time), and the notification/remediation logic is entirely your responsibility.

Snap CD: built-in drift checks

Snap CD runs drift detection on a configurable schedule per Module. When drift is detected, it automatically creates a plan to bring infrastructure back in line, and routes that plan through the same approval workflow as any other change.

Source watching

CI: webhook on push

CI pipelines trigger on Git events — a push, a merged PR, a tag. If no one pushes, nothing happens. There's no concept of "watch this branch and deploy when it changes" independent of a pipeline run. Scheduled pipelines can approximate polling, but they run the entire pipeline on a timer regardless of whether anything changed.

Snap CD: continuous source monitoring

Snap CD monitors Git sources continuously. When a new commit lands on the branch a Module watches, a plan is triggered automatically. Source monitoring supports branches, tags, and semantic version ranges — a Module can track v1.* and re-plan whenever a matching tag appears.

Multi-cloud

CI: one pipeline, many credentials

Running Terraform across multiple clouds from a single CI pipeline means loading credentials for every cloud into the same environment. Scoping them to specific jobs is possible but manual. The dependency graph between clouds (AWS networking → Azure DNS → GCP logging) is encoded in needs: chains and artifact passing — the same fragile pattern, now spanning cloud boundaries.

Snap CD: one Runner per cloud

Each cloud gets its own Runner with only the credentials it needs. An AWS Runner can't touch Azure resources and vice versa. Cross-cloud dependencies are declared with the same snapcd_module_input_from_output resources used for any other dependency — the Server handles the ordering regardless of which Runner executes each Module.

Comparison

Capability GitLab CI / GitHub Actions Snap CD
Cross-state dependencies Manual (needs: + artifacts) Declarative (snapcd_module_input_from_output)
Automatic cascading on output changes No (manual re-trigger) Yes
Credential scoping per state Per-environment secrets (manual) Per-Runner (architectural)
Plan approval gates Manual jobs (no quorum) N-of-M approval with role scoping
Drift detection Custom scheduled pipelines Built-in, configurable per Module
Source watching Webhook on push Continuous polling with tag/branch/commit support
Multi-cloud Possible but messy Natural (one Runner per cloud)
Audit trail CI logs Structured audit log

When CI is enough

CI works well for Terraform when:

  • You have one or two states. The dependency graph is trivial. Passing outputs between two jobs isn't painful.
  • One team owns all infrastructure. There's no credential scoping concern. Everyone has access to everything.
  • Deployments are infrequent. You apply once a week, manually. The overhead of glue code is low because you rarely touch it.
  • You don't need drift detection. Your infrastructure is stable, or you have other monitoring in place.

The tipping point usually comes when you have five or more states with cross-dependencies, multiple teams needing scoped access, or compliance requirements that demand structured approval workflows. At that point, the CI glue code has become a project in itself — one that no one signed up to maintain.

Making the switch

Your Terraform code doesn't change — Snap CD runs the same terraform plan and terraform apply commands your CI pipeline ran. The migration is about replacing the orchestration layer around it.

  1. Deploy the Snap CD stack. Set up the Server (or use snapcd.io) and deploy a Runner in your infrastructure with the cloud credentials your CI jobs use today. See the quickstart guide to get running.
  2. Create a Stack and Namespace. A Stack typically maps to an environment (prod, staging). A Namespace groups related Modules within it (platform, app-a).
  3. Create a Module for each Terraform root. Point it at the Git repo and branch. Snap CD monitors the source and triggers plans on new commits — replacing your CI webhook trigger.
  4. Wire cross-state dependencies. Each terraform_remote_state data source or artifact-passing step in your CI pipeline becomes a snapcd_module_input_from_output resource. Once wired, you can remove terraform_remote_state from your Terraform code entirely.
  5. Move sensitive values to Secrets. Any database passwords, API keys, or tokens currently stored as CI masked variables become Snap CD Secrets, scoped to the Stack, Namespace, or Module that needs them. Bind them to Modules with snapcd_module_input_from_secret — they're injected at runtime and never written to disk in plaintext.
  6. Set approval thresholds. Replace your CI manual-approval jobs with apply_approval_threshold on each Module. Assign the Approver role to the people who currently click the approval button.
  7. Delete the CI pipeline steps for Terraform. The needs: graph, the artifact upload/download, the jq parsing, the scheduled drift jobs — all replaced. Keep CI for application builds and tests.

You can migrate incrementally — move one Terraform root at a time while the rest continue running from CI.

See also

Snap CD

Intelligent GitOps for Infrastructure as Code. Automate, orchestrate, and scale your infrastructure deployments with confidence.


© 2026 Snap CD. All rights reserved.

An unhandled error has occurred. Reload 🗙