# Planfile Storage

import Intro from '@site/src/components/Intro'
import File from '@site/src/components/File'
import ActionCard from '@site/src/components/ActionCard'
import PrimaryCTA from '@site/src/components/PrimaryCTA'

<Intro>
Planfile storage enables the plan-then-deploy workflow in CI. When configured, `atmos terraform plan`
uploads planfiles to storage, and `atmos terraform deploy` downloads and verifies them before applying.
</Intro>

## Configuration

<File title="atmos.yaml">
```yaml
components:
  terraform:
    planfiles:
      # Drift verification on `deploy`: fail (default under CI) | warn | off
      verify: fail

      # Select the first available store in priority order
      priority:
        - "github"
        - "s3"
        - "local"

      # Named stores
      stores:
        github:
          type: github/artifacts
          options:
            retention_days: 7

        s3:
          type: aws/s3
          # Optional: authenticate the store with a specific Atmos Auth identity,
          # independent of the component's identity (see Store Identity below).
          identity: aws-backend
          options:
            bucket: "my-terraform-planfiles"
            prefix: "atmos/"
            region: "us-east-1"

        local:
          type: local/dir
          options:
            path: ".atmos/planfiles"
```
</File>

An explicit `default` store takes precedence over `priority`. Otherwise, the first
available configured name in `priority` is selected; names absent from `stores` are skipped.
GitHub artifacts require a token. For plan uploads, priority selection also requires
`ACTIONS_RUNTIME_TOKEN` and `ACTIONS_RESULTS_URL`; downloads from completed runs
can use the GitHub API token alone. S3 uses the AWS credential chain or the selected
Atmos identity. These availability checks do not verify remote bucket permissions.
An unknown explicit default, an exhausted priority list, or a selected store that
cannot be initialized produces an error. Atmos does not silently redirect planfiles
to a different backend after selection. Environment-based detection is used only
when neither `default` nor `priority` is configured.

## Storage Backends

| Backend | Type | Best For |
|---------|------|----------|
| **GitHub Artifacts** | `github/artifacts` | GitHub Actions workflows (recommended) |
| **S3** | `aws/s3` | AWS-native environments, cross-provider workflows |
| **Local** | `local/dir` | Testing and development |

## Store Identity

By default, a planfile store authenticates with the component's active identity - the same one
`atmos terraform plan` used for the Terraform run. That works when the component and the store live in
the same cloud, but it breaks in a multi-cloud setup: a component that authenticates with, say, an
`azure/subscription` identity cannot produce AWS credentials for an `aws/s3` planfile bucket, so the
upload fails to resolve an AWS auth context.

Set `identity` on a store to authenticate it with a specific [Atmos Auth identity](/cli/configuration/auth)
(from `auth.identities`), independent of the component's identity:

<File title="atmos.yaml">
```yaml
auth:
  identities:
    # Non-AWS identity the component authenticates with.
    azure-plan:
      kind: azure/subscription
      default: true
      # ...

    # AWS-capable identity the planfile store uses.
    aws-backend:
      kind: aws/assume-role   # or ambient, aws/permission-set, etc.
      # ...

components:
  terraform:
    planfiles:
      default: "s3"
      stores:
        s3:
          type: aws/s3
          identity: aws-backend    # store authenticates with aws-backend
          options:
            bucket: "my-terraform-planfiles"
            region: "us-east-1"
```
</File>

The component keeps using its own identity (`azure-plan`) for Terraform operations, while the S3 planfile
store uses `aws-backend` for storage. When `identity` is omitted, the store inherits the component's
identity (the prior behavior), so existing single-cloud setups are unchanged.

The `identity` must be able to authenticate the chosen backend: an `aws/s3` store needs an AWS-capable
identity. It is not limited to `ambient`, though - any AWS-capable identity kind or chain
(`aws/assume-role`, `aws/permission-set`, …) is resolved through the same Atmos Auth chain as the component,
not just passed through from the runner environment. This mirrors the
[`stores.*.identity`](/cli/configuration/stores) field already supported by general Atmos stores.

:::tip
If `components.terraform.planfiles` is not configured, planfile storage operations are silently
skipped. CI summaries and status checks still work without planfile storage.

The one exception: explicitly requesting verification with `--verify-plan` (or
`ATMOS_TERRAFORM_VERIFY_PLAN=true`) errors when planfile storage is not configured — there is no
stored plan to verify against, and the flag cannot stand in for the storage settings it depends on.
:::

## Using GitHub Artifacts in GitHub Actions

The `github/artifacts` backend talks to the **GitHub Actions Artifacts API directly** — Atmos is
the native client, not a wrapper around `actions/upload-artifact`. To do that it needs the runner's
runtime credentials, `ACTIONS_RUNTIME_TOKEN` and `ACTIONS_RESULTS_URL`.

GitHub injects those into **action steps** (`uses:`) but **withholds them from `run:` steps** — a
deliberate least-privilege decision. So when Atmos's own commands do the upload from a `run:` step,
they will fail with:

```
GitHub Artifacts upload requires running within GitHub Actions
(ACTIONS_RUNTIME_TOKEN and ACTIONS_RESULTS_URL must be set)
```

Surface the credentials with the Atmos [`github-runtime`](https://github.com/cloudposse/atmos/tree/main/actions/github-runtime)
action. With `mode: env` it exports the credentials to `$GITHUB_ENV` once, and GitHub then injects
them automatically into every later `run:` step — Atmos's commands pick them up from the environment
with no per-step wiring:

<File title=".github/workflows/atmos.yaml">
```yaml
steps:
  - uses: cloudposse/atmos/actions/github-runtime@v1   # pin to a release or SHA
    with:
      mode: env

  # Credentials are now in the environment of every later run step.
  - run: atmos terraform plan mycomponent -s prod --ci    # uploads the planfile to github/artifacts
  - run: atmos terraform deploy mycomponent -s prod --ci  # downloads, verifies, then applies
```
</File>

To keep the credentials out of unrelated steps, use the default `mode: output` instead: the action
emits masked step outputs that you thread, via `env:`, only into the steps that store planfiles. This
is the same `github-runtime` mechanism the `atmos ci cache` backend uses — see the
[cache configuration docs](/cli/configuration/ci/cache) for the full security tradeoff between
scoped (`mode: output`) and ambient (`mode: env`) credentials.

<File title="Least-privilege alternative (mode: output)">
```yaml
steps:
  - uses: cloudposse/atmos/actions/github-runtime@v1
    id: ghr
  - run: atmos terraform plan mycomponent -s prod --ci
    env:
      ACTIONS_RUNTIME_TOKEN: ${{ steps.ghr.outputs.runtime-token }}
      ACTIONS_RESULTS_URL:   ${{ steps.ghr.outputs.results-url }}
```
</File>

:::note
You only need this when **Atmos** performs the upload from a `run:` step. The credentials are already
present inside `uses:` action steps, and the `local/dir` and `aws/s3` backends don't use them at all.
:::

## How It Works

### Plan Phase

When `atmos terraform plan` runs in CI mode with planfile storage configured:

1. Terraform generates a binary planfile
2. The planfile is bundled with the lock file into a tar archive
3. SHA256 integrity checksums are computed
4. The bundle is uploaded to the configured storage backend

### Deploy Phase

When `atmos terraform deploy` runs in CI mode with planfile storage configured, verification is
**automatic**:

1. Downloads the stored planfile bundle from storage
2. Verifies SHA256 integrity checksums
3. Generates a fresh plan against current infrastructure
4. Performs JSON-structural comparison between stored and fresh plans
5. **If plans match:** Applies the **fresh** plan (generated with the apply-time identity and state)
6. **If plans drift:** Fails the deploy (mode `fail`) or warns and proceeds (mode `warn`)

Atmos reconciles rather than replays: it applies the freshly generated plan once the diff confirms it
matches what was reviewed, so the deploy uses the apply-time state and credentials while still
guaranteeing no material drift. This avoids the brittleness of a saved plan — which goes stale when the
state moves, and whose base credentials come from the apply environment, not the plan (so a plan built
on a PR can fail to apply on merge). (To replay the stored binary directly, use `--from-plan` /
`--planfile`.) Verification runs on `deploy` only — `apply` stays a thin wrapper.

### Deploying across pull requests and merge queues {#deploying-after-a-pull-request-merges}

Stored plans are keyed by the commit that was checked out when they were created. That may be a PR
head, a synthetic PR merge commit, or a merge-queue commit. A subsequent deploy can check out a
different SHA even when the source files are identical. This applies to ordinary GitHub Actions
workflows as well as workflows dispatched by Atmos Pro.

On GitHub Actions, `deploy` resolves the stored plan in this order:

1. **The deploy's exact commit.** An existing plan for that SHA always wins, without additional
   source or PR lookups.
2. **The same Git source tree.** Automatic plan uploads also publish a small, repository-scoped
   reference to the original plan, keyed by the source tree. This lets a plan created on a synthetic
   PR or queue commit be found after squash, merge, or rebase when the landed commit has identical
   source contents. The original artifact must still exist; deleting or expiring it invalidates the
   reference. These references are not additional plans in `atmos terraform planfile list`.
3. **The associated PR revision.** For a post-merge deploy, Atmos resolves the PR whose
   `merge_commit_sha` equals the checked-out SHA. For a `merge_group` run, it resolves the PR head
   only when GitHub's active queue entry still identifies that exact queue commit. For a PR event,
   it uses the head SHA from the event snapshot only when the checkout matches that head or the
   event's synthetic merge commit. It does not use an arbitrary PR's latest head.

Only an artifact-not-found result permits trying another source. Permission errors, corrupt
artifacts, and other storage failures do not select another plan. Ambiguous post-merge PR matches
are rejected. Lookup failures are logged and the existing missing-plan policy applies.

Whichever source is selected, deploy generates a fresh plan for its own checkout and compares it
with the stored plan using the configured plan-diff policy. In `fail` mode, differences block apply;
`warn`, `off`, and `required` keep their documented behavior. Plan equivalence does not replace any
workflow approval or deployment authorization requirements.

#### Merge queues with multiple pull requests

A queue commit can contain several PRs. An individual PR plan is usable only if it matches the fresh
plan for that stack/component in the combined checkout. If several PRs change the same component and
the combined plan differs, generate and review a plan on the queue commit. Atmos does not combine
individual planfiles or try different plans until one passes verification.

Rebuilt or removed queue entries cannot supply a newer PR-head plan to an old queue checkout.
Source-tree references are published by automatic uploads with this version of Atmos; older plans
remain available through their original commit keys and PR-head fallback. Other CI providers retain
their existing behavior unless they implement source resolution. Storage-disabled workflows do not
perform these lookups.

The GitHub token needs read access to source commits and pull requests:

```yaml
permissions:
  contents: read
  pull-requests: read
```

:::note GitHub Actions runtime token
With the `github/artifacts` store, the automatic download (like the upload) talks to the GitHub
Artifacts runtime API, so `deploy` must also run after the
[`github-runtime`](https://github.com/cloudposse/atmos/tree/main/actions/github-runtime) action
surfaces `ACTIONS_RUNTIME_TOKEN` / `ACTIONS_RESULTS_URL` — see
[Using GitHub Artifacts in GitHub Actions](#using-github-artifacts-in-github-actions) above.
:::

## Plan Verification

Verification compares the **JSON plan structures** (`terraform show -json`) of the stored and fresh
plans — not a naive text diff — detecting meaningful changes while ignoring cosmetic noise (sorted keys,
masked sensitive values, skipped computed-hash attributes and data sources).

This semantic comparison is deliberate. A plan legitimately contains values that vary between review and
apply — attributes "known after apply," computed fields, hashes, ordering, timestamps. A byte-for-byte
check (or Terraform's own saved-plan apply, which any state-lineage change invalidates) would reject a
plan that is still perfectly valid. Verification needs **wiggle room**: tolerate benign variation while
still catching substantive drift — a resource added, removed, or actually changed.

It is configurable via `components.terraform.planfiles.verify`:

```yaml
components:
  terraform:
    planfiles:
      verify: fail   # fail (default under CI) | warn | off
```

- **`fail`** (default under CI): fail the deploy on drift.
- **`warn`**: log the drift but proceed with the fresh plan.
- **`off`**: skip verification (and the stored-plan download).

A companion boolean, `components.terraform.planfiles.required`, governs whether a stored plan **must
exist** to verify against. It defaults to tracking `verify` strictness (required when verification
resolves to `fail`), so a fail-by-default CI deploy fails loudly instead of silently applying an
unverified fresh plan. See [Missing stored plan](/components/terraform/planfiles#missing-stored-plan).

Per-run overrides: `--verify-plan` (force `fail`) and `--verify-plan=false` (force `off`); the CLI flag beats
config, which beats the CI default. See
[`atmos terraform deploy`](/cli/commands/terraform/deploy#automatic-plan-verification-in-ci) and
[Planfile drift verification](/components/terraform/planfiles#drift-verification).

## CLI Commands

<ActionCard title="Planfile Commands">
    Manage stored planfiles with upload, download, list, delete, and show commands.
    <div>
      <PrimaryCTA to="/cli/commands/terraform/planfile">Planfile Reference</PrimaryCTA>
    </div>
</ActionCard>

## Related

- [Native CI Overview](/ci) - Feature overview
- [CI Cache Configuration](/cli/configuration/ci/cache) - The same `github-runtime` credential mechanism, in depth
- [CI Configuration](/cli/configuration/ci) - Full configuration reference
- [`atmos terraform planfile`](/cli/commands/terraform/planfile) - CLI commands
