# Calm Serverless Static Site with Vyriy CDK Stack

Build and deploy a static serverless website with S3, CloudFront, Route 53, ACM, and Vyriy stack helpers.

Published: 2026-05-13
Tags: vyriy, cdk, aws, serverless, static-site, cloudfront, s3, route53, calm-architecture
Source: https://vyriy.dev/examples/vyriy-calm-serverless-static-site/

---

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

```text
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:

```text
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:

```text
https://site.com/
```

## Example stack

Create a CDK stack file:

```ts
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:

```ts
const domain = 'site.com';
```

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

```ts
const domain = 'vyriy.dev';
```

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

```ts
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:

```ts
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:

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

The certificate covers:

```text
site.com
*.site.com
```

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

```text
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:

```ts
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:

```text
site.com
www.site.com
```

It also uses:

```ts
defaultRootObject: 'index.html';
```

So the root URL serves `index.html`.

## URL rewrites

The stack adds CloudFront Function associations:

```ts
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:

```text
/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`:

```ts
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:

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

This connects the domain to the CloudFront distribution.

The public site URL becomes:

```text
https://site.com/
```

## Deployment from dist

The stack deploys files from:

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

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

Example output:

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

## Mutable files

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

```ts
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:

```text
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:

```ts
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:

```text
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:

```ts
deployment.createImmutableCacheControl();
```

## HTML and SEO files deployment

Mutable files are deployed separately:

```ts
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:

```text
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:

```ts
deployment.createHtmlCacheControl();
```

## Why deploy assets before HTML?

The HTML deployment depends on the asset deployment:

```ts
htmlDeployment.node.addDependency(assetDeployment);
```

This is important.

The safe order is:

```text
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:

```ts
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:

```text
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:

```text
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:

```text
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:

```text
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:

```text
React components
SSG output
static assets
CDN delivery
serverless hosting
```

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