// 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.
| 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. |
This skill covers production deployment of Composable Svelte SSR applications, with focus on Fly.io and Docker-based deployments.
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?
@composable-svelte/core/ssrGoal: <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:
nodejs:nodejs (UID 1001)find dist -name "*.map" -deleteCommon Mistakes:
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
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:
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:
[[http_service.checks]] NOT [[services.http_checks]].internal domain for Rust backend (no internet egress)fly secrets set KEY=value (NOT in fly.toml)Common Mistakes:
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');
}
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
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
});
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']
});
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
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);
}
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
});
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';
# 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
# 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
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
# List releases
fly releases
# Rollback to previous release
fly releases rollback
# Rollback to specific version
fly releases rollback v3
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:
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]);
Required for Fly.io:
app.get('/health', async () => {
return {
status: 'ok',
timestamp: new Date().toISOString(),
uptime: process.uptime()
};
});
# Stream logs
fly logs
# Filter errors only
fly logs | grep ERROR
# Tail last 100 lines
fly logs --tail 100
# Open console in running instance
fly ssh console
# Check processes
ps aux
# Check memory
free -m
# Exit
exit
# Check logs
fly logs
# Common issues:
# - Missing environment variables
# - Port mismatch (must use PORT env var)
# - Build failed (check Dockerfile)
# 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
# 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)
# Increase memory
fly scale memory 1024
# Or optimize Node.js
NODE_OPTIONS="--max-old-space-size=512"
fly secrets (not env vars)pnpm audit)curl -I)| 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 |
plans/production-deployment/packages/core/src/lib/ssr/plans/production-deployment/SECURITY.mdplans/production-deployment/OPTIMIZATION.md