home / skills / jonathanbelolo / composable-svelte / 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-deploymentReview the files below or copy the command above to add this skill to your agents.
---
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`
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.
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.
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.