Ephemeral environments per pull request
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`.Result
Section titled “Result”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.
Turn it on
Section titled “Turn it on”-
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_idsuffix for example. -
Add the key to
terragucci.ymlon the default branch:ephemeral:roots: ["envs/preview/*"]ttl: 24h # default; m, h or dsweep: 30 # minutes between the sweep's runs, 5 to 60 -
Run
npx terragucci initand commit what it writes: theephemeraljob, and on GitHub and Forgejo the sweep workflowterragucci-ephemeral.yml. -
Open a pull request. The
ephemeraljob 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 3f9c21aaterragucci ephemeral: envs/preview/app: 4 to change at s3://acme-state/envs/preview/app/terraform-pr-12.tfstateterragucci ephemeral: applied envs/preview/app at s3://acme-state/envs/preview/app/terraform-pr-12.tfstateterragucci ephemeral: pull request 12: its copy applied; it expires 2026-10-10T16:02:11.000Z, and closing the pull request destroys it before then -
Close or merge it. The destroy plans and applies, and
_gates/tf-ephemeral/done.jsonlonchant/lifecyclerecords it with the reasonclosed.
The gate
Section titled “The gate”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:
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.
The TTL
Section titled “The TTL”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.
Per forge
Section titled “Per forge”| 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.
Supported roots
Section titled “Supported roots”| 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.
Terragrunt units
Section titled “Terragrunt units”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.
Synthesized roots
Section titled “Synthesized roots”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.
Pull request code
Section titled “Pull request code”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.
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.