Static Website
This page builds a static website and puts it on the internet. By the end you will have two HTML pages served out of an S3 bucket, on a public URL you can open in a browser.
Four modules describe it, a module being one of the building blocks the introduction described, packaged so it can be named in a file and given inputs. These four:
An app to hold the others together, the AWS account everything is created in, a region saying where in AWS the website goes, and the website itself, which is a service.
A bucket holding two small HTML files costs a fraction of a cent per month, and nothing at all under the AWS free tier. The teardown at the end removes it.
This is the exact same mechanism through which this documentation website is hosted as well.
The scaffold
If you followed Installation, this project
already exists and you can skip the command. Otherwise it is one line, and the -t flag picks which
starting file you get:
npx @quadnix/octo-build create-app -t aws-s3-website -n hello-website
cd hello-website
What comes out is a small Node project. Two things in it matter here, the definition file and the website:
hello-website/
├── octo.yaml
├── package.json
├── ...
└── website/
├── error.html
└── index.html
Two HTML files, and they are the website. The module points at that directory and uploads what it finds there, so anything you add to it ships on the next run.
What to change
Open octo.yaml. It is 70 lines, nearly all of them filled in by the template. Two values and a
profile are yours to set, and the rest of this section is what the template already wired up.
The env block at the top holds values the rest of the file refers to. It ships with a placeholder
account number and the bucket name my-website-unique-bucket, and both need replacing:
env:
- key: AWS_ACCOUNT_ID
kind: string
value: "123456789012" # your AWS account number
- key: AWS_S3_WEBSITE_BUCKET_NAME
kind: string
value: octo-hello-website # your own name, not this one
S3 bucket names are unique across the whole of AWS rather than within your account, so the second
one has to be a name no other customer is using. Do not copy octo-hello-website; it is the name
this page was captured with, and it appears in every block below where yours goes.
The terraform block declares the provider Octo writes into every generated folder. If your
credentials live under a profile other than default, change spec.profile:
terraform:
requiredVersion: "1.6.0"
requiredProviders:
aws:
source: hashicorp/aws
providers:
- providerType: aws
accountId: "${env.AWS_ACCOUNT_ID}"
regionId: us-east-1
spec:
profile: default
${env.AWS_ACCOUNT_ID} reads the env block above. The value there is a default:
OCTO_ENV_AWS_ACCOUNT_ID in your shell wins over it, which is how one file works on a laptop and in
CI.
The modules block is the part that decides what gets built:
modules:
- moduleId: app-module
moduleClass: SimpleAppModule
moduleInputs:
name: aws-s3-website
- moduleId: account-module
moduleClass: AwsIniAccountModule
moduleInputs:
accountId: "${env.AWS_ACCOUNT_ID}"
app: "${{app-module.model.app}}"
- moduleId: region-module
moduleClass: AwsServerlessRegionModule
moduleInputs:
account: "${{account-module.model.account}}"
name: aws-s3-website
regionId: aws-us-east-1
- moduleId: s3-website-service-module
moduleClass: AwsS3StaticWebsiteServiceModule
moduleInputs:
bucketName: "${env.AWS_S3_WEBSITE_BUCKET_NAME}"
directoryPath: website
region: "${{region-module.model.region}}"
The last module is where the work happens: AwsS3StaticWebsiteServiceModule produces every cloud
resource on this page. The first two name the application, whose name is yours to pick, and the AWS
account it deploys into, read from ~/.aws/credentials, which is the Ini in the account module's
name. The region creates nothing; it says which AWS region the bucket goes in. Each module is
given what an earlier one made, and ${{...}} is how: two braces, a module ID, and what to take
from it.
A model is an intent node, so that reference means
the app node app-module created, the top box in the diagram. The list is flat, and those
references are what make it the graph the introduction showed. The Definition
File has the full list of what can be referenced that way.
The AWS words in this block belong to the modules rather than to the app, account, and service they
produce, because a module is where a cloud gets chosen. A class name in moduleClass is bound to a
package by the imports block further up the file,
which the template filled in for these four. The AWS Module
Catalog lists the rest, and the inputs each one takes.
directoryPath: website is relative to where you run the commands, which is the project root.
To deploy somewhere other than us-east-1, change the region in two places, regionId in the
provider and regionId in the region module, written there as aws-us-east-1, because the first
says where Terraform authenticates and the second says where the bucket goes.
The run
The four commands, in order, with one more in front of the last two:
npm run octo:generate
npm run octo:validate
LOCK_ID=$(npm run --silent octo:lock)
npm run octo:apply -- --lockId $LOCK_ID
npm run octo:commit -- --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 runs writing at once. The commit releases
it, so you take it once and never think about it again.
generate reads the definition file and writes Terraform. It needs no credentials, and touches
nothing outside your project. One folder comes out, because one of the four modules has cloud
resources to create:
INFO: Started "generate" on "aws-s3-website". {"appName":"aws-s3-website","mode":"generate"}
INFO: Wrote 1 terragrunt folder(s) to ".octo-tf". {"emptied":[],"outputDir":".octo-tf","written":["s3-website-service-module"]}
INFO: Finished "generate" in 7ms. {"appName":"aws-s3-website","durationInMs":7,"mode":"generate","terraformInMs":0}
validate plans that folder and reports the plan back against your intent. This is the first
command that reaches AWS: Terraform reads your credentials to produce a plan, and creates nothing.
The first run is slower than the timings below, because Terragrunt initializes the folder and
downloads the AWS provider before Terraform can plan.
aws-s3-website — octo validate — changes (approximate)
2026-09-29 01:14:01 UTC · 4ms octo · 4.0s terraform
You added service=octo-hello-website-s3-static-website. That creates 6 new things.
- 0 destroy ± 0 replace + 6 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 service=octo-hello-website-s3-static-website [service=octo-hello-website-s3-static-website,region=aws-s3-website,account=123456789012,app=aws-s3-website | s3-website-service-module | +6]
+ bucket-octo-hello-website
+ aws_s3_bucket_policy.bucket-octo-hello-website_website_bucket_policy
+ aws_s3_bucket_public_access_block.bucket-octo-hello-website_website_bucket_public_access
+ aws_s3_bucket_website_configuration.bucket-octo-hello-website_website_bucket_config
+ aws_s3_bucket.bucket-octo-hello-website_website_bucket
+ aws_s3_object.bucket-octo-hello-website_file_errorhtml
+ aws_s3_object.bucket-octo-hello-website_file_indexhtml
The report has three levels. Outermost is the piece of intent you asked for, the website service,
followed in brackets by its identity, the module that produced it, and the number of Terraform
changes underneath it. Below that is one Octo resource, bucket-octo-hello-website, an Octo
resource being the unit Octo tracks and compares between runs. Below that again are the six
Terraform resources that build it. The top line names the request and the numbers count what the
request takes.
The names are derived from the inputs you gave, which is why the bucket name runs through all of
them, and why the service reads octo-hello-website-s3-static-website here while the diagram
above calls it by the bucket name alone.
Serving a public website from S3 takes all six: the bucket has to exist, be told which file is the
index and which is the error page, have its public access block opened, and carry a policy allowing
anonymous s3:GetObject. The order matters as well. Leave the public access block at its default
and AWS refuses the policy that comes after it, and the site answers 403 to everyone. That sequence
is what the module already knows.
It ends up in the generated folder as ordinary Terraform, in depends_on:
resource "aws_s3_bucket_website_configuration" "bucket-octo-hello-website_website_bucket_config" {
provider = aws._123456789012-us-east-1
bucket = aws_s3_bucket.bucket-octo-hello-website_website_bucket.id
error_document {
key = "error.html"
}
index_document {
suffix = "index.html"
}
depends_on = [aws_s3_bucket.bucket-octo-hello-website_website_bucket]
}
resource "aws_s3_object" "bucket-octo-hello-website_file_indexhtml" {
provider = aws._123456789012-us-east-1
bucket = aws_s3_bucket.bucket-octo-hello-website_website_bucket.id
content_type = "text/html"
etag = filemd5("${var.OCTO_PROJECT_ROOT}/website/index.html")
key = "index.html"
source = "${var.OCTO_PROJECT_ROOT}/website/index.html"
}
The provider line names the provider from your terraform block, one per account and region, and
Octo writes it on every resource it generates. Terraform resources belonging to the same Octo
resource are chained with depends_on in the order the module declared them, which is the order the
bucket and its settings require. The files stay out of that chain: each one waits only on the bucket
it names, so they upload in parallel.
Those two files are the last two resources in the report, one aws_s3_object each, with
content_type looked up from the extension and etag a checksum of the file on disk. Add a
stylesheet and there are seven.
var.OCTO_PROJECT_ROOT is how a generated folder names a file on your disk without writing down a
path that only exists on your machine. Octo declares the variable in variables.tf and fills it in
when it runs Terragrunt, so the folder reads the same for everyone who checks it out.
apply creates them. It never stops to ask, because Terraform runs with -auto-approve underneath.
What scrolls past is Terragrunt's own output, resource by resource, and the command ends with
Terraform "apply" finished in …ms.; this stack takes under a minute, where one with a load balancer in it
takes several. The one time apply stops is when something would be destroyed, which is Octo's check
rather than Terraform's, and the teardown at the end of this page runs into it. If an apply stops
part way, fix what it complained about and run it again with the same lock;
Recovering a Failed Run covers the rest.
commit reads the outputs Terraform recorded and writes them into .octo/, where Octo keeps its
picture of your infrastructure. It is a separate command because apply is pure Terragrunt and knows
nothing about Octo, so reading the results back is a step of its own. Skip it and the next
generate plans against a world where none of this exists.
WHAT OCTO RECORDED — values that moved
@octo/s3-website=bucket-octo-hello-website Arn: - → arn:aws:s3:::octo-hello-website
@octo/s3-website=bucket-octo-hello-website awsRegionId: - → us-east-1
@octo/s3-website=bucket-octo-hello-website RegionalDomainName: - → octo-hello-website.s3.us-east-1.amazonaws.com
@octo/s3-website is the type of the resource and bucket-octo-hello-website is its ID, the same
one the validate report grouped six Terraform resources under. Every value there was unknown until
AWS answered.
Open it
An S3 website endpoint address is the bucket name, the words s3-website, and
the region, so this bucket in us-east-1 is:
http://octo-hello-website.s3-website-us-east-1.amazonaws.com
Ask for a path that does not exist and error.html comes back with a 404, which is what the
error_document in the generated Terraform bought you.
In another region the separator before the region may be a dot rather than a dash, because AWS changed the form and kept the old one working for the regions that had it. The bucket's Properties tab in the S3 console prints the exact endpoint.
This endpoint is HTTP only. S3 website hosting does not terminate TLS, so a real site needs CloudFront in front of it. That is a different pair of modules, a closed bucket holding the files and a distribution serving them, which the AWS Module Catalog describes. The certificate the distribution serves is still yours to create.
When it fails
Three things go wrong often enough to name, and each surfaces at a different step.
-
validatefails before it plans anything when the credentials are not there: the AWS provider cannot configure itself. Checkspec.profileagainst~/.aws/credentials, and thataws sts get-caller-identity --profile <the one you named>prints the account number you put inAWS_ACCOUNT_ID. Without--profilethat command reads your default profile, which is a different account from the one Octo is using. -
applyfails when the bucket name is taken. A plan cannot know that, because names are checked at creation, sovalidatepasses and AWS rejects the create withBucketAlreadyExists. Pick another name inAWS_S3_WEBSITE_BUCKET_NAMEand start again fromgenerate. -
The site answers 403 to everyone, with the apply having succeeded, when S3 Block Public Access is turned on for the whole account. The module opens the block on its own bucket, and the account-level setting overrides it. It is a control an administrator turns on rather than a default, so it mostly shows up in company accounts, and if it is on for a reason, a bucket serving a public website is not the argument to reverse it.
Change a page
Edit website/index.html, add a line, and run generate and validate again:
<h1>Hello from hello-website</h1>
<p>Deployed with Octo.</p>
You changed service=octo-hello-website-s3-static-website. That updates 1 thing in place.
- 0 destroy ± 0 replace + 0 create ~ 1 update
WHAT WILL CHANGE
~ changed service=octo-hello-website-s3-static-website [service=octo-hello-website-s3-static-website,region=aws-s3-website,account=123456789012,app=aws-s3-website | s3-website-service-module | ~1]
~ bucket-octo-hello-website
~ aws_s3_object.bucket-octo-hello-website_file_indexhtml
etag: 43d707bebe2010beda60ccf02d98b646 → 0115cb00cb5d5142657ee12ce20c0bad
One resource, one field, and the two checksums that differ. The bucket, the policy, the access block, and the other file are left alone, because Octo compared the file it uploaded last time with the file on disk now.
The heading says changed because the resource under it is updated, not created. Reading a
Validate Report covers the markers in full.
LOCK_ID=$(npm run --silent octo:lock)
npm run octo:apply -- --lockId $LOCK_ID
npm run octo:commit -- --lockId $LOCK_ID
Reload the page and the new line is there.
Tear it down
Removing infrastructure takes the same commands. Comment out the region and website modules in
octo.yaml, leaving the app and account modules:
#- moduleId: region-module
# moduleClass: AwsServerlessRegionModule
# moduleInputs:
# account: "${{account-module.model.account}}"
# name: aws-s3-website
# regionId: aws-us-east-1
#- moduleId: s3-website-service-module
# moduleClass: AwsS3StaticWebsiteServiceModule
# moduleInputs:
# bucketName: "${env.AWS_S3_WEBSITE_BUCKET_NAME}"
# directoryPath: website
# region: "${{region-module.model.region}}"
npm run octo:generate
npm run octo:validate
LOCK_ID=$(npm run --silent octo:lock)
npm run octo:apply -- --lockId $LOCK_ID --auto-approve
npm run octo:commit -- --lockId $LOCK_ID
validate is worth running here, because it says why each resource is going away. And apply needs
--auto-approve this time. Without it, an apply that would destroy anything stops and prints what
it was about to do:
This apply would DESTROY:
[s3-website-service-module] aws_s3_bucket_policy.bucket-octo-hello-website_website_bucket_policy (delete)
[s3-website-service-module] aws_s3_bucket_public_access_block.bucket-octo-hello-website_website_bucket_public_access (delete)
[s3-website-service-module] aws_s3_bucket_website_configuration.bucket-octo-hello-website_website_bucket_config (delete)
[s3-website-service-module] aws_s3_bucket.bucket-octo-hello-website_website_bucket (delete)
[s3-website-service-module] aws_s3_object.bucket-octo-hello-website_file_errorhtml (delete)
[s3-website-service-module] aws_s3_object.bucket-octo-hello-website_file_indexhtml (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 URL stops answering, and the commit at the end reports the module as one that no longer renders anything:
MODULES THAT NO LONGER RENDER ANYTHING
s3-website-service-module
The destroy happened because that folder is still there with its Terraform emptied out: Terragrunt applied a configuration declaring no resources, so everything the folder held was removed. The commit then deleted the folder, since its state holds nothing. Destroying Infrastructure explains why Octo empties a folder rather than deleting it.