Terraform the Easy Way
terraformdevopsinfrastructure-as-codeplatform-engineeringci-cdgithub-actions

Terraform the Easy Way

Terraform the Easy Way

Erik Osterman
byErik OstermanCEO & Founder of Cloud Posse
May 09 2026

If you read Terraform the Hard Way, you walked through twenty-one crossroads — every implicit decision Terraform leaves on your plate when you run it for real. This is the companion post. Same problems. Different answers — the kind a framework that's already made the choices can give you.

The framework here is Atmos, the one we built and the one we use ourselves. The point of this post isn't that there's only one valid framework. It's that having a well-built one — one that's already solved most of these problems out of the box — collapses each crossroad from "build it yourself" to "configure a few lines of YAML." It's only a matter of time before you cross every one of them. Here's what that looks like when the framework has already been there.

Design

In the Hard Way, design was seven crossroads of decisions you'd own forever. Under a framework, most of them become conventions you adopt and stop thinking about.

1
A repo layout you didn't have to invent

The first thing a framework gives you is a layout. Where stacks live. Where components (Atmos's name for root modules) live. Where shared modules, mixins, defaults, and overrides live. You don't juggle seven options; you adopt the convention and move on. The Atmos stack organization design pattern documents the layouts that hold up over time across teams. There's a recommended shape for a single team starting out, one for an organization with multiple environments and regions, one for a multi-tenant platform spanning many accounts, and one for multi-cloud where the same stack model spans AWS, GCP, and Azure. Pick the one that matches where you are today; the layout grows with you.

A single team, one cloud account per environment, one region. Stacks are flat files named after the environment they describe.

.
├── atmos.yaml                       # framework config (auth, toolchain)
├── components/
│   └── terraform/                   # root modules (Atmos calls them "components")
│       ├── vpc/
│       └── s3-bucket/
└── stacks/
    ├── dev.yaml
    ├── staging.yaml
    └── prod.yaml

2
Toolchain — one line per binary

The Hard Way's version of "install Terraform or OpenTofu" was a graph of versions per environment per operating system per runtime. The framework version is a few lines of stack config:

# stacks/orgs/acme/_defaults.yaml
dependencies:
  tools:
    opentofu: "1.10.3"
    terraform: "1.9.8"

Because pins live in stack config, they inherit and override like everything else in Atmos — declare a baseline at _defaults.yaml, then pin a specific component to an older version when an upgrade refactor isn't worth it yet. The framework installs the right binary, for the right OS and architecture, on every laptop and every runner. No tfenv, no tofuenv, no aqua, no asdf, no Dockerfiles full of curl commands. (See the Atmos stack dependencies docs for the inheritance rules.)

3
Auth — one YAML, two paths

Steps three and four of the Hard Way — authenticating to your cloud, then handing that auth off to downstream tools — collapse into one block. SSO for humans, OIDC for CI, identities that map both to a role:

# atmos.yaml
auth:
  providers:
    company-sso:
      kind: aws/iam-identity-center
      region: us-east-1
      start_url: https://company.awsapps.com/start
 
    github-oidc:
      kind: github/oidc
      region: us-east-1
      spec:
        audience: sts.us-east-1.amazonaws.com
 
  identities:
    - name: dev-admin
      kind: aws/assume-role
      via:
        provider: company-sso
      spec:
        role_arn: arn:aws:iam::123456789012:role/Admin
 
    - name: dev-ci
      kind: aws/assume-role
      via:
        provider: github-oidc
      spec:
        role_arn: arn:aws:iam::123456789012:role/AtmosCIRole

Same shape for both. atmos auth login from a laptop runs the SSO dance; the same identity assumed via OIDC runs in CI. No bespoke role-chaining script on either side, and nothing for a contractor to learn beyond which identity name they're allowed to assume.

The ~/.aws/config ritual disappears with it. There's no per-developer profile file to generate from a script or copy out of a wiki page — atmos.yaml is the file, it's checked into the repo, and a new hire's first day on a project is git clone, atmos auth login, run a command. Add an account or rename a role, and the change lands in a PR alongside the code that depends on it; no laptops fall behind.

4
Downstream auth — add the integration

Step four of the Hard Way was "wire up aws ecr get-login-password and aws eks update-kubeconfig into your runner so your developers and your pipelines stop chasing 401s." The framework version is two more YAML blocks:

# atmos.yaml
auth:
  integrations:
    dev/ecr:
      kind: aws/ecr
      via:
        identity: dev-admin
      spec:
        registry:
          account_id: "123456789012"
          region: us-east-1
 
    dev/eks:
      kind: aws/eks
      via:
        identity: dev-admin
      spec:
        cluster:
          name: dev-cluster
          region: us-east-1
          alias: dev

After atmos auth login, docker push and docker pull work against ECR. kubectl works against the EKS cluster. The exec plugin handles short-lived token refresh in the background. The Atmos team has tutorials for ECR and for EKS kubeconfig that walk through the rest.

5
State backend — provisioned natively

The Hard Way's state-backend bootstrap was the chicken-and-egg story: Terraform needs an S3 bucket that it can't create until you've initialized Terraform. Atmos handles this directly. There's no tfstate-backend Terraform module to deploy first, no CloudFormation template, no bootstrap script — the backend is a first-class concept built into the Atmos binary.

You declare the backend once in your stack config, alongside a provision block on the stack that owns it:

# stacks/orgs/acme/_defaults.yaml
terraform:
  backend_type: s3
  backend:
    s3:
      bucket: my-state-bucket
      region: us-east-2
      encrypt: true
      use_lockfile: true

Atmos creates the bucket and the encryption key — just enough to break the chicken-and-egg. On the very next apply, Terraform's import blocks pick those two resources up and take over the rest of their lifecycle natively: versioning, encryption settings, access policies, lifecycle rules, all the steady-state knobs you'd want a Terraform-managed bucket to have. Bootstrap and steady-state ownership happen in one shot, and from then on the backend is just another component the framework manages alongside everything else. The setup isn't a separate ceremony with its own tool; it's stack config like everything else.

(Generating the matching backend.tf files for downstream components is a different concern. Atmos manages those automatically as part of every component's terraform init. So you declare the backend once and never write backend.tf by hand again.)

6
Configuration and templating

Configuration flow and the templating examples I led with in the Hard Way are all managed as stack configuration — configuration, kept as configuration, not as code or shell. Defaults at the org level. Per-environment overrides. The pain point I called out in the Hard Way isn't a pain point under a framework that generates the file in the first place.

But that's just the floor. Atmos's stack config is itself a Go template, with access to every value in the merged stack — .vars, .settings, .component, .stack, .workspace, environment variables, and the full sprig function library. That means you can generate any code you need directly in stack config — no cookiecutter, no envsubst, no Jinja step in CI, no separate templating tool to keep alive. The Hard Way's "reach for a templating tool" crossroad collapses into the framework you already have.

A concrete example — generating a per-component versions.tf with a stack-templated provider version pin:

# stacks/orgs/acme/_defaults.yaml
components:
  terraform:
    vpc:
      vars:
        aws_provider_version: "~> 5.60"
      generate:
        # File key is the path Atmos writes inside the component directory.
        # The literal block shows the body exactly as it lands on disk.
        versions.tf: |
          terraform {
            required_version = ">= 1.9.0"
            required_providers {
              aws = {
                source  = "hashicorp/aws"
                version = "{{ .vars.aws_provider_version }}"
              }
            }
          }

Atmos renders this per stack, writes the resulting versions.tf next to the component, and terraform init picks it up. The same generate: section can emit a locals.tf, a backend.tf.json, a README, or any other file your component needs — keyed by filename, templated against the merged stack. The generation lives where the data lives: in stack config. (See code generation in stack config.)

7
Tagging the Easy Way

In the Hard Way, tagging was step seven of design — define a standard set, apply it in every root module, keep them in sync, and pray. The teams that hit the wall hardest end up reaching for a code-generation tool like yor to inject tags into their HCL; most others get by with a shared tags module and PR-template reminders that catch new components two-thirds of the time. The Easy Way collapses both into a few lines of stack config. There are two patterns, and most real codebases use both.

Atmos has provider generation as a first-class concept, and the AWS provider's default_tags is the natural attachment point for "every resource gets these tags." Declare the AWS provider once at the org defaults level, and Atmos drops the matching providers_override.tf.json next to every terraform component:

# stacks/orgs/acme/_defaults.yaml
terraform:
  providers:
    aws:
      region: "{{ .vars.region }}"
      default_tags:
        tags:
          atmos_component: "{{ .atmos_component }}"
          atmos_stack: "{{ .atmos_stack }}"
          atmos_manifest: "{{ .atmos_stack_file }}"
          terraform_workspace: "{{ .workspace }}"
          git_sha: '{{ env "GITHUB_SHA" | default "local" }}'

Every resource the AWS provider touches gets tagged automatically. No module-level wiring, no tags = var.tags boilerplate on every resource, no rewriter tool injecting tags into your code. Override values per environment by dropping them into stack files lower in the inheritance tree.

Most teams use both: inheritance for the values, provider generation so the values get applied to every resource without every module having to opt in.

Build

In the Hard Way, build was nine more crossroads — most of them tools you'd adopt, wrap, or write to make Terraform behave like a system instead of a CLI. Under a framework, almost all of them are stack configuration instead of new tooling.

What is a root module?

A root module is the directory Terraform runs in — it owns the state file and calls child modules via module "x" { source = "..." }. Terraform can fetch a child module from Git or a registry; it cannot fetch the root module itself, and even for the children it can fetch, terraform init re-fetches them every run.

Atmos's source: field below looks similar to a Terraform module block, but it isn't the same thing. Atmos is sourcing a root module — the thing Terraform can't source remotely on its own. That's the gap the Hard Way's step 10 is built around.

10
Reference remote root modules from stack config

Hard Way step ten was "Terraform can't pull a remote root module, and re-fetches every child module on every init — so you need your own story for getting remote module source onto disk reproducibly." Atmos has a feature called source provisioning that lets you reference a remote root module directly from stack config — no separate vendor step, no Git submodule, no git subtree, no fetcher script before init. Atmos fetches the source per stack, runs Terraform inside its workdir, and the rest of the workflow is identical to a local component.

# stacks/orgs/acme/dev/us-east-1.yaml
vars:
  stage: dev
 
components:
  terraform:
    vpc:
      source:
        uri: "github.com/cloudposse/terraform-aws-vpc.git"
        version: "2.2.0"
      provision:
        workdir:
          enabled: true
      vars: ...
atmos terraform plan vpc -s dev

This fetches the module at the pinned version, runs init and plan inside Atmos's workdir, and writes its plan and state the same way it would for a local component. Pin version to a tag or commit SHA for reproducibility; bump it like any other dependency.

Prefer a local copy you can diff in PRs? Define the same modules in a top-level vendor.yaml and run atmos vendor pull — Atmos writes the source into your tree as committed code. It's a separate mechanism from the source: reference above, with its own config file; teams reach for source provisioning by default and vendor when they specifically want the module code in git history.

13
Running it — one command, with prompts

The Hard Way's discoverability step was "to run a single terraform plan you have to know which binary version, which folder, which flags, which -var-file order, which workspace — and you have to have already installed the tools and authenticated." Easy Way:

atmos terraform plan

That's it. Hit enter and the framework does the rest. It installs the version of Terraform or OpenTofu the stack pins (per step 2 of the Hard Way). It runs the cloud-auth flow (per step 3). It composes the config layers (per step 6). And then — because you didn't tell it what to plan — it asks you. Which component? vpc. Which stack? prod-ue2. Hit enter again. The plan runs.

Specify the component and stack inline (atmos terraform plan vpc -s prod-ue2) and the prompts go away. Either way, the canonical invocation is in the system, not in someone's shell history. New hires don't memorize a Makefile; they learn one verb. Documentation collapses to "run atmos terraform plan and answer the prompts."

This is the part of a framework that's hard to convey on paper but disproportionately changes the day-to-day. Every other step in this post saves you a build-out. This one saves you the cognitive overhead of running infrastructure code at all.

11, 14–17
Keep CI Boring

The Hard Way burned five crossroads on the CI loop alone — git-aware change detection, a readable job summary, a sticky PR comment, the Deployments API, and piping Terraform outputs to downstream steps. Each one is a small piece of ergonomics, and each typically gets resolved by reaching for another third-party GitHub Action and pinning it to a SHA. Easy Way collapses all five into the same command you already run locally:

# .github/workflows/deploy.yml
deploy-dev:
  needs: [test, build]
  runs-on: ["ubuntu-latest"]
  container:
    image: ghcr.io/cloudposse/atmos:${{ vars.ATMOS_VERSION }}
  name: deploy / dev
  environment:
    name: dev
    url: ${{ steps.deploy.outputs.output_url }}
  defaults:
    run:
      shell: bash
  steps:
    - name: Checkout
      uses: actions/checkout@v6
 
    - name: Deploy Service
      id: deploy
      run: |
        atmos terraform apply app -s dev

Count the third-party Actions in that snippet. One: actions/checkout, published by GitHub themselves. The rest is a published container image and a single CLI invocation. Compare to Hard Way steps 14–17, where the same surface area accreted a dozen pinned Actions, each one a fresh maintainer to audit and a fresh entry on your supply-chain surface — exactly the shape that produced CVE-2025-30066 in March 2025.

The command in that last step is byte-for-byte the one a developer would run locally — local reproducibility by design, not as a side benefit. The same binary, in a non-CI shell, just runs the apply — but in CI, that single line is doing the work of a dozen pinned Actions.

What's in a command? Just by running that one line — atmos terraform plan — the following all happen:

  1. Authenticate — exchange OIDC for short-lived cloud credentials
  2. Install the toolchain — place the pinned Terraform or OpenTofu version on the path
  3. Clone the root module — pull the remote root module onto disk, if configured
  4. Generate code — render provider blocks, default_tags, and any per-stack templating
  5. Provision the backend — create the state bucket on first run and write the backend config
  6. Run init, then plan or apply — execute terraform init and the actual plan or apply
  7. Expose outputs — surface Terraform outputs as step outputs for downstream jobs (no jq, no GITHUB_OUTPUT plumbing)
  8. Store artifacts — save the plan file as a workflow artifact for the apply job to consume
  9. Write the job summary — render a readable plan-and-apply summary to the job summary tab
  10. Comment on the PR — post a sticky plan summary, upserting on subsequent pushes
  11. Record the deployment — create a GitHub Deployment when the stack lands an environment URL
  12. Update the check run — open a GitHub status check and update it on success or failure

Same command, different superpowers depending on context. Not all commands are designed the same — every bullet above is table stakes for running Terraform in CI, and every bullet you don't have, you assemble yourself out of pinned third-party Actions and shell shims.

The CI behavior itself is one block in atmos.yaml:

# atmos.yaml
ci:
  enabled: true # auto-detected; explicit for clarity
  output: { enabled: true } # surface outputs to downstream jobs
  summary: { enabled: true } # readable job summary
  checks: { enabled: true } # GitHub status checks
  comments: { enabled: true, behavior: upsert } # sticky PR comments

Atmos auto-detects GitHub Actions from the CI env vars and posts checks, comments, summaries, and outputs to the right surface — no per-surface plumbing in your workflow.

And the rest of the chapter

The remaining build crossroads collapse the same way:

  • Decomposition is the default; components are small by design and state sharing across them — and across workload repos — is conventional.
  • Templating is built in. Multi-region deployments share a single component with per-stack inputs. Provider blocks that vary per environment are normal.

Each one is a few lines of YAML, not a new pipeline you keep alive.

Operate

18
Inventory — a CLI

Hard Way step eighteen was "you'll want a CLI that can list components, list stacks, and describe the composed config." Easy Way:

atmos list components
atmos list stacks
atmos describe component vpc -s prod-ue2
atmos describe affected

That's the CLI. There's nothing to write — because nothing's hidden in a folder hierarchy that the CLI has to grep its way through. The framework isn't overloading the filesystem as a database. Everything is a declarative YAML data model of your architecture: tools can read it, agents can introspect it, humans can diff it.

19
Operator playbooks — custom commands

The playbook problem — Makefile versus Justfile versus go-task versus shell, parameter passing, cross-OS reliability — is solved by atmos custom commands. A real-world example: an app developer needs to populate a handful of SSM parameters and upload a fixture file to S3 to bootstrap a new feature branch. Instead of "ask the platform team" or "follow these eight commands in the wiki," it's one command:

# atmos.yaml
commands:
  - name: seed-fixtures
    description: Populate SSM parameters and upload fixture data
    arguments:
      - name: branch
        description: Feature branch slug
    flags:
      - name: stack
        shorthand: s
        description: Atmos stack to seed
        required: true
    steps:
      - >-
        aws ssm put-parameter
          --name "/app/feature/{{ .Arguments.branch }}/db-host"
          --value "feature-{{ .Arguments.branch }}.internal"
          --type String --overwrite
      - >-
        aws s3 cp ./fixtures/seed.json
          "s3://app-fixtures/feature/{{ .Arguments.branch }}/seed.json"

The playbook lives in YAML. Arguments pass cleanly. The command runs the same way on Mac, Linux, and Windows because Atmos handles dispatch.

The two preflight things every other playbook tradition forgets are handled automatically. Tool installation: any binary the command needs (here aws) is installed against the version pinned in a top-level .tool-versions file, so it's on the path before step one runs. No make ensure-tools ritual, no "works on my laptop because I happen to have v2.13 installed." Authentication: Atmos resolves the stack's auth identity and exchanges short-lived credentials before the command's steps execute, so the playbook never has to start with aws sso login or chase a 401 mid-script.

New hires run one command:

atmos seed-fixtures my-branch -s dev

They get a working environment without reading README sections about which Makefile target corresponds to which environment, and without installing anything or logging into anything first.

8
Cold-start automation — workflows

Atmos workflows chain components together to automate cold starts. Seed the org, prime the IAM roles, deploy the network, deploy the cluster — in the right order, with the right credentials, in one command:

# stacks/workflows/cold-start.yaml
name: Cold Start
description: Stand up a fresh environment from zero
workflows:
  bootstrap:
    description: Stand up the foundational components in dependency order
    steps:
      - command: terraform apply account-map -s core-ue2-root -auto-approve
      - command: terraform apply iam-roles -s core-ue2-identity -auto-approve
      - command: terraform apply vpc -s plat-ue2-dev -auto-approve
      - command: terraform apply eks -s plat-ue2-dev -auto-approve

Run it with:

atmos workflow bootstrap -f cold-start

The same workflow runs on a developer's laptop and in CI; the same ordering is enforced everywhere. The Friday-night cold-start ritual stops being a ritual.

20
Docs that update themselves

The Hard Way's documentation crossroad split into two pipelines: hand-written architecture docs and a generated reference table from terraform-docs. The Easy Way collapses the generator half into atmos docs generate — built into the framework binary, one way to do it, not another tool to install, version, vet, or wire into its own pre-commit hook.

The generator reads your Terraform directly: variables, outputs, providers, resources, and submodules. It merges that introspection with whatever architectural context you keep in a README.yaml and renders the result through a Go template. The output is a fully documented component README — reference table, examples, prerequisites, gotchas, and how the component fits into the stack model — generated from a single source. Not a half-documented module with an auto-generated table glued onto a stale narrative; one render, one artifact.

Declare the generator once in atmos.yaml:

# atmos.yaml
docs:
  generate:
    readme:
      input:
        - "./README.yaml"
      template: "https://raw.githubusercontent.com/cloudposse/.github/main/README.md.gotmpl"
      output: "./README.md"
      terraform:
        source: "src/"
        enabled: true

From any component directory, run:

atmos docs generate readme

The README updates in place. It's the same terraform-docs library — linked directly into the atmos binary — so there's nothing extra to install or version. Wire it into pre-commit the same way you would terraform-docs itself; the hook is still doing the enforcement, the binary's just already there.

And because the framework already has a structured model of every stack and component, system-level documentation comes for free: atmos describe stacks, atmos describe affected, and atmos describe config are queryable, living introspection — the architecture half of the Hard Way's documentation problem, answered by the same tool, no separate publishing pipeline.

21
Drift detection and reconciliation — Atmos Pro

Detection is the easy half: atmos terraform plan on a schedule with a diff check — the same plan command you already run, scheduled.

The harder half — reconciliation — is what Atmos Pro handles. It runs drift checks across every stack on a cadence you set, surfaces each drift in a dashboard with PR-style review, and lets you decide per-component whether reconciliation is auto-applied, gated behind approval, or just a notification with an audit trail. The detection mechanic is still atmos terraform plan; Atmos Pro is what turns the result into a workflow that closes the loop instead of just naming the gap.

The fleet view is the part teams underestimate until they need it. The question pro teams ask isn't "is this stack drifting?" — it's which stacks are drifting right now, which have been perma-drifting for weeks, which workflows are failing and how often, and what change to a given stack correlates with the regression that just paged someone. That's change-failure-rate-and-MTTR applied to infrastructure — the DORA layer most teams never get to because the Hard Way to it is OpenTelemetry from GitHub Actions piped into Prometheus, Grafana, or Datadog, with hand-built dashboards and stack-and-component labels you keep clean across hundreds of workflow runs. The Easy Way is that the framework already has a structured model of every stack, component, and run — so Atmos Pro renders that view as a product instead of asking you to build it.

This is the capstone of the Easy Way — the step that makes everything above keep working over time without anyone having to remember to look.

What Changed

If you stack the Easy Way next to the Hard Way side by side, the work didn't disappear. Auth still has to happen. State still has to be bootstrapped. Drift still has to be detected. Playbooks still have to exist for the people who consume what you provisioned.

What changed is that the answers stopped being one-off scripts and abandoned third-party Actions and in-house pipelines, and started being a few lines of declarative YAML inside a framework that already made the choice. The fourth option from the Hard Way's closing — pick one tool that handles the whole thing as a coherent set — is the option this post showed.

The Tradeoff

In the end, the choice is yours.

You can have thousands of lines of shell scripts cobbled together, with no automated tests, no infrastructure-level validation, no shared conventions — left up to every team to implement themselves. You can pin dozens of untrusted third-party GitHub Actions and accept the supply-chain attack surface that comes with them. You can rebuild the same orchestration in-house every time a team rotates.

Or you can replace all of that with a few hundred lines of YAML — declarative, maintainable, easy to document and understand, and legible to every AI agent out there because it's a data model, not a maze of folders and scripts.

Atmos is the framework we built, and it's the one we use ourselves. It's not the only valid choice; if your team already has one that works, keep using it. The recommendation isn't a tool — it's a posture. Every crossroad in the Hard Way that you're treating as a separate decision your team owns is a crossroad a framework can take off your plate.

If you're looking at those twenty-one crossroads and want a second set of eyes on which ones to consolidate first, let's talk.

Get DevOps insights delivered to your inbox

Subscribe to the Production Ready newsletter.

Erik Osterman
Erik Osterman
CEO & Founder of Cloud Posse
Founder & CEO of Cloud Posse. DevOps thought leader.
Book a Meeting

Share This Post

Related Posts

Continue reading with these featured articles

Terraform the Hard Way

Build Your IDP Last

Native Terraform Myth

The Production Ready Newsletter

Build Smarter. Avoid Mistakes. Stay Ahead of DevOps Trends That Matter.

Turn SOC 2 controls into code and evidence into automation.

For Developers

  • GitHub
  • Documentation
  • Quickstart Docs
  • Resources
  • Read our Blog

Community

  • Join Office Hours
  • Join the Slack Community
  • DevOps Podcast
  • Try our Newsletter

Company

  • Services & Support
  • AWS Migrations
  • Pricing
  • Book a Meeting
  • Media Kit

Legal

  • Terms of Use
  • Privacy Policy
  • Disclaimer
  • Cookie Policy
Copyright ©2026 Cloud Posse, LLC. All rights reserved.