---
title: Create Temporary AWS Preview Environments That Expire
description: One daily cleanup check is a single EventBridge Scheduler invocation, inside the 14 million free invocations a month. The bill that matters is the preview stack's own compute. Whether that stack is cheaper than shared staging is a different article.
url: https://www.factualminds.com/blog/automate-ephemeral-aws-preview-environments/
datePublished: 2026-10-19T00:00:00.000Z
dateModified: 2026-10-19T00:00:00.000Z
author: palaniappan-p
category: DevOps & CI/CD
tags: devops, terraform, opentofu, amazon-eventbridge, cicd
---

# Create Temporary AWS Preview Environments That Expire

> One daily cleanup check is a single EventBridge Scheduler invocation, inside the 14 million free invocations a month. The bill that matters is the preview stack's own compute. Whether that stack is cheaper than shared staging is a different article.

On **19 October 2026**, a preview environment is useful until it is still running on Friday. The [CI/CD cost article](https://www.factualminds.com/blog/cost-aware-cicd-pipelines-aws/) decides whether ephemeral stacks beat a shared stage. This page is the destroy path that cannot see production. The [Terraform cheat sheet](https://www.factualminds.com/blog/terraform-commands-cheat-sheet-aws-2026/) already warns that workspaces are the wrong boundary between production and staging.

We use OpenTofu for new stacks. The migration mechanics are the [OpenTofu article](https://www.factualminds.com/blog/migrate-terraform-opentofu-aws/). The tag contract is the same if you are still on Terraform.

> **Reproduce this** — Copy the [tag contract](https://www.factualminds.com/examples/architecture-blog-2026/preview-environments/tag-contract.md). Cleanup that ignores a missing tag is not cleanup.

## Who this is for

The platform engineer who opens a pull request and wants a URL, and the person who pays for the account when that URL is still up next month.

## What you get, and what you do not

You get a stack named with the pull-request number, a smoke check, an expiry tag, and a function that refuses to destroy anything else. You do not get a cheaper AWS bill by definition. Read the cost article before you turn this on for every repository.

**Opinionated take:** separate state key, required tags, abort on one miss. The trade-off is a stuck preview when someone forgets a tag. A stuck preview is cheaper than a terminated production instance.

## Architecture

The pull-request job runs `tofu apply` with a backend key that includes the pull-request number, under the `preview/` prefix. Every resource gets `Environment=preview`, `PullRequest`, `ExpiresAt`, and `Stack`. The job comments the URL only after the smoke check returns. On close, and once a day, a function lists resources by tag and destroys that state key only.

```mermaid
flowchart LR
  pullRequest[PullRequest] --> apply[OpenTofuApply]
  apply --> smoke[SmokeCheck]
  smoke --> url[PreviewUrl]
  clock[DailySchedule] --> cleanup[TagGuard]
  closed[PullRequestClosed] --> cleanup
  cleanup --> destroy[DestroyPreviewState]
```

## Walkthrough

**1. State key and name are the pull-request number.** Do not reuse a workspace called `default`. The production state key is not a parameter of this job.

**2. Tags are not optional.** Context: OpenTofu `default_tags` on the AWS provider, stack name from the pull-request number.

```hcl
provider "aws" {
  default_tags {
    tags = {
      Environment = "preview"
      PullRequest = var.pull_request
      ExpiresAt   = var.expires_at
      Stack       = "preview-pr-${var.pull_request}"
    }
  }
}
```

**3. Smoke check.** HTTP GET of the preview URL must return the status you wrote down, usually 200, before the job reports the URL. A failed smoke check still leaves the stack up so the author can debug, and it must not mark the pull request green.

**4. Cleanup.** The function calls the Resource Groups Tagging API for `Environment=preview` and the stack name for that pull request. If any ARN in that batch lacks `Environment=preview`, it exits without calling destroy. The destroy command is `tofu destroy` against that state key only, with `-var` values taken from the tags, in the preview account. The role used here does not exist in the production account. If you have one account, the role's identity policy still cannot describe the production state bucket key.

A daily Scheduler schedule catches a pull request that was closed without a webhook. Same function, same guard.

## IAM

The apply role can change resources only where `aws:RequestTag/Environment` equals `preview` on create, and `aws:ResourceTag/Environment` equals `preview` on change and delete. The cleanup role is the same constraint. It can read one state-key prefix, `preview/*`. It cannot read `prod/*`.

Do not attach `AdministratorAccess` and promise the script will be careful.

## Failure and retries

A failed apply leaves a partial stack. The next push applies the same state key. Destroy is idempotent against an already empty state. If destroy fails halfway, the next daily run tries again, still under the tag guard. Do not add a second janitor with a broader role "for the leftovers".

## Cost, as an assumption

The daily check is one [Scheduler](https://aws.amazon.com/eventbridge/pricing/) invocation, inside the **14 million** free invocations. Preview EC2, NAT, and load balancers are the bill. This article does not restate the shared-staging comparison. Use the cost article's method on your own prices. Not a measured saving.

## Testing

Apply a fixture stack with the four tags. Run cleanup with a future `ExpiresAt`. Expect no destroy. Set `ExpiresAt` in the past. Expect destroy of that state only. Remove the tag from one resource in a test list and expect the function to abort with no destroy calls. Point the function at a state key named `prod/terraform.tfstate` in a unit test and expect a refusal before any AWS call.

> **What broke** — Lab, 19 October 2026. Not a customer account. The tag query used `Environment=preview` and the code destroyed every match in the account, including a stack whose `ExpiresAt` was next month, because the date filter ran after the destroy call. Detection: the test log showed destroy before the date parse. Recovery: filter the batch in memory, abort if any row fails the date or tag check, and only then destroy. Lesson: a query is not a guard if you act on the page before you read it.

## Production checklist

- Production state key is unreachable by this role.
- One missing tag aborts the batch.
- Smoke failure does not get marked as a green preview.
- Close and TTL use one function.
- Workspaces are not how production is separated from this.
- The cost article has been read before you enable this for every repository.

## What This Post Doesn't Cover

Whether ephemeral environments save money. OpenTofu migration steps. AWS Blocks sandbox versus a full deploy. Pipeline product choice.

## What to Do This Week

1. Put the tag contract in the provider block for one non-production repository.
2. Run the abort test with a missing tag.
3. Turn on the daily schedule only after that test fails closed.

Building the pipeline around this is [application modernization](https://www.factualminds.com/services/aws-application-modernization/) work when the preview URL is the blocker and the production account boundary is still fuzzy.

## FAQ

### When should we not create a preview environment per pull request?
Skip it when the stack includes a database you cannot fixture, when apply takes longer than the review, or when you have not yet decided that ephemeral is worth it. The CI/CD cost article owns that decision. A preview stack that can touch production is worse than no preview.

### What could go wrong with Terraform workspaces for production and staging?
A workspace switch applies the preview destroy to the wrong state. The command cheat sheet already says workspaces are for ephemeral branches, not for separating production from staging. This design uses a separate state key per pull request and a tag guard. It does not use a workspace named prod.

### What if a resource is missing the preview tag?
The cleanup function aborts the whole batch. It does not skip the untagged resource and destroy the rest. An untagged resource is how a shared security group gets caught in the list.

### Who deletes the environment when the pull request closes?
The close event invokes the same function as the daily TTL check. Both paths use the tag contract. Closing the pull request does not grant a broader role.

---

*Source: https://www.factualminds.com/blog/automate-ephemeral-aws-preview-environments/*
