Skip to main content

deploy static-app

Deploy static websites (React, Vue, Angular, Docusaurus) to S3 with optional CloudFront distribution.

Overview

carlin deploy static-app

This command:

  1. Finds your built static files (build/, out/, storybook-static/, dist/)
  2. Creates S3 bucket for hosting
  3. Optionally creates CloudFront distribution
  4. Uploads files to S3
  5. 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.

note

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/guide301 to /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.

caution

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

note

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