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 about/index.html to match your build output, or falls back to index.html for SPAs
  • 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

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

  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. After the upload the deployment invalidates the CloudFront distribution (/*), so the new content is served immediately instead of after the edge cache’s TTL (up to 24 hours) expires. AWS grants 1,000 free invalidation paths per month and /* counts as one path per deploy. Prefer pure TTL semantics? Set cdn: { invalidateOnDeploy: false }.

Custom Domain

When 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-1 certificate 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 zoneName to the Route53 hosted zone that contains domain (plus hostedZoneId to skip the runtime lookup). The pattern creates a DNS-validated ACM certificate in us-east-1 and attaches it to CloudFront.
Either way the pattern creates the alias A record; its label is derived from 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. 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

CDN Options

priceClass is a latency knob, not a cost knob at small-site traffic: PriceClass_100 serves from North America and Europe only — an audience in Australia, Asia, or South America crosses an ocean on every request. 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.
The access gate is an obscurity gate, not a security boundary. The credentials sit in plain text in infrastructure.ts and in the distribution’s function code, and the origin objects are unchanged. Do not protect sensitive content with it.

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 build output: multipage for one .html file per page, directory for a directory-with-index.html per page, 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 + 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:
Everything these getters return is a real CDK construct — anything CDK can express is reachable from here without leaving the pattern.

3. Full decomposition — StorageFactory + 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 via StorageFactory, fronted by CdnFactory with OAC.
A hand-composed site brings its own build. Fjall runs the build command for type: "staticsite" patterns only — a factory-composed app has no build step, and fjall deploy warns that it deploys the files already on disk. Build before you deploy (locally or in CI), or the bucket receives whatever bytes the previous build left behind.

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: