Skip to main content

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/about serves about.html (or SPA fallback to index.html)
  • Security headers — HSTS, nosniff, frame denial, referrer policy
  • Custom domain — ACM certificate and Route53 alias record, created automatically
  • Contact form — an optional /api/contact endpoint (Lambda Function URL → SES) so a static site can still receive enquiries
There is no VPC, no database, no container and no compute stack — the whole app lands in a single CDN stack, and a low-traffic site costs approximately nothing to run.
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

Or run 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

Fjall runs your build command in 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

  1. Detects type: "staticsite" in infrastructure.ts
  2. Runs your build command in source (10-minute budget)
  3. Synthesises and deploys the single CDN stack
  4. Uploads the build output to S3 (stale files from previous builds are pruned)
  5. 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

When 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. Setting forms provisions a Lambda behind https://<domain>/api/contact that emails submissions via SES.
The sender and recipient are separate axes: The submitter’s own address goes in Reply-To, so replying from your inbox reaches them directly.
Forms require domain — SES only sends from a verified identity, and the site’s domain is that identity. Before the first submission works you must verify the domain in SES (SES console → Verified identities → Create identity → Domain) in the deployment account and region. With the hosted zone in Route53, SES adds the DKIM records automatically. While your account is in the SES sandbox, the to address must also be verified — or request production access.

Wiring a form

POST JSON or form-encoded data with name, 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 _gotcha short-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:SendEmail only for the site’s domain identity, conditioned on the exact From address

Security Headers

Ships four safe defaults: 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 — check corsOrigin 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 the to address is unverified. Check the identity’s status in the SES console.

Clean URLs return 403/404

Confirm routing 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
Fjall: External: