Claude-skill-registry composable-svelte-deployment

Production deployment patterns for Composable Svelte SSR applications. Use when deploying to Fly.io, Docker, or any cloud platform. Covers multi-stage Docker builds, Fly.io configuration, security hardening, performance optimization, and integration with Composable Rust backends.

install
source · Clone the upstream repo
git clone https://github.com/majiayu000/claude-skill-registry
Claude Code · Install into ~/.claude/skills/
T=$(mktemp -d) && git clone --depth=1 https://github.com/majiayu000/claude-skill-registry "$T" && mkdir -p ~/.claude/skills && cp -r "$T/skills/data/composable-svelte-deployment" ~/.claude/skills/majiayu000-claude-skill-registry-composable-svelte-deployment && rm -rf "$T"
manifest: skills/data/composable-svelte-deployment/SKILL.md
source content

Composable Svelte Deployment

This skill covers production deployment of Composable Svelte SSR applications, with focus on Fly.io and Docker-based deployments.


Architecture Overview

Stack: Fastify + Composable Svelte SSR (NOT SvelteKit)

┌─────────────────────────────────────────────────┐
│                    Fly.io                       │
├─────────────────────────────────────────────────┤
│  ┌──────────────────┐    ┌──────────────────┐  │
│  │ Composable       │    │ Composable       │  │
│  │ Svelte SSR       │◄──►│ Rust Backend     │  │
│  │ (Fastify)        │    │ (Axum/Actix)     │  │
│  └──────────────────┘    └──────────────────┘  │
│   Docker Container        Docker Container      │
│   Internal Network: .internal (6PN)             │
└─────────────────────────────────────────────────┘

Why NOT SvelteKit?

  • Incompatible with Composable Architecture patterns
  • We use custom SSR from
    @composable-svelte/core/ssr
  • Reducer-based state management on both client and server
  • Effect system for async operations

Docker Patterns

Multi-Stage Build (REQUIRED)

Goal: <150MB final image, production-only dependencies

# Stage 1: Dependencies (Production Only)
FROM node:20-alpine AS deps
WORKDIR /app
RUN npm install -g pnpm@9
COPY package.json pnpm-lock.yaml ./
RUN pnpm install --frozen-lockfile --prod

# Stage 2: Builder (Development Dependencies + Build)
FROM node:20-alpine AS builder
WORKDIR /app
RUN npm install -g pnpm@9
COPY package.json pnpm-lock.yaml ./
RUN pnpm install --frozen-lockfile
COPY . .
RUN pnpm run build
RUN find dist -name "*.map" -type f -delete  # Remove source maps

# Stage 3: Production Runtime (Minimal)
FROM node:20-alpine AS runner
RUN apk add --no-cache dumb-init
WORKDIR /app
RUN addgroup -g 1001 -S nodejs && adduser -S nodejs -u 1001

# Copy ONLY production dependencies and built artifacts
COPY --from=deps --chown=nodejs:nodejs /app/node_modules ./node_modules
COPY --from=builder --chown=nodejs:nodejs /app/dist ./dist
COPY --from=builder --chown=nodejs:nodejs /app/package.json ./package.json

USER nodejs
ENV NODE_ENV=production PORT=3000
EXPOSE 3000
ENTRYPOINT ["dumb-init", "--"]
CMD ["node", "dist/server/index.js"]

Key Patterns:

  • 3 stages: deps (prod only) → builder (full build) → runner (minimal)
  • Non-root user:
    nodejs:nodejs
    (UID 1001)
  • Alpine Linux: Minimal attack surface (<50MB base)
  • dumb-init: Proper signal handling (PID 1 problem)
  • Remove source maps:
    find dist -name "*.map" -delete
  • Copy package.json: Required for ES module resolution

Common Mistakes:

  • ❌ Single-stage build → 500MB+ images
  • ❌ Running as root → security vulnerability
  • ❌ Including devDependencies → bloated images
  • ❌ Missing package.json → import failures with "type": "module"

Monorepo vs Standalone

Monorepo (like this repo):

# Copy workspace structure
COPY package.json pnpm-lock.yaml pnpm-workspace.yaml ./
COPY packages/core/package.json ./packages/core/
COPY examples/ssr-server/package.json ./examples/ssr-server/

# Install workspace dependencies
RUN pnpm install --frozen-lockfile

# Build both packages
WORKDIR /app/packages/core
RUN pnpm run build
WORKDIR /app/examples/ssr-server
RUN pnpm run build

# Copy ALL workspace artifacts
COPY --from=builder /app/packages/core/dist ./packages/core/dist
COPY --from=builder /app/packages/core/package.json ./packages/core/package.json
COPY --from=builder /app/pnpm-workspace.yaml ./pnpm-workspace.yaml

Standalone (user's app):

# Simple install
COPY package.json package-lock.json ./
RUN npm ci

# Simple build
COPY . .
RUN npm run build

# Simple copy
COPY --from=builder /app/dist ./dist
COPY --from=builder /app/node_modules ./node_modules

Vite Configuration for SSR

Required: Vite must be configured for dual builds (client + server)

// vite.config.ts
import { defineConfig } from 'vite';
import { svelte } from '@sveltejs/vite-plugin-svelte';

export default defineConfig({
  plugins: [svelte()],

  build: {
    // Client build (default)
    outDir: 'dist/client',
    rollupOptions: {
      input: 'src/client/index.ts'
    }
  },

  ssr: {
    // Don't externalize workspace packages
    noExternal: ['@composable-svelte/core']
  }
});

package.json scripts:

{
  "scripts": {
    "build": "vite build && vite build --ssr",
    "build:client": "vite build",
    "build:server": "vite build --ssr src/server/index.ts --outDir dist/server",
    "start": "NODE_ENV=production node dist/server/index.js"
  }
}

Why: SSR requires TWO builds:

  1. Client bundle: Runs in browser, hydrates SSR HTML
  2. Server bundle: Runs in Node.js, renders initial HTML

Fly.io Configuration

fly.toml (Apps V2 Syntax)

app = "my-composable-app"
primary_region = "sjc"  # Choose region closest to users

[build]
  dockerfile = "Dockerfile"

[env]
  NODE_ENV = "production"
  PORT = "3000"
  # Internal Fly network for backend communication
  COMPOSABLE_RUST_BACKEND_URL = "http://my-rust-backend.internal:8080"

[http_service]
  internal_port = 3000
  force_https = true
  auto_stop_machines = true
  auto_start_machines = true
  min_machines_running = 0  # Scale to zero (change to 1+ for production)

  [http_service.concurrency]
    type = "connections"
    hard_limit = 250
    soft_limit = 200

[[vm]]
  memory = '512mb'  # Minimum for Node.js SSR
  cpu_kind = 'shared'
  cpus = 1

# Health checks (Apps V2 syntax)
[[http_service.checks]]
  interval = "30s"
  timeout = "5s"
  grace_period = "10s"
  method = "GET"
  path = "/health"

Critical Patterns:

  • Apps V2 syntax:
    [[http_service.checks]]
    NOT
    [[services.http_checks]]
  • Internal networking:
    .internal
    domain for Rust backend (no internet egress)
  • Health endpoint: Must exist in your Fastify server
  • Secrets via CLI:
    fly secrets set KEY=value
    (NOT in fly.toml)

Common Mistakes:

  • ❌ Mixing Apps V1/V2 syntax → deployment fails
  • ❌ Hardcoding secrets in fly.toml → security breach
  • ❌ Missing health endpoint → app marked unhealthy
  • ❌ Using public URL for backend → unnecessary egress costs

Secrets Management

NEVER commit secrets to fly.toml:

# ✅ CORRECT: Use Fly secrets CLI
fly secrets set \
  SESSION_SECRET=$(openssl rand -hex 32) \
  JWT_SECRET=$(openssl rand -hex 32) \
  BACKEND_API_KEY=your-backend-api-key-here

# Verify secrets are set (values hidden)
fly secrets list

# ❌ WRONG: Environment variables in fly.toml
[env]
  SESSION_SECRET = "abc123"  # NEVER DO THIS

Validate secrets at startup:

// src/server/index.ts
if (!process.env.SESSION_SECRET || process.env.SESSION_SECRET.length < 32) {
  throw new Error('SESSION_SECRET must be at least 32 characters');
}

Security Hardening

HTTP Security Headers

Use built-in middleware:

import { fastifySecurityHeaders } from '@composable-svelte/core/ssr';

fastifySecurityHeaders(app, {
  contentSecurityPolicy: [
    "default-src 'self'",
    "script-src 'self' 'unsafe-inline'",  // Remove 'unsafe-inline' if possible
    "style-src 'self' 'unsafe-inline'",
    "img-src 'self' data: https:",
    "connect-src 'self' https://your-backend.fly.dev",
    "frame-ancestors 'none'",
    "base-uri 'self'"
  ].join('; '),
  frameOptions: 'DENY',
  referrerPolicy: 'strict-origin-when-cross-origin',
  hsts: {
    maxAge: 31536000,
    includeSubDomains: true,
    preload: true
  }
});

Test headers:

curl -I https://your-app.fly.dev
# Should see:
# Content-Security-Policy: default-src 'self'; ...
# X-Frame-Options: DENY
# Strict-Transport-Security: max-age=31536000

Rate Limiting

Per-IP rate limiting:

import { fastifyRateLimit } from '@composable-svelte/core/ssr';

fastifyRateLimit(app, {
  max: 100,        // 100 requests
  windowMs: 60000, // per minute
  message: 'Too many requests from this IP'
});

Per-route rate limiting:

app.get('/api/expensive', {
  config: {
    rateLimit: {
      max: 10,       // 10 requests
      timeWindow: 60000  // per minute
    }
  }
}, async (request, reply) => {
  // Expensive operation
});

CORS Configuration

Strict CORS for Rust backend:

import cors from '@fastify/cors';

app.register(cors, {
  origin: [
    'https://your-app.fly.dev',
    process.env.NODE_ENV === 'development' ? 'http://localhost:3000' : null
  ].filter(Boolean),
  credentials: true,
  methods: ['GET', 'POST', 'PUT', 'DELETE']
});

Docker Security

Non-root user (REQUIRED):

RUN addgroup -g 1001 -S nodejs && adduser -S nodejs -u 1001
COPY --from=builder --chown=nodejs:nodejs /app/dist ./dist
USER nodejs

Read-only filesystem (optional):

# fly.toml
[[vm]]
  read_only = true

[[mounts]]
  source = "logs"
  destination = "/app/logs"  # Only writable directory

Performance Optimization

SSR Caching

In-memory cache (single instance):

const cache = new Map<string, { html: string; timestamp: number }>();
const CACHE_TTL = 60000; // 1 minute

app.get('*', async (request, reply) => {
  const cacheKey = request.url;
  const cached = cache.get(cacheKey);

  if (cached && Date.now() - cached.timestamp < CACHE_TTL) {
    return reply.type('text/html').send(cached.html);
  }

  const html = await renderToHTML(/* ... */);
  cache.set(cacheKey, { html, timestamp: Date.now() });

  return reply.type('text/html').send(html);
});

Redis cache (multi-instance):

import Redis from 'ioredis';

const redis = new Redis(process.env.REDIS_URL);

async function getCachedHTML(key: string) {
  return await redis.get(key);
}

async function setCachedHTML(key: string, html: string, ttl: number) {
  await redis.setex(key, ttl, html);
}

Compression

Enable Brotli compression:

import compress from '@fastify/compress';

app.register(compress, {
  global: true,
  threshold: 1024, // Min size to compress (1KB)
  encodings: ['gzip', 'deflate', 'br'] // Brotli for best compression
});

Bundle Optimization

Code splitting (automatic with Vite):

// Lazy load heavy components
const HeavyChart = lazy(() => import('./HeavyChart.svelte'));

{#if showChart}
  <Suspense fallback={<Spinner />}>
    <HeavyChart data={chartData} />
  </Suspense>
{/if}

Tree shaking (use named imports):

// ✅ GOOD: Named imports
import { createStore, Effect } from '@composable-svelte/core';

// ❌ BAD: Wildcard imports
import * as Core from '@composable-svelte/core';

Deployment Workflow

Local Testing

# 1. Build Docker image
docker build -t my-composable-app .

# 2. Check image size (should be <150MB)
docker images my-composable-app

# 3. Run locally
docker run -p 3000:3000 \
  -e COMPOSABLE_RUST_BACKEND_URL=http://localhost:8080 \
  my-composable-app

# 4. Test health check
curl http://localhost:3000/health

Deploy to Fly.io

# 1. Login
fly auth login

# 2. Create app (first time)
fly launch

# 3. Set secrets
fly secrets set \
  SESSION_SECRET=$(openssl rand -hex 32) \
  JWT_SECRET=$(openssl rand -hex 32)

# 4. Deploy
fly deploy

# 5. Check status
fly status

# 6. Open in browser
fly open

Scaling

Horizontal scaling:

# Scale to 2 instances
fly scale count 2

# Scale to multiple regions
fly scale count 2 --region sjc  # San Jose
fly scale count 1 --region lhr  # London

Vertical scaling:

# Increase memory
fly scale memory 1024

# Increase CPU
fly scale vm shared-cpu-2x

Autoscaling (paid):

[auto_scaling]
  min_instances = 1
  max_instances = 10

  [[auto_scaling.metrics]]
    type = "requests"
    target = 500  # Scale when avg requests > 500/sec

Rollback

# List releases
fly releases

# Rollback to previous release
fly releases rollback

# Rollback to specific version
fly releases rollback v3

Internal Networking (Fly.io 6PN)

Key Pattern: Use

.internal
domain for Composable Rust backend

// fly.toml
[env]
  COMPOSABLE_RUST_BACKEND_URL = "http://my-rust-backend.internal:8080"

// Fastify server
export const backendAPI = createAPIClient({
  baseURL: process.env.COMPOSABLE_RUST_BACKEND_URL,
  timeout: 30000
});

Benefits:

  • ✅ Private network (no internet routing)
  • ✅ Faster (low latency)
  • ✅ Free (no egress charges)
  • ✅ Secure (not exposed to internet)

CORS on Rust backend:

let cors = CorsLayer::new()
    .allow_origin("https://my-composable-app.fly.dev".parse::<HeaderValue>().unwrap())
    .allow_methods([Method::GET, Method::POST])
    .allow_headers([AUTHORIZATION, CONTENT_TYPE]);

Monitoring

Health Check Endpoint

Required for Fly.io:

app.get('/health', async () => {
  return {
    status: 'ok',
    timestamp: new Date().toISOString(),
    uptime: process.uptime()
  };
});

Logs

# Stream logs
fly logs

# Filter errors only
fly logs | grep ERROR

# Tail last 100 lines
fly logs --tail 100

SSH into Instance

# Open console in running instance
fly ssh console

# Check processes
ps aux

# Check memory
free -m

# Exit
exit

Common Issues

App Won't Start

# Check logs
fly logs

# Common issues:
# - Missing environment variables
# - Port mismatch (must use PORT env var)
# - Build failed (check Dockerfile)

Health Check Failing

# Test locally first
docker run -p 3000:3000 my-composable-app
curl http://localhost:3000/health

# Check health check path in fly.toml matches your endpoint

Backend Connection Issues

# Verify internal network
fly ssh console
wget http://my-rust-backend.internal:8080/health

# If fails, check:
# - Backend is running
# - Backend app name is correct (.internal must match)
# - Firewall rules (Fly apps can communicate by default)

High Memory Usage

# Increase memory
fly scale memory 1024

# Or optimize Node.js
NODE_OPTIONS="--max-old-space-size=512"

Checklist

Pre-Deployment

  • All secrets in
    fly secrets
    (not env vars)
  • Security headers configured (CSP, HSTS)
  • Rate limiting enabled
  • CORS properly configured
  • Dependencies audited (
    pnpm audit
    )
  • Non-root user in Docker
  • HTTPS enforced
  • Health endpoint exists
  • Vite configured for SSR builds

Post-Deployment

  • Security headers verified (
    curl -I
    )
  • Rate limiting tested
  • Error tracking configured
  • Monitoring/alerts set up
  • Backup/recovery tested
  • Incident response plan documented

Performance Targets

MetricTargetExcellent
Docker image size<150MB<100MB
Initial JS bundle<100KB (gzipped)<70KB
Time to First Byte (TTFB)<200ms<100ms
First Contentful Paint (FCP)<1.8s<1.0s
Largest Contentful Paint (LCP)<2.5s<1.5s
Time to Interactive (TTI)<3.8s<2.5s
Cumulative Layout Shift (CLS)<0.1<0.05

Reference

  • Full deployment plan:
    plans/production-deployment/
  • SSR implementation:
    packages/core/src/lib/ssr/
  • Security guide:
    plans/production-deployment/SECURITY.md
  • Optimization guide:
    plans/production-deployment/OPTIMIZATION.md