home / skills / jonathanbelolo / composable-svelte / composable-svelte-deployment

composable-svelte-deployment skill

/.claude/skills/composable-svelte-deployment

This skill guides production deployment of Composable Svelte SSR apps with Docker and Fly.io, including multi-stage builds, security hardening, and backend

npx playbooks add skill jonathanbelolo/composable-svelte --skill composable-svelte-deployment

Review the files below or copy the command above to add this skill to your agents.

Files (1)
SKILL.md
16.1 KB
---
name: composable-svelte-deployment
description: 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.
---

# 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

```dockerfile
# 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):
```dockerfile
# 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):
```dockerfile
# 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)

```typescript
// 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**:
```json
{
  "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)

```toml
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**:

```bash
# ✅ 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**:
```typescript
// 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**:
```typescript
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**:
```bash
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**:
```typescript
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**:
```typescript
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**:
```typescript
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):
```dockerfile
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):
```toml
# fly.toml
[[vm]]
  read_only = true

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

---

## Performance Optimization

### SSR Caching

**In-memory cache** (single instance):
```typescript
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):
```typescript
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**:
```typescript
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):
```typescript
// 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):
```typescript
// ✅ GOOD: Named imports
import { createStore, Effect } from '@composable-svelte/core';

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

---

## Deployment Workflow

### Local Testing

```bash
# 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

```bash
# 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**:
```bash
# 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**:
```bash
# Increase memory
fly scale memory 1024

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

**Autoscaling** (paid):
```toml
[auto_scaling]
  min_instances = 1
  max_instances = 10

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

---

### Rollback

```bash
# 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

```typescript
// 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**:
```rust
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**:
```typescript
app.get('/health', async () => {
  return {
    status: 'ok',
    timestamp: new Date().toISOString(),
    uptime: process.uptime()
  };
});
```

---

### Logs

```bash
# Stream logs
fly logs

# Filter errors only
fly logs | grep ERROR

# Tail last 100 lines
fly logs --tail 100
```

---

### SSH into Instance

```bash
# Open console in running instance
fly ssh console

# Check processes
ps aux

# Check memory
free -m

# Exit
exit
```

---

## Common Issues

### App Won't Start

```bash
# Check logs
fly logs

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

---

### Health Check Failing

```bash
# 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

```bash
# 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

```bash
# 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

| 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`

Overview

This skill documents production deployment patterns for Composable Svelte SSR applications, focused on Fly.io and Docker-based workflows. It provides concrete Docker multi-stage builds, Fly.io configuration, security hardening, performance tuning, and guidance for integrating Composable Rust backends. The content is actionable and aimed at keeping images small, secure, and production-ready.

How this skill works

It prescribes a 3-stage Docker build (deps → builder → runner) to produce minimal, non-root runtime images and reliable ES module resolution. It shows Vite SSR configuration for separate client and server builds, Fly.io Apps V2 configuration including internal .internal networking to connect to a Rust backend, and runtime safeguards like health checks, secrets validation, and HTTP security headers. It also covers caching, compression, rate limiting, and monitoring for scalable SSR.

When to use it

  • Deploying a Composable Svelte SSR app to Fly.io or any Docker-friendly cloud platform
  • You need <150MB production images with no devDependencies
  • When integrating a Rust backend over Fly.io internal networking (.internal)
  • When you require secure defaults: non-root runtime, CSP, HSTS, and rate limits
  • When you need clear SSR caching and compression strategies for performance

Best practices

  • Use a 3-stage Dockerfile: deps (prod) → builder → runner (minimal, non-root user)
  • Run health checks and validate required secrets at startup; set secrets via Fly CLI, not fly.toml
  • Configure Vite for dual builds (client + server) and don't externalize workspace packages needed at runtime
  • Enable compression (Brotli), response caching (in-memory for single instance, Redis for multi-instance), and code-splitting for heavy components
  • Harden HTTP with strict CSP, HSTS, X-Frame-Options, and per-route rate limiting

Example use cases

  • Build and deploy a single-repository SSR app to Fly.io with private Rust backend via my-backend.internal:8080
  • Package a monorepo: install workspace deps, build core and example packages, then copy only dist and production deps into runner image
  • Local workflow: build Docker image, run with COMPOSABLE_RUST_BACKEND_URL pointing to localhost for integration testing, validate /health
  • Scale on Fly.io: start with min memory 512mb, then scale horizontally or vertically and enable auto_scaling for traffic-driven scaling

FAQ

How do I keep the final Docker image small?

Use the 3-stage build pattern, install production dependencies only in the deps stage, remove source maps, and use an Alpine base with a non-root user.

Where should I store secrets for Fly deploys?

Never commit secrets in fly.toml. Use fly secrets set to store SESSION_SECRET, JWT_SECRET, and other sensitive values and validate them on app startup.