Skip to content

Ephemeral environments per pull request

llms.txtlists every page for an agent
Optional: hand this page to your coding agentThe steps work by hand too.
Show the whole prompt
Read https://intentius.io/terragucci/guides/ephemeral-environments/.
Look at this repo's roots and tell me which of them could be copied per pull request with ephemeral: name each root's backend and the key its copy would get, and, in a Terragrunt repo, whether the remote_state key reads TERRAGUCCI_EPHEMERAL_SUFFIX.
Do not edit terragucci.yml or run terragucci init.
Never apply, approve (a pull request review or `terragucci approve`), override a policy denial (`terragucci override`), use `--mode apply`, or merge; never touch `.chant/allowed_signers` or `chant/lifecycle`.

With ephemeral set, each open pull request gets an environment of its own to try the change in before it merges: the roots you name, applied from its head under state keys nothing else uses.

When What terragucci does
the pull request opens, or gets a push plans the environment, waits at the gate as gate says, and applies it
the pull request closes or merges plans the environment’s destroy and applies it
its TTL passes first the sweep destroys it the same way

The state key is the root’s own with -pr-<n> added: envs/preview/app/terraform.tfstate becomes envs/preview/app/terraform-pr-12.tfstate. Nothing about the root itself changes. Every apply and destroy is on the audit trail, and the estate page lists each live environment with its expiry.

  1. Pick the roots. A root whose resources have fixed names collides with itself once a second state applies it, so let each environment name its resources apart, with a random_id suffix for example.

  2. Add the key to terragucci.yml on the default branch:

    ephemeral:
    roots: ["envs/preview/*"]
    ttl: 24h # default; m, h or d
    sweep: 30 # minutes between the sweep's runs, 5 to 60
  3. Run npx terragucci init and commit what it writes: the ephemeral job, and on GitHub and Forgejo the sweep workflow terragucci-ephemeral.yml.

  4. Open a pull request. The ephemeral job runs from the default branch’s workflow. It checks out the pull request’s head apart and applies the environment there:

    terragucci ephemeral: pull request 12: its copy of envs/preview/app under the state keys suffixed -pr-12, from 3f9c21aa
    terragucci ephemeral: envs/preview/app: 4 to change at s3://acme-state/envs/preview/app/terraform-pr-12.tfstate
    terragucci ephemeral: applied envs/preview/app at s3://acme-state/envs/preview/app/terraform-pr-12.tfstate
    terragucci ephemeral: pull request 12: its copy applied; it expires 2026-10-10T16:02:11.000Z, and closing the pull request destroys it before then
  5. Close or merge it. The destroy plans and applies, and _gates/tf-ephemeral/done.jsonl on chant/lifecycle records it with the reason closed.

The environment waits at gate pr-<n> of op tf-ephemeral under the gate your waves use:

gate Waits on
always any change
on-destructive a change that destroys or replaces something

The job prints the command that approves it:

Terminal window
terragucci approve ephemeral 12 --plan jcs1-sha256:6d2b...

The next push or a re-run of the job then applies it. Under approval: sealed only a sealed approval counts.

No gate holds a destroy. Closing the pull request is the decision, and so is the TTL the default branch set. The destroy still plans first, and its record carries that plan’s digest.

An environment expires ttl after the last apply that changed it, so a pull request that keeps getting pushes keeps it. Every sweep minutes, a run on the default branch destroys each environment whose TTL passed, and each one whose pull request the forge reports closed. A failed destroy leaves the environment live and flagged on the estate page; the next sweep tries again.

Forge It applies It is destroyed on close
GitHub in the ephemeral job, on pull_request_target from the default branch by the same job, on the closed event
Forgejo the same the same
GitLab in the ephemeral job of the merge request’s pipeline, with terragucci.yml read from the default branch by the sweep: GitLab starts no pipeline when a merge request closes. Add a pipeline schedule with TERRAGUCCI_SCHEDULE=ephemeral

Forks never get one. On GitLab the job needs the project token, so ephemeral with gitlab.token: protected is a config error.

Setup Gets an environment
OpenTofu, Terraform or choudoufu, with an s3, azurerm, gcs or local backend, or on GitLab-managed state yes
a Terragrunt unit whose remote_state key reads TERRAGUCCI_EPHEMERAL_SUFFIX yes; see Terragrunt units
a CDK Terrain stack, with synth yes; see Synthesized roots
a root in HCP Terraform (a cloud block), or on another backend no: the job fails with a config error naming the root
a Terragrunt unit whose key does not read the suffix no: the job fails with a config error naming the file, before anything plans

terragucci never selects a CLI workspace. The suffix goes in the backend block’s own key attribute (prefix on gcs), so the environment’s lock file and a bucket listing show it plainly.

On GitLab-managed state the suffix goes on the state name in address, lock_address and unlock_address, from the block or the TF_HTTP_* variables: .../terraform/state/app becomes .../terraform/state/app-pr-12, which GitLab creates on its first write. A lock address that is not <address>/lock is refused, so a copy never takes the root’s own lock. An http backend that is not GitLab’s is refused.

A unit’s state key comes from its remote_state block, which terragucci leaves as you wrote it. Make the key read the suffix from TERRAGUCCI_EPHEMERAL_SUFFIX, which is empty in every other job:

remote_state {
backend = "s3"
config = {
bucket = "acme-state"
key = "${path_relative_to_include()}/terraform${get_env("TERRAGUCCI_EPHEMERAL_SUFFIX", "")}.tfstate"
}
}

The ephemeral job sets it to -pr-<n> and prepares each unit with terragrunt run -- init, so live/preview/app/terraform.tfstate becomes live/preview/app/terraform-pr-12.tfstate. A dependency block reads its upstream’s environment too, since the upstream’s key takes the same suffix. The units apply in the order their dependencies give, and are destroyed in reverse.

Before anything plans, the job reads the backend Terragrunt initialised each unit with. A key without the suffix stops the job with a config error, so no environment ever runs against a unit’s own state:

terragucci: live/preview/app: its s3 backend's key is live/preview/app/terraform.tfstate with TERRAGUCCI_EPHEMERAL_SUFFIX set, which is the unit's own state, so its copy is refused; make the remote_state block's key read the suffix, ...

The terragucci.hcl that generate writes already reads the suffix in every unit’s key.

With synth, git holds no roots to copy. The ephemeral job runs the synth command in the pull request’s checkout first, as every other job does, then copies the stacks roots names, such as cdktf.out/stacks/preview. The destroy runs it again on the commit the environment was applied from. A stack’s copy takes the suffix on the key its backend construct gives, so give the stack a backend that keeps its state outside the job (S3Backend, for one). CDK Terrain’s default local state lives in the job’s checkout and is gone when the job ends.

A synth command that fails stops the job, and nothing applies.

Unmerged pull request code runs with the apply role in this job, its synth command included, as it does under apply before merge. Scope that role to the -pr- keys with a role per environment, and require reviews in branch protection.

Every flag is on CLI commands, and every key on terragucci.yml keys.

terragucci

These docs count page views and clicks with PostHog. They set no cookies, store nothing in your browser, and send nothing when your browser asks not to be tracked.