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.