Skip to main content
Technology & EngineeringDeployment Patterns490 lines

static-site-deployment

Comprehensive guide to deploying static sites and single-page applications, covering GitHub Pages, Cloudflare Pages, AWS S3 with CloudFront, cache busting strategies, prerendering for SEO, SPA routing configuration, and CDN setup for optimal performance.

Quick Summary30 lines
**Static sites**: Pre-rendered HTML for every route. Fast, SEO-friendly, cacheable. Built by Astro, Hugo, Eleventy, Next.js (static export).

## Key Points

- Unlimited bandwidth (free tier)
- Global CDN (Cloudflare's network)
- Automatic HTTPS
- Built-in analytics
- Web Application Firewall
- **Brotli**: 15-20% smaller than gzip for text assets
- **Gzip**: Fallback for older clients
1. **No SPA fallback**: Users get 404 when refreshing on a client-side route.
2. **Caching `index.html`**: Users stuck on stale versions for hours/days.
3. **Invalidating all CDN paths on deploy**: Expensive and slow. Only invalidate HTML.
4. **Client-side rendering for landing pages**: Hurts SEO and perceived performance.
5. **Not compressing assets**: Serving uncompressed JS/CSS wastes bandwidth.

## Quick Example

```
www.example.com
```

```bash
# Via Wrangler CLI
npx wrangler pages project create my-site
npx wrangler pages deploy dist
```
skilldb get deployment-patterns-skills/static-site-deploymentFull skill: 490 lines
Paste into your CLAUDE.md or agent config

Deploying Static Sites and SPAs

Static Sites vs SPAs

Static sites: Pre-rendered HTML for every route. Fast, SEO-friendly, cacheable. Built by Astro, Hugo, Eleventy, Next.js (static export).

SPAs: Single HTML file + JavaScript handles routing client-side. Built by React (Vite), Vue, Svelte, Angular. Requires routing configuration on the server.

GitHub Pages

Basic Setup

# .github/workflows/deploy-pages.yml
name: Deploy to GitHub Pages

on:
  push:
    branches: [main]

permissions:
  contents: read
  pages: write
  id-token: write

concurrency:
  group: pages
  cancel-in-progress: true

jobs:
  build:
    runs-on: ubuntu-latest
    steps:
      - uses: actions/checkout@v4
      - uses: actions/setup-node@v4
        with:
          node-version: 20
          cache: npm
      - run: npm ci
      - run: npm run build
      - uses: actions/upload-pages-artifact@v3
        with:
          path: dist

  deploy:
    needs: build
    runs-on: ubuntu-latest
    environment:
      name: github-pages
      url: ${{ steps.deployment.outputs.page_url }}
    steps:
      - id: deployment
        uses: actions/deploy-pages@v4

SPA on GitHub Pages

GitHub Pages doesn't support SPA routing natively. The 404 hack:

<!-- public/404.html -->
<!DOCTYPE html>
<html>
<head>
  <script>
    // Redirect all 404s to index.html with the path preserved
    const path = window.location.pathname;
    const query = window.location.search;
    window.location.replace(
      window.location.origin + '/?redirect=' + encodeURIComponent(path + query)
    );
  </script>
</head>
</html>
<!-- index.html (in <head>) -->
<script>
  (function() {
    const params = new URLSearchParams(window.location.search);
    const redirect = params.get('redirect');
    if (redirect) {
      window.history.replaceState(null, '', redirect);
    }
  })();
</script>

Custom Domain

Create public/CNAME with your domain:

www.example.com

DNS: CNAME www to username.github.io.

Anti-pattern: Using GitHub Pages for sites that need server-side logic. It's purely static. Use Cloudflare Pages or Vercel for anything with API routes.

Cloudflare Pages

Setup

# Via Wrangler CLI
npx wrangler pages project create my-site
npx wrangler pages deploy dist

Configuration

# wrangler.toml (optional, for advanced config)
name = "my-site"
pages_build_output_dir = "dist"

[env.production]
vars = { API_URL = "https://api.example.com" }

Framework Presets

Cloudflare Pages auto-detects and configures:

FrameworkBuild CommandOutput Dir
Astroastro builddist
Next.jsnext build.next
Nuxtnuxt build.output
SvelteKitvite buildbuild
Vite/Reactvite builddist

SPA Routing

Cloudflare Pages handles SPA routing automatically when you deploy a single-page app. No configuration needed. All unmatched routes serve index.html.

Functions (Serverless)

// functions/api/hello.js
export async function onRequestGet(context) {
  return new Response(JSON.stringify({ message: 'Hello' }), {
    headers: { 'Content-Type': 'application/json' },
  });
}

Headers and Redirects

# public/_headers
/*
  X-Frame-Options: DENY
  X-Content-Type-Options: nosniff

/assets/*
  Cache-Control: public, max-age=31536000, immutable
# public/_redirects
/old-page    /new-page    301
/blog/*      https://blog.example.com/:splat    302

Advantages

  • Unlimited bandwidth (free tier)
  • Global CDN (Cloudflare's network)
  • Automatic HTTPS
  • Built-in analytics
  • Web Application Firewall

AWS S3 + CloudFront

S3 Bucket Setup

# Create bucket
aws s3 mb s3://my-site-production

# Configure for static hosting
aws s3 website s3://my-site-production \
  --index-document index.html \
  --error-document index.html  # SPA fallback

# Bucket policy for CloudFront access
cat > bucket-policy.json << 'EOF'
{
  "Version": "2012-10-17",
  "Statement": [{
    "Sid": "CloudFrontAccess",
    "Effect": "Allow",
    "Principal": {
      "Service": "cloudfront.amazonaws.com"
    },
    "Action": "s3:GetObject",
    "Resource": "arn:aws:s3:::my-site-production/*",
    "Condition": {
      "StringEquals": {
        "AWS:SourceArn": "arn:aws:cloudfront::123456789:distribution/EDFDVBD6EXAMPLE"
      }
    }
  }]
}
EOF
aws s3api put-bucket-policy --bucket my-site-production --policy file://bucket-policy.json

CloudFront Distribution

# Create CloudFront distribution (simplified)
aws cloudfront create-distribution \
  --origin-domain-name my-site-production.s3.amazonaws.com \
  --default-root-object index.html

SPA Routing with CloudFront

Configure a custom error response:

{
  "CustomErrorResponses": {
    "Items": [
      {
        "ErrorCode": 403,
        "ResponsePagePath": "/index.html",
        "ResponseCode": "200",
        "ErrorCachingMinTTL": 0
      },
      {
        "ErrorCode": 404,
        "ResponsePagePath": "/index.html",
        "ResponseCode": "200",
        "ErrorCachingMinTTL": 0
      }
    ]
  }
}

Deploy Script

#!/bin/bash
# deploy-s3.sh
BUCKET="my-site-production"
DISTRIBUTION_ID="EDFDVBD6EXAMPLE"

# Sync with appropriate cache headers
# HTML files: no cache (always fresh)
aws s3 sync dist/ s3://$BUCKET \
  --delete \
  --exclude "*.html" \
  --cache-control "public, max-age=31536000, immutable"

# HTML files separately with no-cache
aws s3 sync dist/ s3://$BUCKET \
  --delete \
  --include "*.html" \
  --exclude "*" \
  --cache-control "public, max-age=0, must-revalidate"

# Invalidate CloudFront cache
aws cloudfront create-invalidation \
  --distribution-id $DISTRIBUTION_ID \
  --paths "/index.html" "/404.html"

Anti-pattern: Invalidating /* on every deploy. It's slow, costly, and defeats the purpose of a CDN. Only invalidate HTML files; hashed assets don't need invalidation.

Cache Busting

Content-Hashed Filenames

Modern bundlers (Vite, webpack, esbuild) output hashed filenames:

dist/
  index.html              ← No hash (must be fresh)
  assets/
    app-3a4b5c.js         ← Hashed (cache forever)
    app-7d8e9f.css         ← Hashed (cache forever)
    logo-1a2b3c.png        ← Hashed (cache forever)

Vite Configuration

// vite.config.js
export default {
  build: {
    rollupOptions: {
      output: {
        // Customize hash format
        entryFileNames: 'assets/[name]-[hash].js',
        chunkFileNames: 'assets/[name]-[hash].js',
        assetFileNames: 'assets/[name]-[hash][extname]',
      },
    },
  },
};

Cache Headers Strategy

# Hashed assets: cache forever
/assets/*
  Cache-Control: public, max-age=31536000, immutable

# HTML: always revalidate
/*.html
  Cache-Control: public, max-age=0, must-revalidate

# Service worker: never cache
/sw.js
  Cache-Control: no-cache, no-store, must-revalidate

Anti-pattern: Setting long cache times on index.html. Users get stuck on old versions because the browser serves cached HTML that references old asset hashes.

Prerendering for SEO

Static Site Generation (SSG)

// astro.config.mjs — Full SSG
export default defineConfig({
  output: 'static',
});
// next.config.js — Static export
module.exports = {
  output: 'export',
};

Prerendering Specific Routes in SPAs

// vite.config.js with vite-plugin-ssr or similar
import { prerender } from 'vite-plugin-prerender';

export default {
  plugins: [
    prerender({
      routes: ['/', '/about', '/pricing', '/blog'],
      renderer: new PuppeteerRenderer(),
    }),
  ],
};

Dynamic Prerendering with a Prerender Service

For SPAs that can't be statically rendered, use a prerender service for bots:

# Nginx configuration
map $http_user_agent $prerender {
    default       0;
    "~*googlebot" 1;
    "~*bingbot"   1;
    "~*twitterbot" 1;
}

server {
    location / {
        if ($prerender) {
            rewrite .* /prerender/$scheme://$host$request_uri break;
            proxy_pass https://prerender.example.com;
        }

        try_files $uri $uri/ /index.html;
    }
}

Anti-pattern: Relying on client-side rendering for SEO-critical pages. Search engines can render JavaScript, but it's slower and less reliable.

SPA Routing Configuration

Every server/CDN must be configured to serve index.html for all routes so the client-side router can handle them.

Nginx

server {
    listen 80;
    root /var/www/html;
    index index.html;

    location / {
        try_files $uri $uri/ /index.html;
    }

    location /assets/ {
        expires 1y;
        add_header Cache-Control "public, immutable";
    }
}

Apache (.htaccess)

<IfModule mod_rewrite.c>
  RewriteEngine On
  RewriteBase /
  RewriteRule ^index\.html$ - [L]
  RewriteCond %{REQUEST_FILENAME} !-f
  RewriteCond %{REQUEST_FILENAME} !-d
  RewriteRule . /index.html [L]
</IfModule>

Caddy

example.com {
    root * /var/www/html
    try_files {path} /index.html
    file_server

    header /assets/* Cache-Control "public, max-age=31536000, immutable"
}

CDN Configuration Best Practices

Cache Tiers

Browser Cache → CDN Edge → CDN Shield → Origin

Security Headers via CDN

# Cloudflare Transform Rules or _headers file
X-Frame-Options: DENY
X-Content-Type-Options: nosniff
Referrer-Policy: strict-origin-when-cross-origin
Permissions-Policy: camera=(), microphone=(), geolocation=()
Content-Security-Policy: default-src 'self'; script-src 'self' 'unsafe-inline'; style-src 'self' 'unsafe-inline'; img-src 'self' data: https:;

Compression

Ensure your CDN compresses responses:

  • Brotli: 15-20% smaller than gzip for text assets
  • Gzip: Fallback for older clients

Most CDNs (Cloudflare, CloudFront, Vercel) handle this automatically.

Common Anti-Patterns

  1. No SPA fallback: Users get 404 when refreshing on a client-side route.
  2. Caching index.html: Users stuck on stale versions for hours/days.
  3. Invalidating all CDN paths on deploy: Expensive and slow. Only invalidate HTML.
  4. Client-side rendering for landing pages: Hurts SEO and perceived performance.
  5. Not compressing assets: Serving uncompressed JS/CSS wastes bandwidth.
  6. Using S3 without CloudFront: S3 alone is slow, expensive for bandwidth, and doesn't support HTTPS on custom domains.

Platform Comparison

FeatureGitHub PagesCloudflare PagesS3 + CloudFront
CostFreeFree (generous)Pay-per-use
Bandwidth100GB/moUnlimited$0.085/GB
Custom domainsYesYesYes
HTTPSAutoAutoACM (free)
SPA routing404 hackNativeError response
FunctionsNoYes (Workers)Lambda@Edge
Build systemActionsBuilt-inExternal CI
ComplexityLowLowHigh

Install this skill directly: skilldb add deployment-patterns-skills

Get CLI access →

Related Skills

database-deployment

Comprehensive guide to database deployment for web applications, covering managed database services (PlanetScale, Neon, Supabase, Turso), migration strategies, connection pooling, backup and restore procedures, data seeding, and schema management best practices for production environments.

Deployment Patterns539L

docker-deployment

Comprehensive guide to using Docker for production deployments, covering multi-stage builds, .dockerignore optimization, layer caching strategies, health checks, Docker Compose for local development, container registries, and security scanning best practices.

Deployment Patterns479L

fly-io-deployment

Complete guide to deploying applications on Fly.io, covering flyctl CLI usage, Dockerfile-based deployments, fly.toml configuration, persistent volumes, horizontal and vertical scaling, multi-region deployments, managed Postgres and Redis, private networking, and auto-scaling strategies.

Deployment Patterns412L

github-actions-cd

Comprehensive guide to implementing continuous deployment with GitHub Actions, covering deploy workflows, environment protection rules, secrets management, matrix builds, dependency caching, artifact management, and deploying to multiple targets including Vercel, Fly.io, AWS, and container registries.

Deployment Patterns469L

monitoring-post-deploy

Comprehensive guide to post-deployment monitoring for web applications, covering uptime checks, error tracking with Sentry, application performance monitoring, log aggregation, alerting strategies, public status pages, and incident response procedures for production systems.

Deployment Patterns572L

netlify-deployment

Complete guide to deploying web applications on Netlify, covering build settings, deploy previews, serverless and edge functions, forms, identity, redirects and rewrites, split testing, and environment variable management for production workflows.

Deployment Patterns399L