This repository contains the public multi-page company and product marketing site for bwtr.ai.
It is intentionally separate from the Breakwater platform source repository.
Production hosting should stay in AWS, consistent with the rest of Breakwater's public infrastructure:
bwtr.aiandwww.bwtr.ai-> Route 53 -> CloudFront -> private S3 bucketapp.bwtr.airemains on the production application infrastructureinstall.bwtr.airemains on the installer CloudFront/S3 infrastructurelicense.bwtr.airemains on the license CloudFront/Lambda infrastructure
The concise operator runbook and pinned local fallback are in deploy/aws/README.md and
deploy/aws/deploy.sh.
The GitHub Actions workflow at .github/workflows/deploy-aws.yml deploys this static site to AWS by:
- Assuming an AWS IAM role through GitHub OIDC.
- Building an explicit public-site artifact that contains no repository operations files.
- Publishing that artifact to S3 while preserving S3-only product-demo videos and prior content-addressed assets through the release soak window.
- Invalidating the CloudFront distribution.
Large product-demo videos are stored directly in S3 under assets/videos/ and are intentionally
not committed to this repository. The deploy workflow excludes that prefix from content uploads.
It does not delete superseded objects during deployment; cleanup happens separately after the
rollback window. Repository-only files never enter the generated artifact.
Required repository configuration in BreakwaterAI/bwtr.ai:
| Variable | Example | Purpose |
|---|---|---|
AWS_REGION |
us-east-1 |
AWS region used by the deploy workflow. |
DEPLOY_TARGET |
preview or production |
Selects the exact alias and smoke-test contract. |
AWS_ACCOUNT_ID |
506126099258 |
Exact AWS account that the workflow must assume. |
AWS_ACM_CERTIFICATE_ARN |
arn:aws:acm:us-east-1:... |
Exact certificate required after production aliases are attached. |
AWS_S3_BUCKET |
bwtr-ai-site-prod |
Private S3 bucket for static site files. |
AWS_CLOUDFRONT_DISTRIBUTION_ID |
E123EXAMPLE |
CloudFront distribution to invalidate after deploy. |
AWS_CLOUDFRONT_DOMAIN |
d123example.cloudfront.net |
Distribution hostname used to bind deployment and validation targets. |
AWS_OIDC_SUBJECT |
repo:ORG@ORG_ID/REPOSITORY@REPOSITORY_ID:ref:refs/heads/main |
Exact GitHub OIDC subject that must match the deploy role trust. |
SITE_BASE_URL |
https://d123example.cloudfront.net |
Exact URL validated after publishing; it must match AWS_CLOUDFRONT_DOMAIN. |
| Secret | Purpose |
|---|---|
AWS_ROLE_TO_ASSUME |
ARN of the deploy IAM role trusted by GitHub OIDC. |
infra/cloudformation/static-site.yml creates:
- Private encrypted S3 bucket
- CloudFront distribution with Origin Access Control
- Security response headers
- Optional Route 53 aliases for
bwtr.aiandwww.bwtr.ai
You need an issued ACM certificate in us-east-1 covering both:
bwtr.aiwww.bwtr.ai
For a non-live migration preview, deploy without aliases, DNS records, or an ACM certificate:
test "$(aws sts get-caller-identity \
--profile breakwater-prod \
--query Account \
--output text)" = "506126099258" && \
aws cloudformation deploy \
--profile breakwater-prod \
--region us-east-1 \
--stack-name bwtr-ai-static-site-preview \
--template-file infra/cloudformation/static-site.yml \
--parameter-overrides \
AttachCustomDomains=false \
CreateRoute53Records=false \
BucketName=bwtr-ai-site-prod-506126099258Test the PreviewUrl stack output before attaching the production aliases.
Keep DEPLOY_TARGET=preview and SITE_BASE_URL=https://<CloudFrontDomainName> until the alias
transfer is complete. Then change them together to production and https://www.bwtr.ai.
Reconcile the existing target stack after the aliases have moved:
test "$(aws sts get-caller-identity \
--profile breakwater-prod \
--query Account \
--output text)" = "506126099258" && \
aws cloudformation deploy \
--profile breakwater-prod \
--region us-east-1 \
--stack-name bwtr-ai-static-site-preview \
--template-file infra/cloudformation/static-site.yml \
--parameter-overrides \
AttachCustomDomains=true \
UseCustomCertificate=true \
CreateRoute53Records=false \
CertificateArn=arn:aws:acm:us-east-1:506126099258:certificate/3ba2f2a9-fb4e-44a9-a9ae-fa6456fbaff4 \
BucketName=bwtr-ai-site-prod-506126099258After stack creation, copy the stack outputs into the GitHub Actions variables listed above. Then update only the exact Route 53 records for bwtr.ai and www.bwtr.ai to alias to the CloudFrontDomainName output. Leave app.bwtr.ai, install.bwtr.ai, and license.bwtr.ai unchanged.
If you want CloudFormation to create the apex and www aliases, add these parameters and make sure no unmanaged exact records already exist for bwtr.ai or www.bwtr.ai:
CreateRoute53Records=true \
HostedZoneId=YOUR_BWTR_AI_HOSTED_ZONE_IDCreate the account-level GitHub Actions OIDC provider once in a new AWS account:
test "$(aws sts get-caller-identity \
--profile breakwater-prod \
--query Account \
--output text)" = "506126099258" && \
aws cloudformation deploy \
--profile breakwater-prod \
--region us-east-1 \
--stack-name github-actions-oidc-provider \
--template-file infra/cloudformation/github-oidc-provider.ymlAfter the static-site stack is created, deploy the least-privilege GitHub OIDC role:
test "$(aws sts get-caller-identity \
--profile breakwater-prod \
--query Account \
--output text)" = "506126099258" && \
aws cloudformation deploy \
--profile breakwater-prod \
--region us-east-1 \
--stack-name bwtr-ai-github-deploy-role \
--template-file infra/cloudformation/github-deploy-role.yml \
--parameter-overrides \
BucketName=bwtr-ai-site-prod-506126099258 \
CloudFrontDistributionId=E173Y881SRDFT0 \
RepositorySubject=BreakwaterAI@323852433/bwtr.ai@1234382108 \
--capabilities CAPABILITY_NAMED_IAMThe deploy-role template assumes the account already has the GitHub OIDC provider:
token.actions.githubusercontent.com
It limits trust to this repository and branch:
repo:BreakwaterAI@323852433/bwtr.ai@1234382108:ref:refs/heads/main
The numeric values are GitHub's immutable organization and repository IDs. For another customer
repository, obtain them with gh api orgs/ORG --jq .id and
gh api repos/ORG/REPOSITORY --jq .id, then set RepositorySubject to
ORG@ORG_ID/REPOSITORY@REPOSITORY_ID and set the AWS_OIDC_SUBJECT Actions variable to
repo:ORG@ORG_ID/REPOSITORY@REPOSITORY_ID:ref:refs/heads/main.
Copy the RoleArn output into the AWS_ROLE_TO_ASSUME GitHub Actions secret.
index.html: CISO-facing company narrative, decision gaps, ASOC overview, and contactproducts/index.html: Breakwater ASOC and its Secure, Assure, and SOAR modules, served at/products/architecture/index.html: executive reference architecture, trust boundaries, and deployment patterns, served at/architecture/platform/index.html: browser fallback from the retired/platform/route to/architecture/research/index.html: research themes, Cyber Analytics, and product translation, served at/research/about/index.html: company principles, founders, and research connection, served at/about/security/index.html: vulnerability disclosure guidance, served at/security/airports/index.html: how Secure, Assure, and SOAR apply to airport and transportation environments, served at/airports/power-utilities/index.html: how Secure, Assure, and SOAR apply to power and utility environments, served at/power-utilities/connected-industry/index.html: how Secure, Assure, and SOAR apply to connected industrial environments, served at/connected-industry/healthcare/index.html: how Secure, Assure, and SOAR apply to healthcare environments, served at/healthcare/404.html: real not-found response body.well-known/security.txt: machine-readable security contact metadata
The site includes a branded company inquiry form. It posts to a Google Apps Script Web App, which appends submissions to a Google Sheet. This keeps lead capture simple without adding an AWS API, database, or sales platform.
Setup:
- Create a Google Sheet named
Breakwater Website Leads. - In the Sheet, open
Extensions->Apps Script. - Paste the contents of
google-apps-script/Code.gs. - Deploy it as a Web App:
- Execute as:
Me - Who has access:
Anyone
- Execute as:
- Copy the Web App URL into the
FORM_ENDPOINTfallback inscript.js, or setwindow.BREAKWATER_FORM_ENDPOINTbefore loadingscript.js.
The script writes submissions to a Leads sheet and includes a hidden honeypot field named
website for basic spam filtering. Submitted fields are length-limited and escaped before they
reach the sheet so user-controlled values cannot be interpreted as spreadsheet formulas. The
public form discloses that Google Apps Script and a Breakwater-managed Google Sheet process the
contact details.
Public pages use directory-style canonical URLs. The CloudFront viewer-request function redirects
legacy .html, extensionless, explicit index.html, apex-domain, and CloudFront-domain variants
to the matching https://www.bwtr.ai/<page>/ URL. It then rewrites the canonical directory request
to the corresponding S3 index.html object without changing the browser-visible URL.
The retired /platform, /platform/, /platform.html, and /platform/index.html variants redirect
to the canonical /architecture/ route. Follow the staged rollout order below so the destination is
available before the edge begins returning those HTTP 301 responses.
Production rollout order:
- Before changing S3 or CloudFront, record the current stack template and distribution configuration, and capture an immutable S3 inventory or download of the current object versions.
- Build and validate a release artifact locally. Pre-stage only its content-addressed
styles.*.cssandscript.*.jsfiles plusarchitecture/index.html. Do not replace the homepage or any other existing HTML during this phase. - Create and inspect the CloudFormation change set. Execute it, wait for CloudFront to finish
deploying, then verify that
/architectureredirects to/architecture/,/platform/redirects to/architecture/, and the destination returns HTTP 200 with the expected release marker. - Trigger the normal site workflow. Its preflight requires that migration-safe state before it takes a rollback snapshot or changes any public object. Its rollback therefore preserves a working Architecture destination even though the new edge redirect remains active.
- Verify canonical redirects, query preservation, genuine 404 responses, immutable asset headers, all primary pages, and the inquiry form. Retain the baseline artifacts through the soak window.
The repository tests validate artifact integrity, canonical routing, required cache policies, deployment phase ordering, and rollback behavior. The publish test uses mocked AWS and HTTP commands to inject failures before each material release phase and during rollback; it confirms that the snapshot is restored without deleting superseded objects. This does not replace an AWS staging exercise for IAM, network, and CloudFront service behavior. Retain the pre-release S3 snapshot and CloudFront configuration for every production rollout.
During the initial soak, permanent redirects use a five-minute cache lifetime so a stack rollback
can take effect promptly. A normal content-publish failure restores the workflow snapshot, which
already includes the valid Architecture destination. To reverse the route migration itself, restore
and fully deploy the preceding CloudFront function first; only then restore the pre-stage S3 object
versions and remove architecture/index.html. Invalidate /* and verify both canonical and legacy
URLs. Never remove architecture/index.html while the edge redirect remains active. S3 versioning
remains enabled as an additional object-recovery path.
Only bwtr.ai and www.bwtr.ai should point to the new marketing-site CloudFront distribution.
Do not change these production subdomains while deploying the marketing site:
app.bwtr.aiinstall.bwtr.ailicense.bwtr.ai
python3 -m http.server 4177Then open:
http://127.0.0.1:4177/
node scripts/test-brand-contract.mjs
node scripts/test-canonical-urls.mjs
node scripts/test-homepage-content.mjs
node scripts/test-positioning.mjs
node scripts/test-deployment-target.mjs
# With the local preview running; set CHROME_PATH when Chrome is not in a standard location.
node scripts/test-rendered-layout.mjs
bash scripts/test-publish-site.sh
bash scripts/test-deploy-entrypoint.sh
artifact_parent="$(mktemp -d)"
bash scripts/build-site-artifact.sh "${artifact_parent}/site"
node scripts/test-site-artifact.mjs "${artifact_parent}/site"
node scripts/test-brand-contract.mjs --artifact "${artifact_parent}/site"
git diff --check