Calm Serverless Static Site with Vyriy CDK Stack

A static website does not have to be complicated.

For many documentation sites, blogs, landing pages, examples, and frontend applications, the calm solution is also the most practical one:

build static files
upload them to S3
serve them through CloudFront
connect the domain with Route 53
protect the domain with an ACM certificate

This is a good fit for a calm serverless architecture.

There are no servers to patch, no containers to keep alive, and no runtime application process for static pages. The website is just files, CDN, DNS, and a repeatable deployment.

Vyriy stack helpers make this setup smaller and easier to read.

What this stack creates

This example creates a static website infrastructure on AWS:

Route 53 hosted zone lookup
ACM certificate
S3 bucket
CloudFront distribution
CloudFront Function for URL rewrites
A records for the domain
S3 deployment for static assets
S3 deployment for HTML files
CDK outputs

The site is deployed from the local dist directory.

The final result is available at:

https://site.com/

Example stack

Create a CDK stack file:

import { CfnOutput, Stack, type StackProps } from 'aws-cdk-lib';
import type { Construct } from 'constructs';

import { stack } from '@vyriy/cdk';
import { acm, cf, deployment, route53, s3 } from '@vyriy/stack';
import { path } from '@vyriy/path';

stack(
  class StaticSiteStack extends Stack {
    constructor(scope: Construct, id: string, props: StackProps & { env: { account: string; region: string } }) {
      super(scope, id, props);

      const domain = 'site.com';

      const hostedZone = route53.getHostedZone(this, 'HostedZone', {
        domainName: domain,
      });

      const siteBucket = s3.createBucket(this, 'Bucket', {
        bucketName: domain,
      });

      const certificate = acm.createCertificate(this, 'Certificate', {
        domainName: domain,
        subjectAlternativeNames: [`*.${domain}`],
        validation: acm.CertificateValidation.fromDns(hostedZone),
      });

      const siteDistribution = cf.createDistribution(this, 'Distribution', {
        certificate,
        defaultBehavior: cf.createDefaultBehavior(siteBucket, {
          functionAssociations: cf.createFunctionAssociations(this, 'IndexRewriteFunction', {
            rootDomain: domain,
            wwwDomain: `www.${domain}`,
          }),
        }),
        defaultRootObject: 'index.html',
        domainNames: [domain, `www.${domain}`],
        errorResponses: [
          {
            httpStatus: 403,
            responseHttpStatus: 404,
            responsePagePath: '/404.html',
          },
          {
            httpStatus: 404,
            responseHttpStatus: 404,
            responsePagePath: '/404.html',
          },
        ],
      });

      route53.createARecord(this, 'RootRecord', {
        target: route53.createCloudFrontTarget(siteDistribution),
        zone: hostedZone,
      });

      const mutableFiles = [
        'index.html',
        '404.html',
        '**/index.html',
        'sitemap.xml',
        'robots.txt',
      ];

      const assetDeployment = deployment.createBucketDeployment(this, 'DeploySiteAssets', {
        cacheControl: deployment.createImmutableCacheControl(),
        destinationBucket: siteBucket,
        distribution: siteDistribution,
        exclude: mutableFiles,
        distributionPaths: ['/*'],
        sources: [deployment.Source.asset(path('dist'))],
      });

      const htmlDeployment = deployment.createBucketDeployment(this, 'DeploySiteHtml', {
        cacheControl: deployment.createHtmlCacheControl(),
        destinationBucket: siteBucket,
        distribution: siteDistribution,
        distributionPaths: ['/*'],
        exclude: ['*'],
        include: mutableFiles,
        prune: false,
        sources: [deployment.Source.asset(path('dist'))],
      });

      htmlDeployment.node.addDependency(assetDeployment);

      new CfnOutput(this, 'Account', { value: props.env.account });
      new CfnOutput(this, 'Region', { value: props.env.region });
      new CfnOutput(this, 'Tags', { value: JSON.stringify(props.tags ?? {}) });

      new CfnOutput(this, 'BucketName', { value: siteBucket.bucketName });

      new CfnOutput(this, 'DistributionDomainName', { value: siteDistribution.domainName });
      new CfnOutput(this, 'DistributionId', { value: siteDistribution.distributionId });
      new CfnOutput(this, 'DistributionUrl', { value: `https://${siteDistribution.domainName}/` });

      new CfnOutput(this, 'SiteUrl', { value: `https://${domain}/` });
    }
  },
);

Domain

The domain is defined in one place:

const domain = 'site.com';

In a real project, replace it with your own domain:

const domain = 'vyriy.dev';

The stack expects that a Route 53 hosted zone already exists for this domain.

const hostedZone = route53.getHostedZone(this, 'HostedZone', {
  domainName: domain,
});

This keeps DNS ownership explicit. The stack does not guess where the domain lives. It looks up the existing hosted zone and then adds the records it needs.

S3 bucket

The static files are stored in an S3 bucket:

const siteBucket = s3.createBucket(this, 'Bucket', {
  bucketName: domain,
});

The bucket is the origin for CloudFront.

The user does not access the bucket directly. CloudFront is the public entry point.

ACM certificate

The site uses HTTPS through an ACM certificate:

const certificate = acm.createCertificate(this, 'Certificate', {
  domainName: domain,
  subjectAlternativeNames: [`*.${domain}`],
  validation: acm.CertificateValidation.fromDns(hostedZone),
});

The certificate covers:

site.com
*.site.com

This makes it ready for the root domain and subdomains such as:

www.site.com
docs.site.com
storybook.site.com

DNS validation is handled through the Route 53 hosted zone.

CloudFront distribution

CloudFront serves the site:

const siteDistribution = cf.createDistribution(this, 'Distribution', {
  certificate,
  defaultBehavior: cf.createDefaultBehavior(siteBucket, {
    functionAssociations: cf.createFunctionAssociations(this, 'IndexRewriteFunction', {
      rootDomain: domain,
      wwwDomain: `www.${domain}`,
    }),
  }),
  defaultRootObject: 'index.html',
  domainNames: [domain, `www.${domain}`],
  errorResponses: [
    {
      httpStatus: 403,
      responseHttpStatus: 404,
      responsePagePath: '/404.html',
    },
    {
      httpStatus: 404,
      responseHttpStatus: 404,
      responsePagePath: '/404.html',
    },
  ],
});

This distribution is connected to:

site.com
www.site.com

It also uses:

defaultRootObject: 'index.html';

So the root URL serves index.html.

URL rewrites

The stack adds CloudFront Function associations:

functionAssociations: cf.createFunctionAssociations(this, 'IndexRewriteFunction', {
  rootDomain: domain,
  wwwDomain: `www.${domain}`,
}),

This is useful for static sites where clean URLs should resolve to HTML files.

For example, a static site often wants routes like:

/about
/blog/vyriy-webpack-config
/docs

A rewrite function can normalize requests before CloudFront fetches objects from S3.

This keeps the static site friendly for users and simple for hosting.

Error pages

The distribution maps both 403 and 404 to 404.html:

errorResponses: [
  {
    httpStatus: 403,
    responseHttpStatus: 404,
    responsePagePath: '/404.html',
  },
  {
    httpStatus: 404,
    responseHttpStatus: 404,
    responsePagePath: '/404.html',
  },
],

This matters because S3 origins can return 403 when an object is not publicly readable or not found through the origin access path.

For a static website, both cases should usually look like a normal not-found page.

DNS record

The stack creates an A record pointing to CloudFront:

route53.createARecord(this, 'RootRecord', {
  target: route53.createCloudFrontTarget(siteDistribution),
  zone: hostedZone,
});

This connects the domain to the CloudFront distribution.

The public site URL becomes:

https://site.com/

Deployment from dist

The stack deploys files from:

deployment.Source.asset(path('dist'));

So the application build step should create a dist directory before deployment.

Example output:

dist/
  index.html
  404.html
  index.js
  styles.css
  assets/

Mutable files

After testing, the deployment uses an explicit list of mutable files:

const mutableFiles = [
  'index.html',
  '404.html',
  '**/index.html',
  'sitemap.xml',
  'robots.txt',
];

These files should not be cached like immutable assets.

They may change without a hashed filename:

index.html
404.html
blog/index.html
docs/index.html
sitemap.xml
robots.txt

This is important for static sites, SSG output, SEO files, and clean routes.

Immutable assets deployment

Static assets are deployed first:

const assetDeployment = deployment.createBucketDeployment(this, 'DeploySiteAssets', {
  cacheControl: deployment.createImmutableCacheControl(),
  destinationBucket: siteBucket,
  distribution: siteDistribution,
  exclude: mutableFiles,
  distributionPaths: ['/*'],
  sources: [deployment.Source.asset(path('dist'))],
});

This deployment excludes all mutable files.

Everything else is treated as an immutable asset.

Assets usually contain hashed filenames:

index.8f3a1c.js
styles.91bd2.css
logo.a81f.svg
assets/app.2fd91c.js

These files can use long cache headers because their names change when the content changes.

That is why the stack uses:

deployment.createImmutableCacheControl();

HTML and SEO files deployment

Mutable files are deployed separately:

const htmlDeployment = deployment.createBucketDeployment(this, 'DeploySiteHtml', {
  cacheControl: deployment.createHtmlCacheControl(),
  destinationBucket: siteBucket,
  distribution: siteDistribution,
  distributionPaths: ['/*'],
  exclude: ['*'],
  include: mutableFiles,
  prune: false,
  sources: [deployment.Source.asset(path('dist'))],
});

This deployment includes:

index.html
404.html
**/index.html
sitemap.xml
robots.txt

These files should usually have shorter cache headers because they describe the current version of the site.

That is why the stack uses:

deployment.createHtmlCacheControl();

Why deploy assets before HTML?

The HTML deployment depends on the asset deployment:

htmlDeployment.node.addDependency(assetDeployment);

This is important.

The safe order is:

1. Upload new JS, CSS, images, and other immutable assets.
2. Upload mutable files such as index.html, nested index.html, sitemap.xml, and robots.txt.

If mutable files were uploaded first, users could receive a new index.html that points to assets that are not uploaded yet.

Deploying assets first avoids that problem.

This is a small detail, but it is exactly the kind of detail that makes deployment calm.

Outputs

The stack prints useful deployment outputs:

new CfnOutput(this, 'Account', { value: props.env.account });
new CfnOutput(this, 'Region', { value: props.env.region });
new CfnOutput(this, 'Tags', { value: JSON.stringify(props.tags ?? {}) });

new CfnOutput(this, 'BucketName', { value: siteBucket.bucketName });

new CfnOutput(this, 'DistributionDomainName', { value: siteDistribution.domainName });
new CfnOutput(this, 'DistributionId', { value: siteDistribution.distributionId });
new CfnOutput(this, 'DistributionUrl', { value: `https://${siteDistribution.domainName}/` });

new CfnOutput(this, 'SiteUrl', { value: `https://${domain}/` });

These outputs make it easier to inspect what was created:

AWS account
AWS region
S3 bucket name
CloudFront distribution domain
CloudFront distribution ID
direct CloudFront URL
final site URL

Why this is serverless

This architecture is serverless because there is no application server to run.

The website is served by managed AWS services:

S3          -> stores files
CloudFront  -> serves files globally
Route 53    -> resolves the domain
ACM         -> provides HTTPS certificates

There is no EC2 instance, no long-running Node.js process, no container, and no manual web server.

For static content, this is often the calmest architecture.

Why this is calm

A calm static site architecture has a few useful properties:

Simple runtime
Repeatable deployment
Clear cache strategy
No server maintenance
Fast global delivery
Small infrastructure surface

The project builds files into dist.

The stack deploys those files.

CloudFront serves them.

Route 53 connects the domain.

ACM secures the domain.

That is the whole mental model.

When to use this pattern

This pattern is a good fit for:

blogs
documentation sites
landing pages
Storybook builds
static React applications
SSG output
marketing pages
project examples

It is especially useful when the site can be generated before deployment.

For Vyriy, this fits the idea of static-first delivery:

React components
SSG output
static assets
CDN delivery
serverless hosting

The result is a small, practical, and calm deployment model for static websites.