Skip to content

Building on AWS

This guide builds a multi-environment AWS stack one resource at a time and introduces each authoring feature where it is first needed.

The finished configuration has a shared VPC in a common stack, plus dev and prod stacks. Each environment stack holds an S3 bucket, an SQS queue, an IAM role with an inline policy, a Lambda function that reads a secret, and a security group attached to the shared VPC: fifteen resources across two providers, wired together by references.

The end state is examples/aws/example-one.py; you can compare your file against it at any point.

About the output in this guide

Every validate, plan and graph block below is output captured by running the configuration at that step. These commands belong to the pure half of the pipeline and require no AWS credentials, so you can follow the guide without an AWS account and without creating anything.

apply output is not shown, because producing it provisions real infrastructure. Where an applied value would appear, the plan prints (known after apply).

This guide assumes the walkthrough, which covers the same pipeline against local files in more depth.


Before you start

mkdir aws-demo && cd aws-demo

plan requires nothing further. apply requires AWS credentials (environment variables, ~/.aws/credentials, or an instance role) with permission to create the resource types used below.


1. One bucket, one stack

infra.py:

from atlantide.core import Stack, output
from atlantide.providers.aws import Region, S3Bucket

with Stack("dev", region=Region.EuNorth1, name_prefix="atlantide", tags={"env": "dev"}):
    assets = S3Bucket("assets", bucket="atlantide-assets-dev-7f3a91c2")
    output("assets_arn", assets.arn)
atlantide validate infra.py
ok infra.py — 1 resource(s), no cycles
atlantide plan infra.py --state infra.db
state: infra.db
dev ────────────────────────────────────────────────────────────────────────────
  + create  aws.S3Bucket:assets

Plan: 1 to add

Outputs:
  dev:assets_arn = (known after apply)
Stack("dev", ...) A namespace. Every resource inside gets node id dev:<type>:<name> and inherits the stack's region, name_prefix and tags. The same logical name can exist in every stack without colliding.
Region.EuNorth1 An enum, so a typo is a type error in your editor instead of an API error at apply time.
output(...) Declared inside the with block, so it is namespaced dev:assets_arn and readable by another stack. Declared outside, it lands in default.

S3Bucket blocks public access and enables SSE-S3 encryption by default. A public or unencrypted bucket must be configured explicitly.


2. A queue, a role, and a policy wired by references

Add an SQS queue, an IAM role, and an inline policy that grants the role access to the bucket and the queue:

from atlantide.core import Stack, output
from atlantide.providers.aws import (
    IamPolicy, IamRole, Region, S3Bucket, ServicePrincipal, SqsQueue, allow,
)

with Stack("dev", region=Region.EuNorth1, name_prefix="atlantide", tags={"env": "dev"}):
    assets = S3Bucket("assets", bucket="atlantide-assets-dev-7f3a91c2")
    jobs = SqsQueue("jobs", fifo=True)

    worker = IamRole("worker", assumed_by=[ServicePrincipal.Ec2, ServicePrincipal.Lambda])
    IamPolicy(
        "worker-policy",
        role_arn=worker.arn,
        policy_name="worker-access",
        statements=[
            allow(S3Bucket.Action.GetObject, S3Bucket.Action.PutObject, on=assets.objects_arn),
            allow(SqsQueue.Action.SendMessage, on=jobs.arn),
        ],
    )
    output("assets_arn", assets.arn)
atlantide plan infra.py --state infra.db
state: infra.db
dev ────────────────────────────────────────────────────────────────────────────
  + create  aws.IamPolicy:worker-policy
  + create  aws.IamRole:worker
  + create  aws.S3Bucket:assets
  + create  aws.SqsQueue:jobs

Plan: 4 to add

The config declares no ordering, but the engine derives one:

atlantide graph infra.py --format mermaid
graph LR
  subgraph cluster0["dev"]
    n0["aws.IamPolicy:worker-policy"]
    n1["aws.IamRole:worker"]
    n2["aws.S3Bucket:assets"]
    n3["aws.SqsQueue:jobs"]
  end
  n1 --> n0
  n2 --> n0
  n3 --> n0

The policy reads worker.arn, assets.objects_arn and jobs.arn. None of those ARNs exists yet, so each read returns a lazy Ref, and each Ref becomes a graph edge. The role, bucket and queue are independent and are created in parallel; the policy waits for all three.

allow() builds the policy document, so no JSON is hand-written. Actions are enum members such as S3Bucket.Action.GetObject, so a misspelled action fails before anything runs.

The statement uses assets.objects_arn rather than assets.arn, because object-level permissions require the /* form. The resource exposes both ARNs as named attributes.


3. A Lambda, fingerprinted from disk

Create the handler:

processor/app.py
def handler(event, context):
    return {"received": len(event.get("Records", []))}

Add the function and give it the role:

processor = LambdaFunction(
    "processor",
    role_arn=worker.arn,
    handler="app.handler",
    code_path="processor",
    signing_secret=SecretRef("app/signing-key-dev"),
)

code_path points at a local directory. At config-evaluation time the directory is zipped with sorted entries and fixed timestamps, then fingerprinted into code_sha256. Two checkouts of the same source produce the same digest; editing app.py changes it, and the next plan shows an update.

Plan:

atlantide plan infra.py --state infra.db
state: infra.db
error: undefined secret(s): dev:aws.LambdaFunction:processor.signing_secret -> 
'app/signing-key-dev'

SecretRef("app/signing-key-dev") names a secret that is not in the store. An apply would fail part-way through, after creating some resources, so Atlantide checks every referenced secret during planning.

Set the secret:

atlantide secret set app/signing-key-dev
set secret 'app/signing-key-dev'

Without a value argument, the command prompts for it with hidden input. By default, secrets are held in a local AES-256-GCM encrypted keyfile store; [secrets] in atlantide.toml can point at SSM Parameter Store or the environment instead.

atlantide plan infra.py --state infra.db
state: infra.db
dev ────────────────────────────────────────────────────────────────────────────
  + create  aws.IamPolicy:worker-policy
  + create  aws.IamRole:worker
  + create  aws.LambdaFunction:processor
  + create  aws.S3Bucket:assets
  + create  aws.SqsQueue:jobs

Plan: 5 to add

The secret's value never appears in the config, the IR or the state file; only its name does. The plaintext is resolved in memory at apply time. State keeps a salted digest, which detects a rotated secret without storing the value.


4. A shared VPC, and a second environment

Put the VPC that both environments share in its own stack:

with Stack("common", region=Region.EuNorth1, name_prefix="atlantide", tags={"env": "common"}):
    network = Vpc("network", cidr_block="10.0.0.0/16")
    vpc_id = output("vpc_id", network.vpc_id)

output() returns a typed handle. When you consume the variable instead of a string such as "common:vpc_id", a typo is an undefined-variable error at config time instead of a mismatch found during planning.

Declare the two environments and what differs between them:

from atlantide.core import Config, EnvSchema

class AppEnv(EnvSchema):
    versioning: bool = False

config = Config(
    AppEnv,
    envs={
        "dev": {
            "region": Region.EuNorth1,
            "name_prefix": "atlantide",
            "tags": {"env": "dev"},
        },
        "prod": {
            "region": Region.EuNorth1,
            "name_prefix": "atlantide",
            "tags": {"env": "prod"},
            "versioning": True,
        },
    },
)

Wrap the environment body in a loop over the environments, and attach a security group to the shared VPC:

for env in config.envs():
    with Stack(env.name, config=env):
        ...
        edge = SecurityGroup("edge", group_name=f"edge-{env.name}", vpc_id=vpc_id)

region, name_prefix and tags are well-known keys of every environment, so config=env supplies all three. versioning is a config-specific variable with a default: dev uses the default and prod overrides it. The shape is an EnvSchema, the one class Atlas-lang admits (annotated fields only), so your editor completes env.versioning and a misspelling is a type error instead of a plan-time error.

The Config is validated when it is constructed, so a missing or mistyped prod value fails atlantide validate in CI instead of when prod is applied.

Because common is in the same config, the engine turns the output handle into a dependency edge and applies common before dev and prod. dev and prod are independent and apply in parallel. common sits outside the loop because it is not per-environment, so narrowing a run to one environment keeps it in the graph.

Running one environment

atlantide plan --env prod acts on prod alone. dev is out of scope, not undeclared: its recorded state is not diffed and is never planned for deletion. The plan reports this:

envs: prod (of dev, prod) — dev is not planned and will not change

Two stacks, or two configs?

A stack applied by a separate config is referenced with StackReference("common").output("vpc_id"), which resolves from committed state instead of from an edge within the run. Use the handle when the stacks share a config, and StackReference when they do not.


5. Deterministic names, and a value decided at apply

Two more lines in the environment body:

build = Id("build-id", byte_length=4)

bucket_name = f"atlantide-assets-{env.name}-{uuid5('atlantide-buckets', env.name)[:8]}"
assets = S3Bucket(
    "assets",
    bucket=bucket_name,
    versioning=env.versioning,
    tags={"build_id": build.result, "config_hash": sha256_hex(env.name)[:12]},
)

The two values are decided at different times:

When it is decided Where it lives
uuid5(...), sha256_hex(...) Config time. Atlas-lang builtins — pure functions the interpreter injects, used with no import. A fixed literal baked into the IR.
random.Id Apply time. A resource, from the random provider. Generated once and pinned in state. State. A re-apply is a NOOP, never a new value.

The bucket name is stable across every run and machine; the build id is stable across every run after the first.

The bucket reads build.result, so the engine orders the random resource before the AWS bucket in a single graph spanning two providers.

versioning=env.versioning reads the value from the environment instead of branching on its name. Per-environment differences are declared once, typed, in the Config, instead of as env == "prod" tests in the body.


6. Policies, at the top of the file

enforce("require-tags", keys=["env"])
enforce("deny-destroy-in-protected", stacks=["prod"])

Policies run at the end of planning against the finished changeset, so they see the planned actions as well as the config. A mandatory violation blocks apply before anything is changed.

To trigger the first policy, remove the env tag from the stacks and plan:

policy DENY require-tags: dev:aws.S3Bucket:assets is missing tag(s): env
policy DENY require-tags: dev:aws.SqsQueue:jobs is missing tag(s): env
policy DENY require-tags: dev:aws.SecurityGroup:edge is missing tag(s): env
policy DENY require-tags: dev:aws.LambdaFunction:processor is missing tag(s): 
env
...
10 mandatory policy violation(s) block apply

Every offending resource is named. Stack tags merge into every resource in the body, so restoring tags={"env": env} on the two stacks resolves all ten violations.

The second policy, deny-destroy-in-protected, fails any plan that deletes or replaces something in prod. This includes removing a resource from the file and editing an immutable() field, since a replacement is a destroy followed by a create.

It does not cover atlantide destroy

destroy runs against an empty config and therefore evaluates no policy bindings. Lifecycle(prevent_destroy=True) is the per-resource guard for that path.


7. The finished config

Add the exports for each environment:

output("assets_arn", assets.arn)
output("assets_bucket", assets.bucket)
output("jobs_url", jobs.url)
output("worker_role_arn", worker.arn)
output("processor_arn", processor.arn)
output("build_id", build.result)
output("edge_sg_id", edge.group_id)
output("region", Region.EuNorth1)
atlantide plan infra.py --state infra.db
state: infra.db
common ─────────────────────────────────────────────────────────────────────────
  + create  aws.Vpc:network
dev ────────────────────────────────────────────────────────────────────────────
  + create  aws.IamPolicy:worker-policy
  + create  aws.IamRole:worker
  + create  aws.LambdaFunction:processor
  + create  aws.S3Bucket:assets
  + create  aws.SecurityGroup:edge
  + create  aws.SqsQueue:jobs
  + create  random.Id:build-id
prod ───────────────────────────────────────────────────────────────────────────
  + create  aws.IamPolicy:worker-policy
  + create  aws.IamRole:worker
  + create  aws.LambdaFunction:processor
  + create  aws.S3Bucket:assets
  + create  aws.SecurityGroup:edge
  + create  aws.SqsQueue:jobs
  + create  random.Id:build-id

Plan: 15 to add

Outputs:
  common:vpc_id = (known after apply)
  dev:assets_arn = (known after apply)
  dev:assets_bucket = 'atlantide-assets-dev-b89f6cc5'
  dev:jobs_url = (known after apply)
  dev:worker_role_arn = (known after apply)
  dev:processor_arn = (known after apply)
  dev:build_id = (known after apply)
  dev:edge_sg_id = (known after apply)
  dev:region = 'eu-north-1'
  prod:assets_arn = (known after apply)
  prod:assets_bucket = 'atlantide-assets-prod-445ca963'
  ...

assets_bucket and region already have values because both are computed at config time. The other outputs read (known after apply).

The full graph:

graph LR
  subgraph cluster0["common"]
    n0["aws.Vpc:network"]
  end
  subgraph cluster1["dev"]
    n1["aws.IamPolicy:worker-policy"]
    n2["aws.IamRole:worker"]
    n3["aws.LambdaFunction:processor"]
    n4["aws.S3Bucket:assets"]
    n5["aws.SecurityGroup:edge"]
    n6["aws.SqsQueue:jobs"]
    n7["random.Id:build-id"]
  end
  subgraph cluster2["prod"]
    n8["aws.IamPolicy:worker-policy"]
    n9["aws.IamRole:worker"]
    n10["aws.LambdaFunction:processor"]
    n11["aws.S3Bucket:assets"]
    n12["aws.SecurityGroup:edge"]
    n13["aws.SqsQueue:jobs"]
    n14["random.Id:build-id"]
  end
  n2 --> n1
  n4 --> n1
  n6 --> n1
  n2 --> n3
  n7 --> n4
  n0 --> n5
  n9 --> n8
  n11 --> n8
  n13 --> n8
  n9 --> n10
  n14 --> n11
  n0 --> n12

One VPC feeds two independent environment subgraphs. The ordering follows from which resources read which values.


8. Apply, and what happens after

atlantide secret set app/signing-key-prod    # prod needs its own
atlantide apply infra.py --state infra.db

Apply takes a lease over the reachable graph, re-diffs under that lock, and reconciles independent nodes in parallel. State is written per node as each one succeeds, so a crash part-way through leaves an accurate record. On failure, the default --on-failure rollback undoes the completed nodes.

A second run makes every node a no-op with no AWS API calls: the engine compares hashes and stops.

After applying:

atlantide output --state infra.db        # what the apply exported
atlantide state list --state infra.db    # what is recorded
atlantide refresh --state infra.db       # has anything drifted?
atlantide destroy --state infra.db       # tear it down, dependents first

Making it a project

Put the defaults in an atlantide.toml beside the config to omit the flags:

config      = "infra.py"
state       = "infra.db"
aws_region  = "eu-north-1"
aws_profile = "default"
atlantide plan       # no flags needed

For a team, move state to a shared backend (S3 with DynamoDB locking, or Postgres) so concurrent runs cannot overwrite each other. See Remote state.

A [profile.<name>] overlay is separate from the Config: the Config defines what each environment contains, and a profile defines where a run points (state backend, AWS account, parallelism). They are selected independently with --env and --profile. See Configuration.


Where to go next

  • The Merkle hash: why the second apply makes no API calls, and which nodes a change propagates to.
  • Authoring: output combinators for values not known until apply, explicit ordering, and renaming a resource without destroying it.
  • Components: packaging this stack as a reusable construct, publishable from a git repository and pinned by commit.
  • Providers: the full AWS resource list, and how to write a provider.
  • A static website: examples/aws/example-three.py builds a private S3 origin behind CloudFront with an Origin Access Control, in four resources.