Skip to main content

Create Atmos Stacks

In the previous steps, we configured the Terraform components and started the local sandbox.

Next step is to create and configure Atmos stacks — the YAML configuration that tells Atmos how each component should be provisioned in each environment. This is the heart of the tutorial, and it's where most of the Atmos design patterns show up.

Create Catalog for Components

Atmos supports the Configuration Catalog pattern to define default settings for Atmos components. All the common default settings for each component go into a separate file under the stacks/catalog directory, which is then imported into the parent Atmos stacks. This keeps the stack configurations DRY by reusing the component config that's common to every environment.

Refer to Stack Imports for more details on Atmos imports.

kms-key

The KMS key encrypts every other resource, so it's the root of the dependency graph. Notice there is no per-component identity binding: every component runs under the default identity (local-aws, declared with default: true in atmos.yaml), so it reaches the local sandbox automatically. To run a component under a different identity you'd use the --identity flag or a component-level auth section — not a settings field.

components:
terraform:
kms-key:
metadata:
component: kms-key
description: "Customer-managed KMS key that encrypts the application backend."
vars:
name: app
enable_key_rotation: true

s3-bucket

The bucket is server-side encrypted with the KMS key. Two patterns appear here for the first time:

  • dependencies.components declares that the bucket depends on kms-key, so Atmos deploys the key first (see Component Dependencies).
  • Stack name templates build predictable resource coordinates from namespace, tenant, environment, and stage, so the full graph can be planned before first-run state exists.

It also declares validation (covered in Configure Validation) and a hooks block that publishes the bucket's coordinate to the config/ssm store after apply (covered under service discovery below).

components:
terraform:
s3-bucket:
metadata:
component: s3-bucket
description: "Encrypted S3 bucket for application assets."
settings:
validation:
validate-s3-bucket-with-jsonschema:
schema_type: jsonschema
schema_path: "s3-bucket/validate-s3-bucket-component.json"
description: Validate 's3-bucket' variables using JSON Schema
check-s3-bucket-with-opa-policy:
schema_type: opa
schema_path: "s3-bucket/validate-s3-bucket-component.rego"
module_paths:
- "catalog/constants"
description: Check 's3-bucket' configuration using OPA policy
timeout: 10
dependencies:
components:
- name: kms-key
hooks:
publish-coordinates:
events:
- after-terraform-apply
kind: store
name: config/ssm
outputs:
bucket_id: .bucket_id
vars:
name: assets
versioning_enabled: true
kms_key_arn: "alias/{{ .vars.namespace }}-{{ .vars.tenant }}-{{ .vars.environment }}-{{ .vars.stage }}-app"

dynamodb-table

The DynamoDB table has no dependencies of its own; it just publishes its table name to the config/ssm store after apply.

components:
terraform:
dynamodb-table:
metadata:
component: dynamodb-table
description: "DynamoDB table for application state."
hooks:
publish-coordinates:
events:
- after-terraform-apply
kind: store
name: config/ssm
outputs:
table_name: .table_name
vars:
name: state
hash_key: id

sns-topic

The SNS topic is KMS-encrypted with the same deterministic key alias and depends on kms-key.

components:
terraform:
sns-topic:
metadata:
component: sns-topic
description: "SNS topic for application events."
dependencies:
components:
- name: kms-key
hooks:
publish-coordinates:
events:
- after-terraform-apply
kind: store
name: config/ssm
outputs:
topic_arn: .topic_arn
vars:
name: events
kms_key_arn: "alias/{{ .vars.namespace }}-{{ .vars.tenant }}-{{ .vars.environment }}-{{ .vars.stage }}-app"

sqs-queue

The queue subscribes to the SNS topic. It uses the stack-derived topic ARN and depends on sns-topic, so the graph still deploys the topic first.

components:
terraform:
sqs-queue:
metadata:
component: sqs-queue
description: "SQS queue subscribed to the events topic."
dependencies:
components:
- name: sns-topic
hooks:
publish-coordinates:
events:
- after-terraform-apply
kind: store
name: config/ssm
outputs:
queue_url: .queue_url
vars:
name: events
topic_arn: "arn:aws:sns:{{ .vars.region }}:000000000000:{{ .vars.namespace }}-{{ .vars.tenant }}-{{ .vars.environment }}-{{ .vars.stage }}-events"

app-config

The app-config component ties everything together. It depends on all five resources, uses stack-derived coordinates for the values it publishes, and pulls two secrets with the !secret function. Keeping the coordinates template-based lets atmos terraform deploy --all build the full graph on the first run, before any Terraform state or store values exist.

components:
terraform:
app-config:
metadata:
component: app-config
description: "Publishes resolved app configuration and secrets to SSM Parameter Store."
dependencies:
components:
- name: kms-key
- name: s3-bucket
- name: dynamodb-table
- name: sns-topic
- name: sqs-queue
secrets:
vars:
API_KEY:
description: "Third-party API key for the application."
store: secrets/ssm
required: true
DB_CONFIG:
description: "Structured database credentials (JSON) stored in Secrets Manager."
store: secrets/asm
required: true
vars:
name: app
kms_key_arn: "alias/{{ .vars.namespace }}-{{ .vars.tenant }}-{{ .vars.environment }}-{{ .vars.stage }}-app"
bucket_id: "{{ .vars.namespace }}-{{ .vars.tenant }}-{{ .vars.environment }}-{{ .vars.stage }}-assets"
table_name: "{{ .vars.namespace }}-{{ .vars.tenant }}-{{ .vars.environment }}-{{ .vars.stage }}-state"
topic_arn: "arn:aws:sns:{{ .vars.region }}:000000000000:{{ .vars.namespace }}-{{ .vars.tenant }}-{{ .vars.environment }}-{{ .vars.stage }}-events"
queue_url: "http://sqs.{{ .vars.region }}.localhost.localstack.cloud:4566/000000000000/{{ .vars.namespace }}-{{ .vars.tenant }}-{{ .vars.environment }}-{{ .vars.stage }}-events"
api_key: !secret API_KEY
db_password: !secret DB_CONFIG | path ".password"

Two ways components share data

This example deliberately shows two cross-component patterns, and it's worth pausing on the distinction:

  • Templates plus dependencies — stack templates produce predictable names and ARNs, while dependencies.components controls apply/destroy order. This is the primary path in the quick start because it supports first-run atmos terraform deploy --all.

  • Stores — components also write their actual outputs to a store after apply using a hook. Operators can inspect those published coordinates, and larger stacks can use !store when consumers should discover values that are not predictable from naming conventions.

Group the catalog with catalog/backend

Rather than importing all six catalog files everywhere, the example collects them (plus the emulator) into a single catalog/backend manifest. Importing catalog/backend once brings the whole backend into a stack.

import:
- catalog/emulator/aws
- catalog/kms-key/defaults
- catalog/s3-bucket/defaults
- catalog/dynamodb-table/defaults
- catalog/sns-topic/defaults
- catalog/sqs-queue/defaults
- catalog/app-config/defaults

Atmos Top-level Stacks

When executing the CLI commands, Atmos does not use the stack file names and their filesystem locations to search for the stack where the component is defined. Instead, Atmos uses the context variables (namespace, tenant, environment, stage) to search for the stack. The stack config file names can be anything, and they can be in any folder or sub-folder in the stacks directory.

For example, when executing the atmos terraform apply app-config -s plat-ue2-dev command, the Atmos stack plat-ue2-dev is specified by the -s flag. Atmos evaluates name_template: "{{ .vars.tenant }}-{{ .vars.environment }}-{{ .vars.stage }}" (see Configure CLI) for each top-level stack. The stack whose variables render to plat-ue2-dev is the match. Then Atmos searches the top-level stack manifests (in the stacks directory) where tenant: plat, environment: ue2 and stage: dev are defined (inline or via imports).

We use a hierarchical layout that follows the way AWS thinks about infrastructure (organization → OU/tenant → account/stage → region). This works very well as you grow to dozens or hundreds of accounts and regions, and it's the recommended starting point even for a small example like this one.

Create the following filesystem layout (the final layout for this Quick Start guide):

infra-live/
Terminal
   │   # Centralized stacks configuration
├── stacks
│ ├── catalog
│ │ ├── backend.yaml
│ │ ├── emulator
│ │ │ └── aws.yaml
│ │ ├── kms-key
│ │ │ └── defaults.yaml
│ │ ├── s3-bucket
│ │ │ └── defaults.yaml
│ │ ├── dynamodb-table
│ │ │ └── defaults.yaml
│ │ ├── sns-topic
│ │ │ └── defaults.yaml
│ │ ├── sqs-queue
│ │ │ └── defaults.yaml
│ │ └── app-config
│ │ └── defaults.yaml
│ ├── mixins
│ │ └── region
│ │ ├── global-region.yaml
│ │ ├── us-east-2.yaml
│ │ └── us-west-2.yaml
│ └── orgs
│ └── acme
│ ├── _defaults.yaml
│ ├── core
│ │ └── _defaults.yaml
│ └── plat
│ ├── _defaults.yaml
│ ├── dev
│ │ ├── _defaults.yaml
│ │ ├── global-region.yaml
│ │ ├── us-east-2.yaml
│ │ └── us-west-2.yaml
│ ├── prod
│ │ ├── _defaults.yaml
│ │ ├── global-region.yaml
│ │ ├── us-east-2.yaml
│ │ └── us-west-2.yaml
│ └── staging
│ ├── _defaults.yaml
│ ├── global-region.yaml
│ ├── us-east-2.yaml
│ └── us-west-2.yaml

│ # Centralized components configuration. Components are broken down by tool
└── components
└── terraform # Terraform components (Terraform root modules)
├── kms-key
├── s3-bucket
├── dynamodb-table
├── sns-topic
├── sqs-queue
└── app-config

Configure Region Mixins

Mixins are a special kind of "import". It's simply a convention we recommend to distribute reusable snippets of configuration that alter behavior in some deliberate way. Mixins are not handled in any special way — they are technically identical to all other imports.

Reach for a mixin when a snippet is genuinely cross-cutting — reused across many stacks regardless of where they sit in the hierarchy. The region is the textbook case: every account (dev, staging, prod) deploys into the same set of regions, so the region context belongs in one reusable place.

Don't create a mixin for a single line

Earlier versions of this example also had mixins/tenant/* and mixins/stage/* files that each set a single context variable (tenant: plat, stage: dev). That added an extra file to chase for no real reuse. A value that belongs to exactly one layer of the hierarchy should live in that layer's _defaults.yaml, not in a one-line mixin. So tenant now lives in the tenant's _defaults.yaml, and stage lives in each account's _defaults.yaml. Component settings stay in the top-level regional stacks that actually deploy those components.

In stacks/mixins/region/us-east-2.yaml, add the following config:

vars:
region: us-east-2
environment: ue2

In stacks/mixins/region/us-west-2.yaml, add the following config:

vars:
region: us-west-2
environment: uw2

The region mixin defines the global context variables region and environment, which Atmos uses when searching for a component in a stack. The mixin gets imported into the top-level stacks, so we don't repeat the region context in every account — keeping the configuration DRY. This is the multi-region modeling pattern.

Configure Defaults for Organization, OU and accounts

The _defaults.yaml stack manifests contain the default settings for the Organization(s), Organizational Units, and accounts. This is the _defaults.yaml design pattern.

info

The _defaults.yaml stack manifests are not imported into other Atmos manifests automatically. You need to explicitly import them using imports.

In stacks/orgs/acme/_defaults.yaml, define the namespace for the entire acme Organization (the real file also sets common tags and template settings):

vars:
namespace: acme

In stacks/orgs/acme/plat/_defaults.yaml, configure the plat OU (tenant). The tenant context variable lives right here in the tenant's _defaults.yaml (no separate one-line mixin to chase). Defaults files should hold context and truly common settings; deployable component catalogs are imported by the top-level stacks that actually run them.

import:
- orgs/acme/_defaults

vars:
tenant: plat

When Atmos processes this stack config, it imports and deep-merges all the variables defined in the imported files and inline. Since the backend catalog is not imported at this layer, helper stacks such as global-region can carry context without accidentally defining regional application resources.

In stacks/orgs/acme/plat/dev/_defaults.yaml, configure the dev account context. Keep this file focused on context and truly common defaults:

import:
- orgs/acme/plat/_defaults

vars:
stage: dev

Configure the prod account the same way:

import:
- orgs/acme/plat/_defaults

vars:
stage: prod

Add the staging account defaults the same way.

Configure Top-level Stacks

After we've configured the catalog, the region mixins, and the defaults for the Organization, OU and accounts, the final step is to configure the Atmos root (top-level) stacks. Each deployable regional leaf stack imports the backend catalog, its account defaults, and a region mixin. It also carries the component settings for that stage and region.

This keeps component definitions in the stacks that actually deploy them, while defaults stay focused on shared context. This layering is the organizational hierarchy pattern in action.

In stacks/orgs/acme/plat/dev/us-east-2.yaml, define the dev backend in us-east-2. This is where you can see, at a glance, what makes dev different from the catalog defaults — same services, deliberately cheaper and more ephemeral settings:

import:
- catalog/backend
- orgs/acme/plat/dev/_defaults
- mixins/region/us-east-2

components:
terraform:
s3-bucket:
vars:
force_destroy: true # easy teardown of non-empty dev buckets
versioning_enabled: false # not worth the storage for throwaway data
kms-key:
vars:
deletion_window_in_days: 7 # shortest window AWS allows
enable_key_rotation: false
sqs-queue:
vars:
message_retention_seconds: 86400 # 1 day

Similarly, create the top-level Atmos stack for the dev account in us-west-2 with the same dev component settings and a different region mixin:

import:
- catalog/backend
- orgs/acme/plat/dev/_defaults
- mixins/region/us-west-2

components:
terraform:
s3-bucket:
vars:
force_destroy: true
versioning_enabled: false
kms-key:
vars:
deletion_window_in_days: 7
enable_key_rotation: false
sqs-queue:
vars:
message_retention_seconds: 86400

Repeat the pattern for the staging and prod accounts, importing the matching _defaults, region mixin, and component settings. For example, stacks/orgs/acme/plat/prod/us-east-2.yaml uses the opposite, hardened choices:

import:
- catalog/backend
- orgs/acme/plat/prod/_defaults
- mixins/region/us-east-2

components:
terraform:
s3-bucket:
vars:
force_destroy: false # never silently delete a non-empty prod bucket
versioning_enabled: true # required in prod (enforced by the OPA policy)
kms-key:
vars:
deletion_window_in_days: 30 # longest window AWS allows — recoverable
enable_key_rotation: true
sqs-queue:
vars:
message_retention_seconds: 1209600 # 14 days (AWS max)

These per-account, per-region leaf files are the top-level stacks Atmos resolves when you pass -s plat-ue2-dev, -s plat-uw2-prod, and so on. The global-region stacks intentionally omit catalog/backend; they define context only, so regional resources like sns-topic and sqs-queue are not accidentally instantiated in plat-gbl-*.

Component Inheritance

This is exactly what the regional top-level stack files do above: the catalog defines the defaults once, and each stage overrides only what differs via Component Inheritance. dev turns versioning_enabled off while prod keeps it on — which is exactly the difference the OPA policy enforces for prod. You can override per stage (as here) or per region, at any layer of the hierarchy.


Next: run scans on every plan and publish outputs on every apply → Configure Hooks →