Shop Stack
This page builds the shop, the application the rest of these pages refer back to. By the end it exists in your own AWS account: a network to run in, a bucket where the checkout service keeps the receipts customers upload, and permission for checkout to read and write that bucket and nothing else.
Five nodes, none of which mentions AWS. The work happens in two passes: the first four go in together and get applied, then checkout arrives on top of live infrastructure, which is where the dotted line turns into a policy.
Everything on this page is free to create in an empty AWS account: a VPC, an internet gateway, an S3 bucket with nothing in it, and an IAM role. The teardown at the end removes all of it.
The definition file
Give the shop a project of its own, so it does not share .octo/ state with anything else you have
deployed:
npx @quadnix/octo-build create-app -t aws-s3-website -n shop
cd shop
Then replace octo.yaml with this, and delete the website/ directory the template wrote:
version: 1
env:
- key: AWS_ACCOUNT_ID
kind: string
value: "123456789012" # your AWS account number
- key: RECEIPTS_BUCKET_NAME
kind: string
value: shop-receipts # your own name, not this one
terraform:
requiredVersion: "1.6.0"
requiredProviders:
aws:
source: hashicorp/aws
providers:
- providerType: aws
accountId: "${env.AWS_ACCOUNT_ID}"
regionId: eu-west-1
spec:
profile: default # your profile in ~/.aws/credentials
settings:
listeners:
- type: LoggingEventListener
stateProvider:
type: LocalStateProvider
statePath: .octo
imports:
- className: SimpleAppModule
importPath: "@quadnix/octo-aws-cdk/modules/app/simple-app"
- className: AwsIniAccountModule
importPath: "@quadnix/octo-aws-cdk/modules/account/aws-ini-account"
- className: AwsSingleAzRegionModule
importPath: "@quadnix/octo-aws-cdk/modules/region/aws-single-az-region"
- className: AwsS3StorageServiceModule
importPath: "@quadnix/octo-aws-cdk/modules/service/aws-s3-storage-service"
modules:
- moduleId: app-module
moduleClass: SimpleAppModule
moduleInputs:
name: shop
- moduleId: account-module
moduleClass: AwsIniAccountModule
moduleInputs:
accountId: "${env.AWS_ACCOUNT_ID}"
app: "${{app-module.model.app}}"
- moduleId: region-module
moduleClass: AwsSingleAzRegionModule
moduleInputs:
account: "${{account-module.model.account}}"
name: eu-west-1
regionId: aws-eu-west-1a
vpcCidrBlock: 10.0.0.0/16
- moduleId: receipts-module
moduleClass: AwsS3StorageServiceModule
moduleInputs:
region: "${{region-module.model.region}}"
bucketName: "${env.RECEIPTS_BUCKET_NAME}"
accessPrefixes:
- uploads
Three values are yours. Your account number in AWS_ACCOUNT_ID, a RECEIPTS_BUCKET_NAME nobody has
taken, because S3 bucket names are unique across the whole of AWS rather than within your account,
and spec.profile if your credentials do not live under default.
Four sections carry the whole file, and The Definition File covers each one properly. For now:
terraformis the provider setup that would otherwise sit at the top of every.tffile. One entry per account and region you deploy into.importslists the module classes this project uses, and where to import them from.modulesis your infrastructure. Each entry loads one module under amoduleIdof your choosing, with the inputs that module's schema declares.${{...}}is how one module receives what another produced. The account module is handed the app that the app module built, not the string"shop", and that is the difference between a list of four entries in Terraform and a graph in Octo.
Generate
npm run octo:generate
INFO: Started "generate" on "shop". {"appName":"shop","mode":"generate"}
INFO: Wrote 2 terragrunt folder(s) to ".octo-tf". {"emptied":[],"outputDir":".octo-tf","written":["receipts-module","region-module"]}
INFO: Finished "generate" in 5ms. {"appName":"shop","durationInMs":5,"mode":"generate","terraformInMs":0}
Two folders for four modules. Every module contributes to the graph, but only some of them have cloud resources to create:
An app is a name everything else hangs off, and an account is which credentials to use and which provider block to render into the other folders. Neither creates anything. The region and the bucket do.
Look at what it wrote:
.octo-tf/
├── receipts-module/
│ ├── main.tf
│ ├── outputs.tf
│ ├── terragrunt.hcl
│ └── variables.tf
└── region-module/
├── ...
terraform {
required_version = ">= 1.6.0"
required_providers {
aws = {
source = "hashicorp/aws"
}
}
}
provider "aws" {
alias = "_123456789012-eu-west-1"
region = "eu-west-1"
allowed_account_ids = ["123456789012"]
profile = "default"
}
resource "aws_s3_bucket" "bucket-shop-receipts" {
provider = aws._123456789012-eu-west-1
bucket = "shop-receipts"
}
That is ordinary Terraform, with no Octo in it. You can read it and terraform plan it by hand, and
it is checked into your repository so that a reviewer sees the resources a change produced next to
the intent that produced them.
Validate
npm run octo:validate
This runs terraform plan on every folder and then reports the plans against what changed in your
intent. It creates nothing.
shop — octo validate — changes (approximate)
2026-08-19 01:38:21 UTC · 3ms octo · 9.7s terraform
You added region=eu-west-1 (+1 other change). That creates 3 new things.
- 0 destroy ± 0 replace + 3 create ~ 0 update
Your intent changed, so each folder was planned on its own against its dependencies' last
applied outputs. A cross-folder consequence of this change is invisible until apply. This
report can both understate what apply will do and overstate drift.
WHAT WILL CHANGE
+ added region=eu-west-1 [region=eu-west-1,account=123456789012,app=shop | region-module | +2]
+ vpc-eu-west-1
+ aws_vpc.vpc-eu-west-1
+ igw-eu-west-1
+ aws_internet_gateway.igw-eu-west-1
+ added service=shop-receipts-s3-storage [service=shop-receipts-s3-storage,region=eu-west-1,account=123456789012,app=shop | receipts-module | +1]
+ bucket-shop-receipts
+ aws_s3_bucket.bucket-shop-receipts
The three levels are what you asked for, what Octo decided that needs, and what Terraform will
create, so you can start at "I added a region" and follow it down to
aws_internet_gateway.igw-eu-west-1 without leaving the report.
It says approximate because your intent changed on this run. Once your infrastructure and your intent agree, planning becomes exact and the same command reports drift you did not cause. Reading a Validate Report is the page for that.
Apply and commit
LOCK_ID=$(npm run --silent octo:lock)
npm run octo:apply -- --lockId $LOCK_ID
octo lock takes the app lock and prints its ID. Apply and commit are the two steps that write
shared state, so both require it, which is what stops two people writing at once. The commit
releases it, so you take it once per run and never think about it again.
This is the only step that changes anything. The logging listener prints Terragrunt's own output as it arrives, because an apply can run for a long time and its progress is the only sign it is alive. At the end:
INFO: ❯❯ Run Summary 2 units 6s {"command":"apply","processId":1,"stream":"stdout"}
INFO: ──────────────────────────── {"command":"apply","processId":1,"stream":"stdout"}
INFO: Succeeded 2 {"command":"apply","processId":1,"stream":"stdout"}
INFO: Terraform "apply" finished in 6214ms. {"command":"apply","dir":"/home/you/shop/.octo-tf","durationInMs":6214,"exitCode":0}
Then record what happened:
npm run octo:commit -- --lockId $LOCK_ID
shop — octo commit — changes
2026-08-19 01:38:44 UTC · 6ms octo · 4.5s terraform
You added region=eu-west-1 (+1 other change). That created 3 new things.
- 0 destroy ± 0 replace + 3 create ~ 0 update
WHAT CHANGED
+ added region=eu-west-1 [region=eu-west-1,account=123456789012,app=shop | region-module | +2]
+ vpc-eu-west-1
+ aws_vpc.vpc-eu-west-1
+ igw-eu-west-1
+ aws_internet_gateway.igw-eu-west-1
+ added service=shop-receipts-s3-storage [service=shop-receipts-s3-storage,region=eu-west-1,account=123456789012,app=shop | receipts-module | +1]
+ bucket-shop-receipts
+ aws_s3_bucket.bucket-shop-receipts
WHAT OCTO RECORDED — values that moved
@octo/s3-storage=bucket-shop-receipts Arn: - → arn:aws:s3:::shop-receipts
@octo/vpc=vpc-eu-west-1 VpcArn: - → arn:aws:ec2:eu-west-1:123456789012:vpc/vpc-0d8d555c656b950ad
@octo/vpc=vpc-eu-west-1 VpcId: - → vpc-0d8d555c656b950ad
@octo/internet-gateway=igw-eu-west-1 InternetGatewayArn: - → arn:aws:ec2:eu-west-1:123456789012:internet-gateway/igw-046856f4d8c6a86dd
@octo/internet-gateway=igw-eu-west-1 InternetGatewayId: - → igw-046856f4d8c6a86dd
The last block is what commit is for. Only AWS could say what the VPC's id is, and it said so to Terraform. Commit is the step that copies those answers back into Octo, so that the next run knows the bucket in your intent is that bucket in AWS. Apply is pure Terragrunt and has never heard of Octo, which is why reading the results back is a command of its own rather than the tail end of the apply.
Your bucket exists:
aws s3 ls | grep shop-receipts
Give checkout access to it
Checkout needs to read and write the uploads prefix in the receipts bucket. Add the server to
octo.yaml, under imports:
- className: AwsEcsServerModule
importPath: "@quadnix/octo-aws-cdk/modules/server/aws-ecs-server"
and at the end of modules:
- moduleId: checkout-server-module
moduleClass: AwsEcsServerModule
moduleInputs:
account: "${{account-module.model.account}}"
serverKey: checkout
s3:
- service: "${{receipts-module.model.service}}"
accessPrefixes:
- access: READ_WRITE
accessPrefix: uploads
There is no policy document in there, and no ARN. The server module is handed the receipts service
itself, and READ_WRITE on a prefix in it. Which S3 actions that means is the server module's
business.
npm run octo:generate && npm run octo:validate
You added server=checkout (+1 other change). That creates 5 new things.
- 0 destroy ± 0 replace + 5 create ~ 0 update
WHAT WILL CHANGE
+ added server=checkout [server=checkout,app=shop | checkout-server-module | +4]
+ iam-role-ServerRole-checkout
+ aws_iam_policy.iam-role-ServerRole-checkout_aws-ecs-server-s3-access-overlay-937a53bb02a4
+ aws_iam_role_policy_attachment.iam-role-ServerRole-checkout_AmazonECSTaskExecutionRolePolicy
+ aws_iam_role_policy_attachment.iam-role-ServerRole-checkout_aws-ecs-server-s3-access-overlay-937a53bb02a4_attach
+ aws_iam_role.iam-role-ServerRole-checkout
+ added service=shop-receipts-s3-storage [service=shop-receipts-s3-storage,region=eu-west-1,account=123456789012,app=shop | receipts-module | +1]
~ bucket-shop-receipts
+ aws_s3_bucket_policy.bucket-shop-receipts-policy
Five Terraform resources from those eleven lines, and they land in two different folders. Checkout's folder gets the role, the managed policy, and both attachments. The receipts folder gets a bucket policy naming that role. AWS wants the grant written on both sides, so the module that owns servers writes the role half and the module that owns buckets writes the bucket half, and one request produced both.
The actions are in the generated file, if you want to see exactly what READ_WRITE bought:
resource "aws_iam_policy" "iam-role-ServerRole-checkout_aws-ecs-server-s3-access-overlay-937a53bb02a4" {
provider = aws._123456789012-eu-west-1
name = "aws-ecs-server-s3-access-overlay-937a53bb02a4"
policy = jsonencode({
Statement = [{
Action = ["s3:GetObject", "s3:GetObjectAttributes", "s3:GetObjectTagging", "s3:GetObjectVersionAcl", "s3:GetObjectVersionAttributes", "s3:GetObjectVersionTagging"]
Effect = "Allow"
Resource = ["arn:aws:s3:::shop-receipts/uploads", "arn:aws:s3:::shop-receipts/uploads/*"]
Sid = "Allowreadfrombucketshopreceipts"
}, {
Action = ["s3:ListBucket"]
Effect = "Allow"
Resource = ["arn:aws:s3:::shop-receipts"]
Sid = "Allowlistfrombucketshopreceipts"
Condition = {
StringLike = {
"s3:prefix" = ["uploads", "uploads/*"]
}
}
}, {
Action = ["s3:PutObject", "s3:PutObjectTagging", "s3:DeleteObjectVersion", "s3:DeleteObject", "s3:DeleteObjectTagging"]
Effect = "Allow"
Resource = ["arn:aws:s3:::shop-receipts/uploads/*"]
Sid = "Allowwritefrombucketshopreceipts"
}]
Version = "2012-10-17"
})
}
Six actions for read, three for write, and listing on its own. Listing is separate because
s3:ListBucket is answered against the bucket rather than against an object, so the prefix it is
confined to has to be written as a condition. Somebody worked that out once, inside the server
module, and every service that ever asks for READ_WRITE gets the same answer.
The two folders also have to be applied in a particular order, since the bucket policy names a role that does not exist yet. Nobody wrote that ordering down. Octo emitted it, into the receipts folder:
dependency "checkout-server-module" {
config_path = "../checkout-server-module"
mock_outputs = {
"iam-role-ServerRole-checkout-Arn" = "arn:aws:iam::000000000000:role/mock-role"
}
mock_outputs_allowed_terraform_commands = ["init", "plan", "show", "validate"]
mock_outputs_merge_strategy_with_state = "shallow"
}
inputs = {
iam_role_ServerRole_checkout_Arn = dependency.checkout-server-module.outputs["iam-role-ServerRole-checkout-Arn"]
}
The mock_outputs block is what lets the receipts folder be planned before the role it names
exists, and it is why the report above called itself approximate: each folder is planned on its own,
against whatever its dependencies last produced.
Apply and record it:
LOCK_ID=$(npm run --silent octo:lock)
npm run octo:apply -- --lockId $LOCK_ID
npm run octo:commit -- --lockId $LOCK_ID
Tear it down
You remove infrastructure by removing it from your intent. Comment out the region, receipts, and
checkout modules in octo.yaml, leaving the app and account modules in place, and run the same
commands again:
npm run octo:generate
LOCK_ID=$(npm run --silent octo:lock)
npm run octo:apply -- --lockId $LOCK_ID --auto-approve
npm run octo:commit -- --lockId $LOCK_ID
Without --auto-approve, the apply refuses and shows you what it would have removed:
This apply would DESTROY:
[checkout-server-module] aws_iam_role.iam-role-ServerRole-checkout (delete)
[receipts-module] aws_s3_bucket.bucket-shop-receipts (delete)
[region-module] aws_vpc.vpc-eu-west-1 (delete)
...
Error: Refusing to apply. Run "octo validate" to see why each of these is being destroyed, then re-run with --auto-approve to proceed!
The commit at the end is not optional here either. Destroying Infrastructure explains why, and what Octo does with a module folder whose module no longer exists.