Building on AWS

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

The finished configuration has a shared VPC in a common stack, and dev and prod stacks that each hold 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. That is fifteen resources across two providers, wired together entirely by references.

The end state is examples/aws/example-one.py in the repository, so you can compare your file against it at any point.

About the output in this guide

Every validate, plan and graph block below is real output, captured by running the configuration at that step. All three belong to the pure half of the pipeline and require no AWS credentials, so the whole guide can be followed without an AWS account and without creating anything.

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

If you have not met the engine before, the walkthrough covers the same pipeline against local files in more depth. This guide assumes that material.


Before you start

mkdir aws-demo && cd aws-demo

plan requires nothing further. apply requires AWS credentials resolvable in any of the usual ways — environment variables, ~/.aws/credentials, or an instance role — together 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)

Three things are happening in six lines:

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 rather than a string, 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 would land in default.

S3Bucket blocks public access and enables SSE-S3 encryption by default. A public or unencrypted bucket should be a decision recorded in the config, not an oversight.


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

Add an SQS queue, an IAM role, and an inline policy granting the role access to both:

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

Nothing in the config declares an ordering, yet one exists. Ask the engine for it:

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 depends on all three resources because it reads all three: 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 an edge in the graph.

The role, bucket and queue do not depend on one another, so they are created in parallel. The policy waits for all three.

Two details of the policy are worth noting. allow() builds the policy document, so there is no hand-written JSON, and the actions are enum members such as S3Bucket.Action.GetObject — a misspelled action fails before anything runs.

The statement also uses assets.objects_arn rather than assets.arn, because object-level permissions require the /* form. The resource exposes both, so the correct one is chosen by name instead of by remembering to append a suffix.


3. A Lambda, fingerprinted from disk

Create the handler:

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

Then 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. The directory is zipped with sorted entries and fixed timestamps, then fingerprinted into code_sha256 at config-evaluation time.

Two properties follow. Two checkouts of the same source produce the same digest, and editing app.py changes the digest — which is what makes the next plan show an update.

Now 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'

The plan refuses to proceed. SecretRef("app/signing-key-dev") names a secret that does not exist in the store, and an apply would therefore fail part-way through, having already created some resources. Atlantide checks the entire secret set during planning to avoid that.

Set it:

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

Omitting the value prompts for it with the input hidden. Secrets are held in a local AES-256-GCM encrypted keyfile store by default; [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 travels. The plaintext is resolved in memory at apply time. State keeps a salted digest of it, which is enough to detect a rotated secret without storing the secret itself.


4. A shared VPC, and a second environment

One VPC that both environments build on. Put it 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. Consuming that variable, rather than a string such as "common:vpc_id", turns a typo into an undefined-variable error at config time instead of a mismatch discovered during planning.

Now wrap the environment body in a loop and hang a security group off the shared VPC:

for env in ["dev", "prod"]:
    with Stack(env, region=Region.EuNorth1, name_prefix="atlantide", tags={"env": env}):
        ...
        edge = SecurityGroup("edge", group_name=f"edge-{env}", vpc_id=vpc_id)

This is ordinary Python — a for loop over stacks. Because common lives in the same config, the engine inlines the output handle into a real dependency edge and applies common before dev and prod. Those two are independent of each other, so they apply in parallel.

Two stacks, or two configs?

A stack applied by a separate config is referenced differently, using StackReference("common").output("vpc_id"), which resolves from committed state rather than from an edge within the run. The idea is the same; the mechanism differs. 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}-{uuid5('atlantide-buckets', env)[:8]}"
assets = S3Bucket(
    "assets",
    bucket=bucket_name,
    versioning=(env == "prod"),
    tags={"build_id": build.result, "config_hash": sha256_hex(env)[:12]},
)

The two lines look similar but do opposite things:

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 therefore stable across every run and every machine, while 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 — a single graph spanning two providers.

Note also versioning=(env == "prod"). Per-environment differences are ordinary Python expressions; there is no separate templating layer.


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 actions rather than only the config. A mandatory violation blocks apply before anything is touched.

To see the first one working, drop 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. Because stack tags merge into every resource declared in the body, restoring tags={"env": env} on the two stacks satisfies all ten violations at once, without repeating a tag per resource.

The second policy, deny-destroy-in-protected, fails any plan that would delete or replace something in prod. That covers removing a resource from the file, and also 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

Exports for each environment, then the whole thing:

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'
  ...

Note which outputs already have values. assets_bucket and region are known now, because both are computed at config time. Everything else reads (known after apply) because it does not exist yet.

The whole 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. Nothing in the config stated that ordering; it 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 still leaves an accurate record. On failure, the default --on-failure rollback undoes the nodes that had completed.

Run it a second time and every node is a no-op, with no AWS API calls at all: the engine compares hashes and stops. On a graph this size, that is the difference between a re-run costing nothing and one costing hundreds of calls.

Afterwards:

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, and the flags are no longer needed:

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 that concurrent runs cannot overwrite each other. That, along with per-environment [profile.<name>] overlays, is covered in Remote state and Configuration.


Where to go next

  • The Merkle hash — why the second apply costs nothing, 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 websiteexamples/aws/example-three.py builds a private S3 origin behind CloudFront with an Origin Access Control, in four resources.