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
applymutates 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
Approverrole 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.
- 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.
- Create a Stack and Namespace. A Stack typically maps to an environment (
prod,staging). A Namespace groups related Modules within it (platform,app-a). - 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.
- Wire cross-state dependencies. Each
terraform_remote_statedata source or artifact-passing step in your CI pipeline becomes asnapcd_module_input_from_outputresource. Once wired, you can removeterraform_remote_statefrom your Terraform code entirely. - 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. - Set approval thresholds. Replace your CI manual-approval jobs with
apply_approval_thresholdon each Module. Assign theApproverrole to the people who currently click the approval button. - Delete the CI pipeline steps for Terraform. The
needs:graph, the artifact upload/download, thejqparsing, 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
- Modular Deployments — how Snap CD's Module system wires cross-state dependencies
- Self-Hosted Terraform Runners with Credential Isolation — how Runners provide per-environment credential isolation
- Event-driven Continuous Deployment — how Snap CD cascades changes automatically
- Non-invasive Orchestration — Snap CD runs standard Terraform commands, no wrappers
Intelligent GitOps for Infrastructure as Code. Automate, orchestrate, and scale your infrastructure deployments with confidence.
© 2026 Snap CD. All rights reserved.