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+3, global edge caching
- Clean URLs —
/aboutservesabout.html(or SPA fallback toindex.html) - 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 → SES) so a static site can still receive enquiries
The pattern serves 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 app
fjall create app with no flags and choose Static Site in the
interactive picker.
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
Set with
--routing spa at create time, or routing: "spa" in
infrastructure.ts.
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) URL
Subsequent Deploys
The same command rebuilds and re-uploads. Only changed files transfer, and pruning keeps the bucket in lockstep with the latest build — a deleted page disappears from S3 rather than lingering.Custom Domain
domain is set, the pattern looks up the Route53 hosted zone, creates a
DNS-validated ACM certificate in us-east-1, attaches it to CloudFront, and
creates an alias A record.
Requires: a Route53 hosted zone for the domain in the deployment account.
See Domain for delegating or importing zones.
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 + CORS — browsers on other sites cannot POST to it; the
allow-origin defaults to
https://<domain>(override with--cors-origin) - Honeypot — 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,
X-Content-Type-Options: nosniff, X-Frame-Options: DENY, and
Referrer-Policy.
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
Build Options
Security Options
Forms Options
Troubleshooting
Form POST returns 403
The Origin/Referer gate rejected the request. The browser must send the form from the configured origin — checkcorsOrigin 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/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/404
Confirmrouting matches the site shape: multipage for a folder of .html
pages, 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: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
- Next.js Static Exports -
output: "export"setup - Amazon SES identity verification - Verifying the sending domain