deploy static-app
Deploy static websites (React, Vue, Angular, Docusaurus) to S3 with optional CloudFront distribution.
Overview
carlin deploy static-app
This command:
- Finds your built static files (
build/,out/,storybook-static/,dist/) - Creates S3 bucket for hosting
- Optionally creates CloudFront distribution
- Uploads files to S3
- Configures caching and CDN
Quick Start
Build and deploy a Vite app:
# Build your app
pnpm build
# Deploy to S3 only
carlin deploy static-app
# Deploy to S3 + CloudFront
carlin deploy static-app --cloudfront
Deploy with custom domain:
carlin deploy static-app \
--cloudfront \
--aliases app.example.com \
--acm arn:aws:acm:us-east-1:123456789012:certificate/abc123 \
--hosted-zone-name example.com
Options
--build-folder
Specify build output folder.
carlin deploy static-app --build-folder dist
Default: Auto-detects build/, out/, storybook-static/, or dist/
--cloudfront
Create CloudFront distribution.
carlin deploy static-app --cloudfront
Benefits:
- Global CDN (faster load times)
- HTTPS support
- Custom domains
- Caching
Default: false (S3 only)
--aliases
CloudFront custom domain names (CNAMEs).
carlin deploy static-app --cloudfront --aliases app.example.com www.app.example.com
Requires: --acm (SSL certificate)
Related: CloudFront Alternate Domain Names
--acm
SSL certificate ARN or exported CloudFormation value name.
# Direct ARN
carlin deploy static-app --acm arn:aws:acm:us-east-1:123456789012:certificate/abc123
# CloudFormation export
carlin deploy static-app --acm MyCertificateArn
Requires: Certificate in us-east-1 region for CloudFront
Related: AWS Certificate Manager
--hosted-zone-name
Route 53 hosted zone for automatic DNS configuration.
carlin deploy static-app \
--hosted-zone-name example.com \
--aliases app.example.com
carlin automatically creates DNS records pointing aliases to CloudFront distribution.
Example: For hosted zone example.com and alias app.example.com, carlin creates A record app.example.com → CloudFront.
--append-index-html
Append index.html to request URIs (for Docusaurus, VitePress, static site generators).
carlin deploy static-app --append-index-html
Behavior:
- Request:
/docs/guide→ Serves:/docs/guide/index.html - Request:
/about→ Serves:/about/index.html
Use case: Clean URLs without .html extension
Conflicts with: --viewer-request-function-code. A cache behavior takes a
single viewer request function, and this option associates the shared one carlin
keeps in the base stack. A function of your own calls the appendIndexHtml
helper instead.
On its own, this option answers /docs/guide and /docs/guide/ with the same
page, so every page of the site has a duplicate URL. Add
--redirect-to-trailing-slash to serve each
page on a single URL.
--redirect-to-trailing-slash
Answer an extension-less request URI with a 301 to its trailing slash form
instead of serving the page on both.
carlin deploy static-app --cloudfront --append-index-html --redirect-to-trailing-slash
Behavior:
- Request:
/docs/guide→301to/docs/guide/, query string carried over - Request:
/docs/guide/→ Serves:/docs/guide/index.html - Request:
/assets/main.js→ Serves:/assets/main.js
Without it both forms answer 200 with the same HTML, so search engines crawl
and de-duplicate a twin of every page, inbound links split between two URLs, and
a link written without the trailing slash works — which is what makes the
mistake impossible to notice. The redirect closes all three: there is one URL
per page, and the other form says so.
The redirect is served by a CloudFront function carlin creates for this
distribution, rather than by the shared one in the base stack that
--append-index-html associates. That function is imported by every static app
of the account, so redirecting through it would change the behavior of all of
them on a version bump.
Requires: --append-index-html. The redirect is a mode of the index
appending — the trailing slash form it redirects to is still served by it.
Conflicts with: --spa. An extension-less URI of a single page application
is a client route rather than a directory, so redirecting /user/profile to
/user/profile/ would move every route of the app to a URL its router doesn't
produce.
--spa
Enable Single Page Application (SPA) mode.
carlin deploy static-app --spa
Behavior: All 404 errors serve index.html (for client-side routing)
Use case: React Router, Vue Router, Angular Router
--response-headers
Headers that CloudFront adds to every response it sends to viewers.
carlin deploy static-app --cloudfront --response-headers.x-robots-tag=noindex
Header values such as a content security policy are long and awkward to quote on a command line, so prefer the configuration file:
import { defineConfig } from 'carlin/config';
export default defineConfig({
cloudfront: true,
responseHeaders: {
'content-security-policy': "default-src 'self'",
'permissions-policy': 'geolocation=()',
},
});
Headers defined this way override the ones received from the origin. Use the array form when a header should be sent only when the origin doesn't send it:
responseHeaders: [
{
header: 'x-robots-tag',
value: 'noindex',
override: false,
},
];
Requires: --cloudfront. The headers are added by CloudFront, so a bucket
only deploy has nothing to attach them to.
Every deploy uses the managed CORS-with-preflight-and-SecurityHeadersPolicy,
which sends Strict-Transport-Security, X-Content-Type-Options,
X-Frame-Options, Referrer-Policy, X-XSS-Protection and the CORS headers.
Defining response headers replaces that managed policy with one carlin creates,
which reproduces those settings so both deploys behave the same. Defining one
of the security headers above replaces the managed value with yours. The CORS
headers are configured as a unit and cannot be replaced individually — use
--response-headers-policy when you need to change them.
Defining vary is the exception. CloudFront's CORS handling manages Vary
itself — it sends Vary: Origin on a plain request and drops Vary on a
cross-origin one — so the policy carlin creates leaves the CORS configuration
out and your Vary reaches the viewer as written. CORS keeps working: the
bucket the distribution serves from is configured for CORS, and the
Managed-CORS-S3Origin origin request policy forwards Origin and the
Access-Control-Request-* headers to it, so S3 answers both simple and
preflight requests. It allows GET and HEAD, the methods it can serve, rather
than the full set the managed policy advertises.
Changing headers updates the distribution, which takes a few minutes to reach every edge location. No invalidation is needed: the headers are applied to cache hits too.
--response-headers-policy
The id of an existing response headers policy, or the name of an exported value whose value is the id, to associate to the distribution instead of the default managed one.
# Managed or custom policy id
carlin deploy static-app --cloudfront --response-headers-policy 67f7725c-6f97-4210-82d7-5512b31e9d03
# CloudFormation export
carlin deploy static-app --cloudfront --response-headers-policy MyResponseHeadersPolicyId
Use this when the policy is managed elsewhere, or when you need settings that
--response-headers doesn't reach, such as CORS or removing headers. carlin
takes the policy as given and adds nothing to it.
Requires: --cloudfront. Conflicts with: --response-headers. A cache
behavior takes a single response headers policy.
--viewer-request-function-code
Path to a file whose code runs as the CloudFront viewer request function of the distribution, for logic that has to run before CloudFront looks the request up in the cache — rewriting a URI, or answering a request outright.
import { defineConfig } from 'carlin/config';
export default defineConfig({
cloudfront: true,
viewerRequestFunctionCode: './cloudfront/viewerRequest.js',
});
The file must declare a function handler(event), which is the entry point
CloudFront calls. carlin injects an appendIndexHtml(request) helper into the
function, holding the same logic as --append-index-html,
and a redirectToTrailingSlash(request) helper holding that of
--redirect-to-trailing-slash, so a site that
needs both calls the one it wants where it belongs:
function handler(event) {
var request = event.request;
var accept = request.headers.accept ? request.headers.accept.value : '';
if (accept.includes('text/markdown')) {
request.uri = request.uri.replace(/\/$/, '') + '.md';
return request;
}
return appendIndexHtml(request);
}
Order is yours to choose at the call site, and it matters: a function rewriting
/docs/guide to /docs/guide.md has to run before the index appending, which
would otherwise turn the URI into /docs/guide/index.html first.
The rewritten URI is what CloudFront looks up in the cache, so each branch gets
its own cache entry with no cache policy change. Pair the example above with
responseHeaders: { vary: 'accept' } so caches downstream key on the header the
function reads.
Requires: --cloudfront. Conflicts with: --append-index-html.
Constraints: CloudFront runs the code on the cloudfront-js-2.0 runtime,
with no network, filesystem, timers or dynamic evaluation, and no access to the
request body. The composed function — your code plus the injected helpers — must
stay under 10 KB, which carlin checks before deploying.
--skip-upload
Update CloudFormation without uploading files.
carlin deploy static-app --skip-upload
Use case: Update CloudFront configuration without re-uploading files
--upload-source-maps
Upload source map (.map) files to S3.
carlin deploy static-app --upload-source-maps
Default: false — source maps are excluded from the upload.
The bucket this command publishes to is public, and a source map embeds your
application's original source, so publishing one discloses that source to
anyone who requests it. carlin therefore never uploads .map files unless you
ask for it explicitly.
Before enabling this, consider uploading your source maps to your error tracking provider instead. That gives you readable stack traces without serving the source publicly.
carlin only decides which files are uploaded — it does not modify file
contents. If your bundler emits sourceMappingURL comments and you leave this
option off, those comments will point at files that return 404. Strip them at
build time (most source-map upload tools do this for you) if that matters to
you.
--region
Static app deployments always use us-east-1 (CloudFront requirement). This option is ignored.
Examples
Vite/React App
# Build
pnpm build
# Deploy with CloudFront and custom domain
carlin deploy static-app \
--cloudfront \
--spa \
--aliases app.example.com \
--acm arn:aws:acm:us-east-1:123456789012:certificate/abc123 \
--hosted-zone-name example.com
Docusaurus Documentation
# Build
pnpm build
# Deploy with clean URLs
carlin deploy static-app \
--cloudfront \
--append-index-html \
--redirect-to-trailing-slash \
--aliases docs.example.com \
--acm arn:aws:acm:us-east-1:123456789012:certificate/abc123
Next.js Static Export
# Build static export
pnpm build
# Deploy
carlin deploy static-app \
--build-folder out \
--cloudfront \
--spa
Multi-Environment Deployment
# Staging
carlin deploy static-app \
--environment staging \
--cloudfront \
--aliases staging.app.example.com
# Production
carlin deploy static-app \
--environment production \
--cloudfront \
--aliases app.example.com www.app.example.com
Architecture
S3 Only
Pros: Simple, low cost Cons: No CDN, no HTTPS, slower for global users
S3 + CloudFront
Pros: Fast globally, HTTPS, custom domains, caching Cons: Slightly higher cost
Deployment Flow
SSL Certificate Setup
Create SSL certificate in AWS Certificate Manager (must be in us-east-1):
# Request certificate
aws acm request-certificate \
--domain-name app.example.com \
--subject-alternative-names www.app.example.com \
--validation-method DNS \
--region us-east-1
# Note the certificate ARN
# arn:aws:acm:us-east-1:123456789012:certificate/abc123
Validate certificate via DNS or email, then use ARN in deployment:
carlin deploy static-app \
--acm arn:aws:acm:us-east-1:123456789012:certificate/abc123 \
--aliases app.example.com
Caching Strategy
CloudFront caches files based on file type:
- HTML files: No cache (always fetch latest)
- JS/CSS/Images: Long cache (1 year) with content hash in filename
Recommended build setup (Vite example):
// vite.config.ts
export default {
build: {
rollupOptions: {
output: {
entryFileNames: 'assets/[name].[hash].js',
chunkFileNames: 'assets/[name].[hash].js',
assetFileNames: 'assets/[name].[hash].[ext]',
},
},
},
};
Updating Deployments
Update files:
pnpm build
carlin deploy static-app
carlin uploads changed files and invalidates CloudFront cache automatically.
Update CloudFront configuration only:
carlin deploy static-app --skip-upload
Cost Considerations
See AWS S3 Pricing and CloudFront Pricing for current rates.
Troubleshooting
Build Folder Not Found
Error: Build folder not found
Solution: Build your app first or specify folder:
pnpm build
carlin deploy static-app --build-folder dist
Certificate Not in us-east-1
Error: Certificate must be in us-east-1 region
Solution: Create certificate in us-east-1:
aws acm request-certificate --region us-east-1 --domain-name app.example.com
CloudFront Propagation Delay
Problem: Changes take 15-30 minutes to appear globally
Solution: This is normal CloudFront behavior. Wait for distribution deployment to complete.
Check status:
aws cloudfront list-distributions
Source Maps Return 404
Problem: .map files that used to be served now return 404
Cause: source maps are excluded from the upload by default. This changed in carlin v2 — earlier versions published everything in the build folder, including source maps.
Solution: if you intend to serve them publicly, opt back in:
carlin deploy static-app --upload-source-maps
SPA Routes Return 404
Problem: Client-side routes (e.g., /about, /contact) return 404
Solution: Use --spa flag:
carlin deploy static-app --spa
Related Topics
- Base Stack - CloudFront function for
--append-index-html - Environments - Multi-environment deployments