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.
git clone https://github.com/majiayu000/claude-skill-registry
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"
skills/data/composable-svelte-deployment/SKILL.mdComposable 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:
(UID 1001)nodejs:nodejs - ✅ 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:
- Client bundle: Runs in browser, hydrates SSR HTML
- 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:
NOT[[http_service.checks]][[services.http_checks]] - ✅ Internal networking:
domain for Rust backend (no internet egress).internal - ✅ Health endpoint: Must exist in your Fastify server
- ✅ Secrets via CLI:
(NOT in fly.toml)fly secrets set KEY=value
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
(not env vars)fly secrets - 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
| Metric | Target | Excellent |
|---|---|---|
| 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