Skip to content
Français
Cloud medium

Test your Terraform in CI with no cloud credentials

2 min de lecture

Read this page in French

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 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.

Fenêtre de terminal
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

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.

Next steps

Is this site useful to you?

Fewer than 1% of readers support this site.

I maintain more than 700 free guides, with no ads and no tracking. Any support, even a symbolic one, helps cover hosting and keeps these resources free. Thank you for the help.

The form does not show? Open Ko-fi in a new tab.

Subscribe and follow my DevSecOps work on LinkedIn