Migrating from Rain
Move the CloudFormation stacks you deploy with Rain to Atmos. Your templates, parameter files, and deployed stacks stay where they are. This guide maps every Rain directive, config file, and command to its Atmos-native equivalent, so you end up with Atmos conventions rather than a Rain emulation.
Install the atmos-migration skill so Claude Code, Cursor, GitHub Copilot, and other AI coding
assistants can apply this guide directly to your repository:
atmos ai skill install atmos-migration
See AI Agent Skills for details.
Why Migrate from Rain
Rain was archived in July 2026. The existing
releases still run, but nothing ships after them. If Rain is how your team deploys CloudFormation,
the question is what replaces it, and Atmos answers it with a native aws/cloudformation
component type. Atmos talks to the CloudFormation API through the AWS SDK, so there is no Rain,
aws, or sam binary involved, and your stacks become components in the same stack manifests,
inheritance, and CI workflows the rest of your infrastructure uses.
The migration is parity, not compatibility. Rain preprocessed templates and resolved !Rain::
directives before deployment. Atmos sends a file template to CloudFormation exactly as written and
offers a native way to accomplish what each directive did. Templates that are plain CloudFormation
need no changes at all.
Key Differences at a Glance
| Concern | Rain | Atmos |
|---|---|---|
| Stack name | Command argument | stack_name on the component |
| Parameters | --params, --config, RAIN_VAR_* | parameters: map, or !include of your existing parameter file |
| Tags | --tags, --config, RAIN_DEFAULT_TAG_* | tags: map, or tags: !labels to carry Atmos labels onto the stack |
| Region and credentials | --region, --profile | settings.aws_cloudformation.region and Atmos Auth or the SDK credential chain |
| Template directives | !Rain::Env, !Rain::Include, !Rain::Embed, !Rain::S3 | YAML functions inside an included template, hooks for uploads |
| Artifact bucket | rain-artifacts-<account>-<region>, created on first deploy | A kind: aws/s3 provision target, created by backend create or automatically with provision.backend.enabled: true |
| Asset packaging | rain pkg rewrites the template | archive and publish hook steps upload assets; the template references them through parameters |
| Environments | One invocation per environment | One stack manifest per environment, sharing a catalog through imports and inheritance |
| Stack outputs | Console or aws cloudformation describe-stacks | atmos aws cloudformation output, plus !aws.cloudformation.output from other components |
| Drift | Not a Rain feature | drift detect and drift describe |
Directory Structure Comparison
- Before (Rain)
- After (Atmos)
.
├── templates/
│ ├── app.yaml # !Rain::Env, !Rain::Embed, !Rain::Include, !Rain::S3
│ └── fragments/tags.yaml
├── src/handler.py # embedded by !Rain::Embed
├── dist/worker/ # uploaded by !Rain::S3
├── templates/network.yaml # a second stack the app reads an output from
├── params/
│ ├── dev.yaml # {Parameters: {...}, Tags: {...}} per environment
│ └── staging.yaml
└── deploy.sh # loops environments, hand-orders network before app,
# copies the network output into the app parameters
.
├── atmos.yaml
├── components/cloudformation/app/
│ ├── template.yaml # plain CloudFormation
│ ├── fragments/tags.yaml
│ └── src/
│ ├── handler.py # inlined through !include
│ └── worker/ # archived and published by the hook
├── components/cloudformation/network/template.yaml
├── params/ # unchanged; read with !include
│ ├── dev.yaml
│ └── staging.yaml
└── stacks/
├── catalog/
│ ├── network.yaml # abstract base: template, stack name pattern
│ └── app.yaml # abstract base: parameters, dependency on network, packaging hook
├── mixins/region/us-east-1.yaml
└── deploy/
├── dev.yaml # imports the bases and the mixin; includes params/dev.yaml
└── staging.yaml
Nothing forces this layout. Point components."aws/cloudformation".base_path at wherever your
templates already live and keep the parameter files where they are.
Concept Mapping
Rain Directives
Rain resolved these directives by rewriting the template before it reached CloudFormation. Atmos never rewrites a file template, so a template that still contains a directive fails locally, before any AWS call, with the replacement named for each directive:
Error: aws/cloudformation template contains Rain directives, which Atmos does not preprocess
components/cloudformation/app/template.yaml uses Rain directives (Constant, Env, Embed, S3).
💡 Replace !Rain::Env with `!env NAME` in the component's parameters:, or inside the template
when it is included with template: !include template.yaml | eval
💡 Migration guide: https://atmos.tools/migration/rain
| Directive | What Rain did | Atmos-native replacement |
|---|---|---|
!Rain::Env NAME | Inserted an environment variable | A CloudFormation parameter fed by !env NAME in parameters:, or !env NAME inside the template when it is included with | eval |
!Rain::Constant NAME, ${Rain::NAME} | Inserted a value from the template's Rain: Constants: section | A CloudFormation parameter fed from stack vars:, or {{ .vars.name }} in an inline template |
!Rain::Include file.yaml | Merged a YAML or JSON fragment | !include file.yaml inside the template, included with | eval |
!Rain::Embed file | Inlined a file as a string | !include file inside the template, included with | eval; a non-YAML file loads as a string |
!Rain::S3 path, !Rain::S3Http path | Uploaded a file or directory and inserted its S3 URI or URL | archive and publish hook steps upload the asset; the template reads the bucket and key from parameters |
!Rain::Module | Client-side template composition (experimental in Rain) | No equivalent. Expand modules in your build step, or use AWS CDK, and point path at the output |
Two of these replacements rely on the | eval option of !include.
An included file is data by default, so its YAML functions are not evaluated. Adding | eval
turns that on for the included template, and CloudFormation's own short-form intrinsics (!Ref,
!Sub, !GetAtt) are preserved either way.
Environment Values and Constants
- Before (Rain)
- After (Atmos)
Rain:
Constants:
MarkerPrefix: /acme/app
Resources:
Marker:
Type: AWS::SSM::Parameter
Properties:
Name: !Sub "${Rain::MarkerPrefix}/${Stage}/owner"
Type: String
Value: !Rain::Env DEPLOY_OWNER
# components/cloudformation/app/template.yaml — plain CloudFormation
Parameters:
Stage:
Type: String
MarkerPrefix:
Type: String
DeployOwner:
Type: String
Resources:
Marker:
Type: AWS::SSM::Parameter
Properties:
Name: !Sub "${MarkerPrefix}/${Stage}/owner"
Type: String
Value: !Ref DeployOwner
# stacks/deploy/dev.yaml — the values come from stack configuration
parameters:
Stage: "{{ .vars.stage }}"
MarkerPrefix: /acme/app
DeployOwner: !env DEPLOY_OWNER
Turning a directive into a CloudFormation parameter keeps the template plain, which is the most
portable outcome. An unset variable without a default resolves to an empty string and logs a
warning, so give !env a default (!env DEPLOY_OWNER platform) when an empty value would be a
mistake.
Included Fragments and Embedded Files
When you would rather keep the directives' shape, include the template into template: with
| eval and use Atmos functions in the same places Rain used directives:
- Before (Rain)
- After (Atmos)
Resources:
Bucket:
Type: AWS::S3::Bucket
Properties:
Tags: !Rain::Include fragments/tags.yaml
Function:
Type: AWS::Lambda::Function
Properties:
Role: !GetAtt Role.Arn
Code:
ZipFile: !Rain::Embed ../src/handler.py
# stacks/catalog/app.yaml
components:
"aws/cloudformation":
app:
template: !include ../../components/cloudformation/app/template.yaml | eval
# components/cloudformation/app/template.yaml
Resources:
Bucket:
Type: AWS::S3::Bucket
Properties:
Tags: !include ./fragments/tags.yaml
Function:
Type: AWS::Lambda::Function
Properties:
Role: !GetAtt Role.Arn
Code:
ZipFile: !include ./src/handler.py
A nested !include inside the template resolves ./ and ../ relative to the template file,
exactly where Rain's !Rain::Embed ../src/handler.py looked; bare paths resolve relative to
base_path.
Uploaded Assets
!Rain::S3 uploaded a file or directory during deployment and wrote the location into the
template. Atmos keeps uploads out of the template and in a hook, because YAML functions only read
values and never change infrastructure. The hook runs before apply and before
changeset create, and the template reads the bucket and key through parameters:
- Before (Rain)
- After (Atmos)
Resources:
Worker:
Type: AWS::Lambda::Function
Properties:
Code: !Rain::S3
Path: ../dist/worker
BucketProperty: S3Bucket
KeyProperty: S3Key
Zip: true
# stacks/catalog/app.yaml
components:
"aws/cloudformation":
app:
path: template.yaml
vars:
stage: dev
release: v1 # Change this value for each release.
parameters:
WorkerBucket: "acme-plat-cfn-artifacts-{{ .vars.stage }}"
WorkerKey: "lambda/worker-{{ .vars.release }}.zip"
provision:
targets:
artifacts:
kind: aws/s3
bucket: "acme-plat-cfn-artifacts-{{ .vars.stage }}"
region: us-east-1
hooks:
package-worker:
events:
- before.aws/cloudformation.apply
- before.aws/cloudformation.changeset-create
kind: steps
with:
- name: archive
type: archive
source: src/worker/
destination: .build/worker.zip
mtime: epoch
- name: publish
type: publish
source: .build/worker.zip
destination: "lambda/worker-{{ .vars.release }}.zip"
target: artifacts
# components/cloudformation/app/template.yaml
Resources:
Worker:
Type: AWS::Lambda::Function
Properties:
Code:
S3Bucket: !Ref WorkerBucket
S3Key: !Ref WorkerKey
The key carries a release variable (set it per deployment, for example from a git tag or
!env RELEASE), because CloudFormation only updates a Lambda function when the S3Key value
changes; uploading new code under the same key leaves the function untouched. The publish step
skips uploads whose content has not changed, so repeated deployments of the same release report
0 uploaded, 1 unchanged. See asset packaging hooks
for the full pattern.
Parameter Files and Rain Config Files
An AWS CLI parameter array is accepted as-is, and the Parameters and Tags sections of a Rain
config file are selected with a YQ expression:
Including a whole Rain config file as parameters is an error, because Parameters and Tags
would be read as two parameters with map values. Values Rain took from RAIN_VAR_* and
RAIN_DEFAULT_TAG_* environment variables belong in the catalog as parameters and tags, where
every environment inherits them.
Tags
Stack tags are a map. An AWS CLI tags file is a list of Key/Value objects and is rejected
rather than silently dropped. Reshape it with a YQ expression, or carry your Atmos
metadata.labels onto the stack explicitly:
tags: !include '../../params/tags.json ".[] as $t ireduce ({}; .[$t.Key] = $t.Value)"'
# or
tags: !labels
Atmos labels and tags select components in Atmos commands; they never become CloudFormation tags
unless you write tags: !labels.
The Artifact Bucket
Rain created rain-artifacts-<account>-<region> the first time a deployment needed it. Atmos
uses the kind: aws/s3 provision target you declare, and creates it in one of two explicit ways:
atmos aws cloudformation backend create app -s dev
or, for the same create-on-demand behavior Rain had, with provision.backend.enabled: true on the
component, which makes apply and deploy create a missing bucket. Preview commands never create
it; they fail with a hint naming the command that will. Templates over the 51,200-byte inline limit
are uploaded to this bucket automatically on every command that submits a template.
CLI Command Comparison
| Rain | Atmos | Notes |
|---|---|---|
rain deploy <template> <stack> | apply or deploy | apply previews the changeset and prompts; deploy auto-approves |
rain deploy --no-exec | changeset create | Prints the changeset name and keeps it; fires the changeset-create hook events |
rain deploy --changeset <stack> <name> | changeset execute --changeset-name=<name> | |
rain deploy --keep | disable_rollback: true | Component field |
rain deploy --role-arn | role_arn | Component field |
rain deploy --termination-protection | termination_protection: true | delete then requires --disable-termination-protection |
rain diff <from> <to> | plan / diff | Different operation: Rain compared two templates, Atmos previews a changeset against the deployed stack |
rain fmt | fmt | Formats in place; --check is Rain's --verify. Blank lines between sections are removed |
rain cat <stack> | get template | --original returns the submitted template, like rain cat --unformatted |
rain ls | list | One region per call; marks which stacks match an Atmos component |
rain ls --changeset | changeset list | |
rain logs <stack> | logs | --chart renders a per-resource timeline in the terminal; --follow streams |
rain watch <stack> | watch | |
rain tree <template> | tree | Different operation: Rain graphed a local template, Atmos shows deployed nested stacks |
rain rm <stack> | delete | Prompts; respects termination protection |
rain rm --changeset | changeset delete | |
rain bootstrap | backend create | |
rain pkg | Hook steps | See Uploaded Assets |
rain stackset deploy | stackset create / stackset update | Accounts and regions come from a kind: aws/stackset target; delegated admin is not supported |
rain stackset ls | stackset instances | |
rain stackset rm | stackset delete | |
rain console | atmos auth console | |
rain info | atmos auth whoami, atmos describe config | |
rain forecast, rain build, rain merge, rain module, rain cc | No equivalent | Keep a build step that emits plain CloudFormation |
| (none) | output, drift detect, validate | New verbs Rain never had |
Migration Steps
The examples use components network and app, Atmos stacks dev and staging, and CloudFormation stacks acme-plat-<stage>-network and acme-plat-<stage>-app.
Replace them with your names. Where a step references an existing deployment, match its stack name,
account, region, and parameter values, because a different name or region selects a different stack.
Step 1: Install Atmos and Authenticate
brew install atmos
See Installation for other platforms. Configure an Atmos identity for
the account, or keep using the AWS SDK credential chain (AWS_PROFILE in the shell works). Every
command below accepts --identity=<name> when more than one identity is configured.
Step 2: Create a Minimal atmos.yaml
Step 3: Resolve Rain Directives
Skip this step for plain CloudFormation templates. For each directive, pick its replacement from
Rain Directives: a CloudFormation parameter fed from stack configuration, an
included template with | eval, or a packaging hook. Any command that reads the template
(render, validate, plan, apply) lists the directives that remain; --dry-run only checks the
stack configuration and never opens the template file.
Step 4: Split Shared Config from Environment Config
Rain repeated the same rain deploy per environment with a different --config file. In Atmos
the shared part becomes an abstract base in a catalog, the per-environment part is each
environment's stack manifest, and anything shared by a region or account becomes a mixin.
The environment stack includes the Rain config file for that environment, and its Parameters
map deep-merges with the catalog's parameters, so Stage and SharedBucket come from the base
and DeployOwner comes from the file. Keep the per-environment !include in the environment
stack: an !include path is resolved when the manifest loads, before Go templates render, so
!include ../../params/{{ .vars.stage }}.yaml in a catalog does not work. The staging stack is
the same file with stage: staging and params/staging.yaml.
Add termination_protection, role_arn, stack_policy, or notification_arns to the base
when the existing deployment uses them. See the
CloudFormation component reference for every field.
Step 5: Preview, Apply, Verify
Check the stack configuration without touching AWS, then render the template once so any remaining Rain directive is reported before a real changeset is attempted:
Then preview against the real stacks. Atmos creates a changeset per component, renders it, and removes it, in dependency order:
Changes for acme-plat-dev-network: 0 resources
acme-plat-dev-app: no changes (changeset would be a no-op)
For an existing stack, no changes is the result you want. Review any replacement or deletion before
you apply. This single command replaces the whole deploy.sh loop: network deploys first, the
SharedBucket output is read from the deployed stack and passed to app, and delete --all
later runs in reverse order:
INFO Processing components component_type=aws/cloudformation order=dependency count=2
...
Output Value
SharedBucketUsed acme-net-dev-shared
Use apply instead of deploy to review and confirm each changeset interactively. In a
non-interactive shell without --auto-approve, apply stops with an error before changing
anything. Repeat for staging by pointing at its stack; the catalog and mixin are shared.
Migration Checklist
- Install Atmos and configure credentials for the account
- Match each existing stack's name, account, region, and parameter values
- Resolve every Rain directive, or let the local error list the ones that remain
- Reuse parameter files and Rain config sections with
!include - Declare the artifact bucket as a provision target and create it, or enable automatic provisioning
- Move
rain pkguploads into anarchive+publishhook subscribed to the apply and changeset-create events - Run a dry run, then a real plan, and expect no changes for an existing stack
- Apply, then verify outputs and tags
- Update scripts for the new verbs, flags, and confirmation behavior
- Move shared config into catalog bases and mixins; keep each environment's Rain config
!includein its own stack - Replace hand-ordered deploy loops with
dependencies.componentsand!aws.cloudformation.output, thendeploy --all
What Atmos Won't Do
- Preprocess
!Rain::directives or Rain modules. A template that still has them fails locally with the replacement named for each. - Upload assets from inside a template. Uploads happen in hook steps, and YAML functions never change infrastructure.
- Reproduce
rain forecast,rain build,rain merge, or Cloud Control passthrough. - Compare two local templates the way
rain diffdid, or graph a local template the wayrain treedid. The Atmos verbs of the same name work against the deployed stack. - Deploy StackSets with delegated administration.