Overview
The static-site pattern deploys a folder of built files (a Next.js export, a Vite build, an Astro site, hand-written HTML) to AWS with production hardening you would otherwise assemble by hand:- Private S3 bucket. No public bucket policy. CloudFront reaches it via Origin Access Control (OAC).
- CloudFront CDN. HTTPS, HTTP/2 and HTTP/3, global edge caching.
- Clean URLs.
/aboutservesabout.htmlorabout/index.htmlto match your build output, or falls back toindex.htmlfor SPAs. - Security headers. HSTS,
nosniff, frame denial, referrer policy. - Custom domain. ACM certificate and Route53 alias record, created automatically.
- Contact form. An optional
/api/contactendpoint (Lambda Function URL to SES) so a static site can still receive enquiries.
The pattern serves the files your build produces. It does not run a server.
If your site needs SSR or API routes beyond the contact form, use the
Payload pattern instead.
Quick Start
1. Have a build that emits static files
Any tool works, as long as one command produces a folder of files:2. Create the Fjall application
--source, --build-command and
--output-dir.
Prefer prompts? Run fjall create, choose Patterns, then Static site.
The flow asks for name, build directory, build command, output directory,
routing mode, custom domain and contact form. fjall create app on its own
opens the six-tier application picker, which lists no patterns.
Paths are written into
fjall/my-site/infrastructure.ts and resolved from
that directory at deploy time. --source ../.. points at the repo root, and
--output-dir is relative to --source.3. Deploy
source, synthesises the CDN stack, and
uploads the contents of the output folder to S3. The deploy output includes the
CloudFront URL.
What’s Included
No Docker image is built and no ECR repository is created. The deploy artefact
is the static files themselves.
Routing Modes
Pick the mode that matches your build output. Astro’s default layout is
directory. Astro with build.format: "file" and Next.js output: "export"
produce multipage. fjall apps detect reads Astro’s build.format and
prefills the matching mode. Set the mode with --routing <mode> at create
time, or routing: "<mode>" in infrastructure.ts.
Requests under /api/ and paths containing /. (such as /.well-known/...)
are never rewritten in any mode.
Deployment Process
First deploy
- Detects
type: "staticsite"ininfrastructure.ts - Runs your build command in
source(10-minute budget) - Synthesises and deploys the single CDN stack
- Uploads the build output to S3 (stale files from previous builds are pruned)
- Prints the CloudFront and custom-domain URLs
Subsequent deploys
The same command rebuilds and re-uploads. Only changed files transfer, and pruning keeps the bucket in lockstep with the latest build, so a deleted page disappears from S3 rather than lingering. After the upload, the deployment invalidates the CloudFront distribution (/*), so new content serves immediately instead of after the edge cache TTL
(up to 24 hours) expires. AWS grants 1,000 free invalidation paths per month,
and /* counts as one path per deploy. For pure TTL semantics, set
cdn: { invalidateOnDeploy: false }.
Custom Domain
domain is set, the pattern needs to know which hosted zone the
domain lives in. Zone identity is always explicit, never guessed from the
domain’s labels (label-guessing mis-resolved .com.au-style domains and
delegated sub-zones):
- Fjall-managed domain. Nothing extra to configure. The CLI injects a
managed-domain binding at deploy time with literal values: the hosted zone ID
and the domain stack’s
us-east-1certificate ARN. CloudFront uses that certificate directly, so redeploying the site never churns a per-site certificate, and the binding works across accounts and regions. - Bring-your-own zone. Set
zoneNameto the Route53 hosted zone that containsdomain, plushostedZoneIdto skip the runtime lookup. The pattern creates a DNS-validated ACM certificate inus-east-1and attaches it to CloudFront.
domain relative to the zone (example.com in zone example.com is the apex,
www.example.com is www).
Requires: a Route53 hosted zone containing the domain. See
Domain for creating, importing, or adopting
zones with fjall domain.
Contact Forms
A static site usually still needs one dynamic thing: a way for visitors to get in touch. Settingforms provisions a Lambda behind
https://<domain>/api/contact that emails submissions via SES.
The submitter’s own address goes in Reply-To, so replying from your inbox
reaches them directly.
Wiring a form
POST JSON or form-encoded data withname, email, and message fields.
Include an empty _gotcha field as a honeypot. Bots that fill it get a silent
success and no email is sent.
Abuse posture
The endpoint is public and unauthenticated, so the pattern bounds what a caller can do rather than pretending to authenticate them:- Fixed sender and recipient. Whatever a caller sends, the email can only
ever go from
noreply@<domain>to your configured address. - Origin gate and CORS. Browsers on other sites cannot POST to it. The
allow-origin defaults to
https://<domain>(override with--cors-origin). - Honeypot. A filled
_gotchashort-circuits to a silent success. - Reserved concurrency. The Lambda is capped (default 5 concurrent executions), so a flood cannot run up the SES bill or drain account-wide Lambda concurrency.
- Scoped IAM. The Lambda may call
ses:SendEmailonly for the site’s domain identity, conditioned on the exactFromaddress.
Security Headers
Strict-Transport-Security (max-age 730 days),
X-Content-Type-Options: nosniff, X-Frame-Options: DENY, and
Referrer-Policy: strict-origin-when-cross-origin.
HSTS ships scoped to the site’s own host. includeSubdomains and preload
stay off unless you ask for them, because both reach beyond this distribution:
includeSubdomains binds every subdomain of the apex (a sibling host on plain
HTTP becomes unreachable), and preload removal ships on a browser release train
measured in months. Opt in with an hsts object, or drop the header with
hsts: false:
hsts object take the aggressive defaults
(maxAgeDays: 730, includeSubdomains: true, preload: true), so hsts: {}
opts into both directives.
Content-Security-Policy is deliberately opt-in. A wrong policy silently breaks
inline scripts and hydration, so it is never a shipped default:
Pattern Parameters
Full Configuration Example
Root Options
The schema is strict, so an unrecognised key fails validation. Zone and
certificate identity for a Fjall-managed domain arrives as a deploy-time CDK
context injection, not as a config key you author.
Build Options
Security Options
Forms Options
CDN Options
priceClass is a latency knob, not a cost knob at small-site traffic.
PriceClass_100 serves from North America and Europe only, so an audience in
Australia, Asia, or South America crosses an ocean on every request.
PriceClass_200 adds Asia but still no Australia or New Zealand. Set
"PriceClass_All" for a global or Oceania audience. The price difference at low
traffic is effectively zero.
Access Gate Options
The gate is a CloudFront viewer function that challenges every request with
HTTP Basic auth before the routing rewrite runs. Use it to keep a
not-yet-launched site out of casual view and search indexes while a client signs
off, then remove it at launch.
Troubleshooting
Form POST returns 403
The Origin/Referer gate rejected the request. The browser must send the form from the configured origin. Check thatcorsOrigin matches the site origin
exactly (https://example.com, no path, no trailing slash), and that you are
not testing from localhost against the deployed endpoint.
Form POST returns 502
SES refused the send. Almost always identity verification: the domain is not verified as an SES identity in the deployment account and region, or the account is in the SES sandbox and theto address is unverified. Check the
identity’s status in the SES console.
Clean URLs return 403 or 404
Confirmrouting matches the build output. Use multipage for one .html
file per page, directory for a directory with an index.html per page, and
spa for a client-side router that needs every path to serve index.html.
Deploy fails: build output not found
outputDir is resolved relative to source. Run the build command manually in
source and check which folder it emits. Next.js exports to out, Vite and
Astro to dist.
Costs
Estimated monthly costs for a low-traffic site:Breaking Out the Pattern
The pattern is the opinionated layer. Everything beneath it stays yours. Three escape tiers, in order of reach:1. Typed knobs
Most customisation needs a config field, not surgery.routing, security
(headers, HSTS and CSP), forms, cdn.priceClass, cdn.invalidateOnDeploy,
cdn.behaviours (per-path CloudFront overrides), and accessGate are all part
of the pattern’s own surface, documented above.
2. Instance access, raw CDK underneath
app.addPattern returns the constructed pattern, and its getters reach every
resource it created:
3. Full decomposition with StorageFactory and CdnFactory
For total control, compose the resources yourself instead of using the pattern. The CDN Factory page’s Static Website with S3 example is the starting point: a private bucket viaStorageFactory, fronted by
CdnFactory with OAC.
Next Steps
Payload Pattern
Need a CMS or SSR? Deploy Payload on Lambda
CDN Factory
Advanced CloudFront behaviours and origins
Storage Factory
S3 bucket configuration in depth
Deploy Application
General deployment guide
Related Documentation
Fjall:- Deploy Application - General deployment guide
- CDN Factory - CloudFront configuration
- Storage Factory - S3 options
- Domain - Hosted zones and custom domains
- Next.js Static Exports -
output: "export"setup - Amazon SES identity verification - Verifying the sending domain