Skip to content
Français
Account: not signed in, sync available My learning
Select theme
Français

Test your Terraform in CI with no cloud credentials

  • 15 min

By updated

Improve this page

A pipeline that tests Terraform needs a cloud, and a cloud needs an account, a secret and a bill. Feint removes all three: the emulator enters a services: block like any test database, and the real Terraform provider applies against it. No secret to create, no resource to clean up when a job fails halfway.

What you will get

By the end of this page, a pull request will run a full apply, second plan and destroy, on an ordinary runner, without a single cloud secret existing in the repository and without a billed resource being created.

  • Choose between the service container, the dedicated action and the binary.
  • Write the job for GitHub Actions and for GitLab CI.
  • Understand why the second plan is the only real assertion.
  • Verify the image's signature before running it.

Three forms, and which one to take

The choice comes down to one question: does your job already run in containers?

FormWhen to choose itWhat it gives you
Service containerthe job already uses containersnothing to install, an image pinned by digest
setup-feint actionthe runner executes commands directlyinstalls the binary, verifies its checksum, waits for the answer
Binary on a hostyou want real machines behind the APIthe only mode that opens the Incus runtime

The container is a control plane and nothing else. It runs with --vm off and emulates only the three APIs. An image promising to start containers from inside a container would be a half-truth, and the project prefers to state it: real machines need the binary on a host, as detailed in Run real machines.

The whole pipeline, in both forges

The job does the same thing on both sides: apply, re-plan, destroy. Only the way of waiting for the emulator changes.

The runner holds the steps until the image's own healthcheck answers, so the first step can talk to it immediately.

name: Terraform against a local cloud
on:
pull_request:
workflow_dispatch:
permissions: {}
jobs:
terraform:
name: apply, re-plan, destroy
runs-on: ubuntu-24.04
timeout-minutes: 10
services:
feint:
image: ghcr.io/stephrobert/feint:v0.13.0@sha256:d46639ac7c7a0fa2b6b69d0c5d74bdca0757e3124675563209e8ada58e6a6040
ports:
- 4599:4599
env:
SCW_ACCESS_KEY: SCWXXXXXXXXXXXXXXXXX
SCW_SECRET_KEY: 11111111-1111-1111-1111-111111111111
SCW_DEFAULT_PROJECT_ID: 11111111-1111-1111-1111-111111111111
SCW_DEFAULT_ORGANIZATION_ID: 11111111-1111-1111-1111-111111111111
SCW_DEFAULT_ZONE: fr-par-1
SCW_DEFAULT_REGION: fr-par
SCW_API_URL: http://127.0.0.1:4599
SCW_INSECURE: "true"
steps:
- uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
with:
persist-credentials: false
- uses: hashicorp/setup-terraform@b9cd54a3c349d3f38e8881555d616ced269862dd # v3.1.2
with:
terraform_version: 1.15.4
- run: terraform init
- run: terraform apply -auto-approve
- run: terraform plan -detailed-exitcode
- run: terraform destroy -auto-approve

The assertion that makes this a test

The line that counts is terraform plan -detailed-exitcode, not the apply. A successful apply proves the API answered 200 or 201; it does not prove it stored what it said it stored.

The -detailed-exitcode flag turns the plan into an assertion:

CodeMeaning
0no change, the infrastructure converged
1error
2changes proposed, therefore drift

An emulator answering 200 while storing something else would be invisible to the apply and obvious here: the second plan would propose to recreate what already exists. That is the assertion the project's own conformance suite runs against every provider, and measured on the v0.13.0 container, this second plan does exit 0.

The diagram follows a job from start to finish. The thick path is a green pipeline: the emulator answers, the apply passes, the second plan returns 0, and the destroy cleans up. Look above all at the two red exits: a silent emulator fails the job straight away, and a plan returning 2 reports the defect this test exists to find.

Cycle of a CI job against Feint: the pull request starts the Feint service, the job waits for health to answer, applies, then runs plan -detailed-exitcode; code 0 leads to destroy, codes 1 and 2 turn the job red, and an emulator that never answers fails the job at once.noyes0: converged2: drift1: errorPull requestFeint serviceimage by digestHealthanswers?Job failsoutrightterraform applyplan-detailed-exitcodeterraform destroyDefect foundred job
The second plan, not the apply, decides the colour of the job. Source · SVG
Esc to close, + and − to zoom, scroll to move around. 100 %

These three codes are not specific to Feint: they serve any Terraform run driven by a machine, as detailed in Running Terraform in automation (in French).

The credentials that are not secrets

The values in the env block deserve an explanation, otherwise the first code review will read them as a leak.

They are well formed and meaningless. The Scaleway SDK validates the format of these variables before sending anything: the access key looks like SCWXXXXXXXXXXXXXXXXX and the secret is a UUID. They are fake in the only way that still lets a real client start.

The emulator verifies no authentication. It parses the shape of a credential and validates nothing, which is exactly the property that removes the need for an account. The trade-off is clear: an IAM permission test makes no sense here, as detailed in What Feint proves.

Verify the image before running it

This site teaches software supply chain security, and an emulator that runs in everybody's pipeline is precisely an attack surface. The image is signed and attested by the release workflow, under the same identity as the binaries.

Terminal window
cosign verify ghcr.io/stephrobert/feint:v0.13.0 \
--certificate-identity-regexp '^https://github\.com/stephrobert/feint/\.github/workflows/release\.yml@refs/tags/v' \
--certificate-oidc-issuer https://token.actions.githubusercontent.com

The output confirms three distinct attestations on the same digest: a software bill of materials in CycloneDX format, a SLSA provenance and the signature itself. The verified identity is the decisive part of the command: it requires the signature to come from this repository's release workflow, on a tag, and not from any account that happened to push an image.

One tag per release, and no latest. A mutable reference pulls whatever is newest, which is an image nobody can name afterwards when a test fails. Pinning the digest in the pipeline, as in the examples above, closes the question completely.

Troubleshooting

Most failures of such a pipeline come down to waiting for the service, the credential format, or an operation the pack does not serve. A job that must refuse to continue without an emulator relies on feint wait, covered with the other exit codes in Diagnose a run. To check that a job also survives an API outage, the scenarios of Trigger an API outage replay in this same pipeline.

SymptomCauseFix
The job fails immediately on a refused connectionGitLab does not wait for the serviceKeep the waiting loop in before_script
terraform plan exits 2A real divergence between the apply and the stored stateRead the plan: that is the defect this test looks for
The SDK refuses to startA malformed credential variableRespect the formats: SCWXXXXXXXXXXXXXXXXX and a UUID
An operation answers 404 for no clear reasonThe pack declines that operationLook for the X-Feint-Not-Emulated header
The pipeline is green but tests nothingNo assertion after the applyAdd terraform plan -detailed-exitcode
Calls leave for the real cloudAn Object Storage resource in the configurationRun feint doctor in the directory before the apply

Key takeaways

  • The emulator enters a services: block like any other test dependency.
  • The container is a control plane: no machines, and that is announced rather than promised.
  • The second plan is the assertion, the apply is only a prerequisite.
  • The credentials are well formed and meaningless, which still lets a real client start.
  • The image is verified by signature and pinned by digest, never by latest.
  • GitLab does not wait for the service: the loop tests a condition, not a duration.

Is this project useful to you?

The guides, courses, quizzes and labs stay free. If you use them regularly, you can contribute to their upkeep and development.

Become a supporting member (new tab) One-time support (new tab) Learn more

Follow the project on LinkedIn