Web application deployment patterns for production hosting and CI/CD. Load when deploying web applications or configuring deployment pipelines.
Installation
Details
Usage
After installing, this skill will be available to your AI coding assistant.
Verify installation:
npx agent-skills-cli listSkill Instructions
name: preferences-web-application-deployment description: Web application deployment patterns for production hosting and CI/CD. Load when deploying web applications or configuring deployment pipelines.
Web application deployment
Deployment philosophy
Deploy web applications to Cloudflare Workers/Pages unless specific requirements dictate otherwise.
Why Cloudflare
Technical advantages:
- Global edge network with automatic geo-distribution (300+ locations)
- Zero cold starts with V8 isolate-based workers (sub-millisecond startup)
- Integrated platform with consistent billing and unified developer experience
- Type-safe bindings via wrangler for platform resources
- Native support for modern web standards (Web APIs, streaming, WebSockets)
Economic advantages:
- Generous free tier (100,000 requests/day, 10ms CPU time per request)
- Predictable pricing with no hidden costs
- No infrastructure management overhead
- Automatic scaling without capacity planning
Developer experience:
- Local development with
wrangler devmirrors production environment - Instant deployments via git integration or CLI
- Built-in observability (metrics, logs, traces)
- TypeScript-first with automatic type generation
When to consider alternatives
Use alternative platforms when:
- Workload requires >30 seconds of CPU time per request (use Cloudflare Workflows or traditional servers)
- Application depends on native binaries not available in Workers runtime
- Regulatory requirements mandate specific geographic data residency beyond Cloudflare's controls
- Existing infrastructure investment makes migration cost-prohibitive
Database configuration
Prefer Cloudflare D1 for SQLite-compatible workloads, PostgreSQL for complex relational requirements.
Cloudflare D1 (preferred)
Cloudflare D1 provides serverless SQLite at the edge with zero configuration scaling.
When to use D1:
- Application data fits SQLite's capabilities (relational data, transactions, full-text search)
- Read-heavy workloads benefit from edge caching
- Cost optimization is critical (D1 has generous free tier)
- Simplified operations without connection pooling concerns
Limitations:
- SQLite limitations apply (no concurrent writes from multiple isolates)
- 10GB database size limit per database
- Single-region primary with read replicas at edge
Environment variables for D1
# packages/data-ops/.env or apps/user-application/.env
CLOUDFLARE_DATABASE_ID="<database-id-from-dashboard>"
CLOUDFLARE_ACCOUNT_ID="<account-id-from-dashboard>"
CLOUDFLARE_D1_TOKEN="<api-token-with-d1-permissions>"
Create D1 database:
# Create production database
wrangler d1 create my-app-db
# Create staging database
wrangler d1 create my-app-db-stage
# List databases
wrangler d1 list
Drizzle configuration for D1
// packages/data-ops/drizzle.config.ts
import type { Config } from "drizzle-kit";
const config: Config = {
out: "./src/drizzle",
schema: ["./src/drizzle/auth-schema.ts"],
dialect: "sqlite",
driver: "d1-http",
dbCredentials: {
accountId: process.env.CLOUDFLARE_ACCOUNT_ID!,
databaseId: process.env.CLOUDFLARE_DATABASE_ID!,
token: process.env.CLOUDFLARE_D1_TOKEN!,
},
tablesFilter: ["!_cf_KV", "!auth_*"],
};
export default config satisfies Config;
Wrangler D1 binding
// wrangler.jsonc
{
"d1_databases": [
{
"binding": "DB", // Access via env.DB in worker code
"database_id": "your-database-id",
"database_name": "my-app-db",
"experimental_remote": true // Enable remote D1 access during local dev
}
]
}
Runtime database access
// src/server.ts or server functions
import { env } from "cloudflare:workers";
import { drizzle } from "drizzle-orm/d1";
export default {
fetch(request: Request) {
// D1 binding available via env.DB
const db = drizzle(env.DB);
// Use Drizzle for type-safe queries
const users = await db.select().from(usersTable);
return new Response(JSON.stringify(users));
}
}
D1 migrations
# Generate migration from schema changes
pnpm run generate-drizzle-sql-output
# Apply migrations to D1 (local dev)
wrangler d1 execute my-app-db --local --file=./packages/data-ops/src/drizzle/0001_migration.sql
# Apply migrations to D1 (production)
wrangler d1 execute my-app-db --file=./packages/data-ops/src/drizzle/0001_migration.sql
# Or use Drizzle Kit's migration runner
pnpm run drizzle:migrate
PostgreSQL (secondary option)
Use PostgreSQL for workloads requiring advanced features not available in SQLite.
When to use PostgreSQL:
- Advanced data types needed (JSON path queries, arrays, hstore)
- Complex analytical queries benefit from query planner
- Team expertise in PostgreSQL
- Existing PostgreSQL database migration
Recommended providers:
- Supabase with Supavisor transaction mode (pooled connections)
- Neon with built-in connection pooling (serverless-optimized)
Environment variables for PostgreSQL
# packages/data-ops/.env
DATABASE_HOST="hostname.com/database-name"
DATABASE_USERNAME="username"
DATABASE_PASSWORD="password"
Connection string format:
postgresql://{username}:{password}@{hostname}/{database-name}
Drizzle configuration for PostgreSQL
// packages/data-ops/drizzle.config.ts
import type { Config } from "drizzle-kit";
const config: Config = {
out: "./src/drizzle",
schema: ["./src/drizzle/auth-schema.ts"],
dialect: "postgresql",
dbCredentials: {
url: `postgresql://${process.env.DATABASE_USERNAME}:${process.env.DATABASE_PASSWORD}@${process.env.DATABASE_HOST}`,
},
tablesFilter: ["!_cf_KV", "!auth_*"],
};
export default config satisfies Config;
Runtime database access with connection pooling
// src/server.ts
import { initDatabase } from "@repo/data-ops/database/setup";
import { env } from "cloudflare:workers";
export default {
fetch(request: Request) {
// Initialize pooled connection
const db = initDatabase({
host: env.DATABASE_HOST,
username: env.DATABASE_USERNAME,
password: env.DATABASE_PASSWORD,
});
// Database queries here
}
}
Important: Use transaction mode or connection pooling for serverless environments. Workers create many short-lived connections - traditional connection pooling fails without Supavisor/Neon pooling.
Schema management with Drizzle
All database schemas managed via Drizzle ORM for type-safe database access.
Workflow:
- Define schemas in TypeScript using Drizzle schema builders
- Generate migrations from schema changes with
drizzle-kit generate - Apply migrations to database with
drizzle-kit migrateor wrangler - Pull schema from database to verify with
drizzle-kit pull
# From workspace root
pnpm run generate-drizzle-sql-output # Generate migration SQL
pnpm run drizzle:migrate # Apply to database
pnpm run pull-drizzle-schema # Verify schema sync
See @~/.claude/skills/preferences-schema-versioning/SKILL.md for migration patterns and versioning strategies.
Wrangler configuration
Wrangler configures Cloudflare Workers deployment, local development, and platform bindings.
Essential wrangler.jsonc structure
{
// JSON schema for IDE autocomplete
"$schema": "node_modules/wrangler/config-schema.json",
// Worker name (deployment identifier)
"name": "my-app",
// Entry point - custom server or framework default
"main": "src/server.ts",
// API compatibility date (use recent date for latest features)
"compatibility_date": "2025-04-10",
// Enable Node.js compatibility layer for npm packages
"compatibility_flags": ["nodejs_compat"],
// Enable workers.dev subdomain for testing (disable in production)
"workers_dev": true,
// Static assets configuration (for SPAs, SSR apps)
"assets": {
"directory": ".output/public",
"binding": "ASSETS",
"not_found_handling": "single-page-application",
// Routes where worker runs before checking static assets
"run_worker_first": ["/api/*", "/trpc/*", "/auth/*"]
},
// Build command (runs before deployment)
"build": {
"command": "pnpm run build"
},
// Enable observability (metrics, logs, traces)
"observability": {
"enabled": true
}
}
Environment variables vs secrets
Use vars for non-sensitive configuration:
{
"vars": {
"API_URL": "https://api.example.com",
"FEATURE_FLAG_NEW_UI": "true",
"LOG_LEVEL": "info"
}
}
Use secrets for sensitive data:
# Set secret (not stored in wrangler.jsonc)
wrangler secret put DATABASE_PASSWORD
wrangler secret put GOOGLE_CLIENT_SECRET
wrangler secret put BETTER_AUTH_SECRET
# List secrets
wrangler secret list
# Delete secret
wrangler secret delete OLD_SECRET
Access in worker code:
import { env } from "cloudflare:workers";
// Both vars and secrets available via env
const apiUrl = env.API_URL; // from vars
const dbPassword = env.DATABASE_PASSWORD; // from secret
Type generation for environment
Generate TypeScript types for Cloudflare environment bindings:
# Generate types from wrangler.jsonc
pnpm run cf-typegen
# Or directly
wrangler types --env-interface Env
Usage:
import { env } from "cloudflare:workers";
// env is fully typed based on wrangler.jsonc configuration
env.DB // Type: D1Database
env.CACHE // Type: KVNamespace
env.MY_VAR // Type: string
Platform resources and bindings
Cloudflare Workers integrate with platform resources via bindings configured in wrangler.jsonc.
See @~/.claude/skills/preferences-cloudflare-wrangler-reference/SKILL.md for comprehensive binding configuration.
D1 databases (serverless SQL)
{
"d1_databases": [
{
"binding": "DB",
"database_id": "your-database-id",
"experimental_remote": true
}
]
}
Usage:
import { drizzle } from "drizzle-orm/d1";
const db = drizzle(env.DB);
const users = await db.select().from(usersTable);
KV namespaces (key-value caching)
{
"kv_namespaces": [
{
"binding": "CACHE",
"id": "your-kv-namespace-id",
"experimental_remote": true
}
]
}
Usage:
// Cache API responses
await env.CACHE.put("user:123", JSON.stringify(user), {
expirationTtl: 3600 // 1 hour
});
const cached = await env.CACHE.get("user:123", "json");
R2 buckets (object storage)
{
"r2_buckets": [
{
"binding": "BUCKET",
"bucket_name": "my-storage"
}
]
}
Usage:
// S3-compatible API
await env.BUCKET.put("uploads/file.pdf", fileData, {
httpMetadata: {
contentType: "application/pdf"
}
});
const file = await env.BUCKET.get("uploads/file.pdf");
const blob = await file.blob();
Service bindings (microservices)
Service bindings enable type-safe communication between multiple workers.
{
"services": [
{
"binding": "BACKEND_SERVICE",
"service": "data-service-production",
"experimental_remote": true
}
]
}
Usage:
// Call another worker service
const response = await env.BACKEND_SERVICE.fetch(
new Request("https://internal/api/data", {
method: "POST",
body: JSON.stringify({ query: "..." })
})
);
const data = await response.json();
Pattern: Monorepo with multiple worker apps communicating via service bindings.
apps/
user-application/ # Frontend worker (TanStack Start)
data-service/ # Backend worker (Hono API)
See backpine-saas-kit for reference implementation.
Queues (async message processing)
{
"queues": {
"producers": [
{
"binding": "QUEUE",
"queue": "data-processing-queue"
}
],
"consumers": [
{
"queue": "data-processing-queue",
"dead_letter_queue": "data-processing-dlq"
}
]
}
}
Producer usage:
// Send message to queue
await env.QUEUE.send({
userId: "123",
action: "process_upload"
});
// Batch send
await env.QUEUE.sendBatch([
{ body: { userId: "123" } },
{ body: { userId: "456" } }
]);
Consumer handler:
export default {
async queue(batch: MessageBatch, env: Env): Promise<void> {
for (const message of batch.messages) {
try {
await processMessage(message.body);
message.ack();
} catch (error) {
message.retry();
}
}
}
}
Workflows (durable execution)
Long-running, durable processes that survive worker restarts.
{
"workflows": [
{
"binding": "MY_WORKFLOW",
"name": "my-workflow-production",
"class_name": "MyWorkflow"
}
]
}
Workflow definition:
import { WorkflowEntrypoint, WorkflowStep } from "cloudflare:workers";
export class MyWorkflow extends WorkflowEntrypoint {
async run(event: any, step: WorkflowStep) {
// Steps are checkpointed - execution resumes on failure
const result1 = await step.do("fetch-data", async () => {
return await fetch("https://api.example.com/data");
});
// Sleep preserves state
await step.sleep("wait-for-processing", "1 hour");
const result2 = await step.do("process-data", async () => {
return processData(result1);
});
return result2;
}
}
Durable Objects (stateful compute)
Strongly consistent, stateful workers with persistent storage.
{
"durable_objects": {
"bindings": [
{
"name": "COUNTER",
"class_name": "Counter"
}
]
},
"migrations": [
{
"tag": "v1",
"new_classes": ["Counter"]
}
]
}
Durable Object definition:
import { DurableObject } from "cloudflare:workers";
export class Counter extends DurableObject {
async fetch(request: Request) {
let count = (await this.ctx.storage.get<number>("count")) || 0;
count++;
await this.ctx.storage.put("count", count);
return new Response(count.toString());
}
}
Access from worker:
// Create unique Durable Object instance
const id = env.COUNTER.idFromName("global-counter");
const stub = env.COUNTER.get(id);
// Call Durable Object
const response = await stub.fetch(request);
Workers AI (AI model inference)
{
"ai": {
"binding": "AI"
}
}
Usage:
const response = await env.AI.run("@cf/meta/llama-2-7b-chat-int8", {
prompt: "What is the capital of France?"
});
Browser rendering
{
"browser": {
"binding": "VIRTUAL_BROWSER"
}
}
Usage:
const browser = await env.VIRTUAL_BROWSER.launch();
const page = await browser.newPage();
await page.goto("https://example.com");
const screenshot = await page.screenshot();
TanStack Start SSR deployment
Deploy TanStack Start applications to Cloudflare Workers with SSR support.
Project structure
src/
routes/ # File-based routes
server.ts # Custom Cloudflare Workers entry point
start.tsx # TanStack Start client entry
vite.config.ts # Vite + Cloudflare plugin configuration
wrangler.jsonc # Cloudflare deployment configuration
Vite configuration with Cloudflare plugin
// vite.config.ts
import { defineConfig } from "vite";
import { tanstackStart } from "@tanstack/react-start/plugin/vite";
import viteReact from "@vitejs/plugin-react";
import viteTsConfigPaths from "vite-tsconfig-paths";
import tailwindcss from "@tailwindcss/vite";
import { cloudflare } from "@cloudflare/vite-plugin";
export default defineConfig({
plugins: [
viteTsConfigPaths({
projects: ["./tsconfig.json"],
}),
tailwindcss(),
tanstackStart({
srcDirectory: "src",
start: { entry: "./start.tsx" },
server: { entry: "./server.ts" }, // Custom server entry
}),
viteReact(),
cloudflare({
viteEnvironment: {
name: "ssr", // Enable SSR environment
},
}),
],
});
Custom server entry point
// src/server.ts
import { setAuth } from "@repo/data-ops/auth/server";
import { initDatabase } from "@repo/data-ops/database/setup";
import handler from "@tanstack/react-start/server-entry";
import { env } from "cloudflare:workers";
export default {
fetch(request: Request) {
// Initialize database connection
const db = initDatabase({
host: env.DATABASE_HOST,
username: env.DATABASE_USERNAME,
password: env.DATABASE_PASSWORD,
});
// Configure authentication
setAuth({
secret: env.BETTER_AUTH_SECRET,
socialProviders: {
google: {
clientId: env.GOOGLE_CLIENT_ID,
clientSecret: env.GOOGLE_CLIENT_SECRET,
},
},
adapter: {
drizzleDb: db,
provider: "pg", // or "sqlite" for D1
},
});
// Delegate to TanStack Start handler
return handler.fetch(request, {
context: {
db,
// Additional context passed to routes
},
});
},
};
Wrangler configuration for SSR
{
"name": "my-app",
"main": "src/server.ts",
"compatibility_date": "2025-04-10",
"compatibility_flags": ["nodejs_compat"],
"assets": {
"directory": ".output/public",
"binding": "ASSETS",
"not_found_handling": "single-page-application",
// API routes handled by worker before checking static assets
"run_worker_first": ["/api/*", "/trpc/*", "/auth/*"]
},
"d1_databases": [
{
"binding": "DB",
"database_id": "production-db-id"
}
],
"vars": {
"API_URL": "https://api.example.com"
}
}
Deployment workflow
# Generate Cloudflare types
pnpm run cf-typegen
# Build application (runs Vite build)
pnpm run build
# Deploy to Cloudflare
pnpm run deploy
# Or combined
pnpm run build && wrangler deploy
Server functions with Cloudflare bindings
// src/core/functions/example-functions.ts
import { createServerFn } from "@tanstack/react-start";
import { env } from "cloudflare:workers";
import { z } from "zod";
const InputSchema = z.object({
userId: z.string(),
});
export const fetchUserData = createServerFn()
.validator((data) => InputSchema.parse(data))
.handler(async ({ data }) => {
// Access Cloudflare bindings
const cached = await env.CACHE.get(`user:${data.userId}`, "json");
if (cached) return cached;
const user = await env.DB.prepare(
"SELECT * FROM users WHERE id = ?"
).bind(data.userId).first();
// Cache for 1 hour
await env.CACHE.put(`user:${data.userId}`, JSON.stringify(user), {
expirationTtl: 3600,
});
return user;
});
Static site deployment
Deploy client-only applications (no SSR) to Cloudflare Pages.
When to use static deployment
Use static deployment for:
- Client-side rendered applications (Vite + React)
- Static site generators (Astro, VitePress)
- Documentation sites
- Marketing pages
Use Workers deployment for:
- Server-side rendering (TanStack Start, Remix)
- API routes or backend logic
- Authentication flows
- Dynamic content generation
Cloudflare Pages deployment
Via Git integration (recommended):
- Connect repository to Cloudflare Pages
- Configure build settings in Cloudflare dashboard:
- Build command:
pnpm run build - Build output directory:
dist(or.output/publicfor TanStack Start)
- Build command:
- Automatic deployments on git push
Via wrangler CLI:
# Build application
pnpm run build
# Deploy to Pages
wrangler pages deploy dist --project-name=my-app
# Or configure in wrangler.jsonc with pages-specific settings
Pages configuration for SPAs
{
"pages": {
"project_name": "my-app",
"build_output_directory": "dist",
// SPA mode - serve index.html for all non-asset routes
"single_page_application": true
}
}
Pages Functions (serverless API routes)
Add API routes to static sites with Pages Functions:
functions/
api/
hello.ts # Available at /api/hello
users/
[id].ts # Available at /api/users/:id
Function example:
// functions/api/hello.ts
export async function onRequest(context) {
const { request, env } = context;
// Access bindings (KV, D1, etc.) via env
const data = await env.KV.get("greeting");
return new Response(JSON.stringify({ message: data }), {
headers: { "Content-Type": "application/json" },
});
}
Multi-environment deployment strategies
Separate development, staging, and production environments with wrangler.jsonc env configuration.
Environment configuration pattern
{
"name": "my-app",
// Default configuration (development)
"vars": {
"API_URL": "https://api-dev.example.com"
},
"d1_databases": [
{
"binding": "DB",
"database_id": "dev-database-id",
"experimental_remote": true
}
],
// Environment-specific overrides
"env": {
"stage": {
"vars": {
"API_URL": "https://api-stage.example.com"
},
"d1_databases": [
{
"binding": "DB",
"database_id": "stage-database-id"
}
],
"routes": [
{
"pattern": "stage.example.com",
"custom_domain": true
}
]
},
"production": {
"vars": {
"API_URL": "https://api.example.com"
},
"d1_databases": [
{
"binding": "DB",
"database_id": "production-database-id"
}
],
"routes": [
{
"pattern": "example.com",
"custom_domain": true
}
]
}
}
}
Deploy to specific environment
# Deploy to staging
wrangler deploy --env stage
# Deploy to production
wrangler deploy --env production
# Local development (uses default config)
wrangler dev
Environment-specific secrets
# Set secrets per environment
wrangler secret put DATABASE_PASSWORD --env stage
wrangler secret put DATABASE_PASSWORD --env production
# Secrets isolated between environments
Package.json scripts for multi-environment
{
"scripts": {
"dev": "vite dev",
"build": "vite build",
"deploy:stage": "pnpm run build && wrangler deploy --env stage",
"deploy:production": "pnpm run build && wrangler deploy --env production",
"cf-typegen": "wrangler types --env-interface Env"
}
}
Local development
Wrangler dev for local testing
# Start local development server with remote bindings
wrangler dev
# Custom port
wrangler dev --port 3000
# With remote bindings (access production D1, KV)
wrangler dev --remote
# Enable experimental local mode
wrangler dev --experimental-local
Note: Use experimental_remote: true in bindings to access remote resources during local dev.
Environment variables for local dev
# .dev.vars (gitignored - for local secrets)
DATABASE_PASSWORD=local-password
GOOGLE_CLIENT_SECRET=dev-client-secret
BETTER_AUTH_SECRET=local-auth-secret
Wrangler loads .dev.vars automatically during wrangler dev.
Framework-specific dev servers
# TanStack Start (uses Vite + Cloudflare plugin)
pnpm dev
# Hono (direct wrangler dev)
wrangler dev --x-remote-bindings
Observability and monitoring
Enable observability in wrangler
{
"observability": {
"enabled": true
}
}
Access metrics, logs, and traces in Cloudflare dashboard under Workers & Pages > [Your Worker] > Metrics.
Logging patterns
// Structured logging
console.log(JSON.stringify({
level: "info",
message: "User authenticated",
userId: user.id,
timestamp: new Date().toISOString(),
}));
// Error logging with context
console.error("Database query failed", {
query: sql,
error: error.message,
userId: context.userId,
});
Performance monitoring
// Measure execution time
const start = Date.now();
await expensiveOperation();
const duration = Date.now() - start;
console.log(JSON.stringify({
operation: "data_processing",
duration_ms: duration,
}));
Custom analytics with Analytics Engine
{
"analytics_engine_datasets": [
{
"binding": "ANALYTICS"
}
]
}
Usage:
env.ANALYTICS.writeDataPoint({
blobs: ["user_signup"],
doubles: [1],
indexes: [userId],
});
Best practices
Security
- Never commit secrets to wrangler.jsonc - use
wrangler secret put - Use .dev.vars for local development secrets (gitignored)
- Validate all inputs with Zod schemas at worker boundaries
- Sanitize outputs to prevent XSS in SSR contexts
- Set CORS headers explicitly for API routes
Performance
- Cache aggressively with KV for read-heavy data
- Use bindings instead of fetch for inter-service communication
- Minimize bundle size - tree-shake unused code
- Leverage edge caching for static assets and API responses
- Use streaming for large responses
Cost optimization
- Use D1 over PostgreSQL when possible (lower cost)
- Implement caching to reduce database queries
- Batch operations to reduce request count
- Set appropriate TTLs on cached data
- Use free tier limits strategically (D1, KV, Workers)
Development workflow
- Type generation first - run
cf-typegenafter wrangler.jsonc changes - Test locally with
wrangler dev --remotefor production parity - Use environments for staging deployments before production
- Automate deployments via CI/CD with wrangler GitHub Actions
- Monitor observability dashboard for errors and performance
Integration with other preferences
See related preference files for complementary patterns:
- @~/.claude/skills/preferences-react-tanstack-ui-development/SKILL.md - TanStack Start SSR patterns
- @~/.claude/skills/preferences-typescript-nodejs-development/SKILL.md - Hono backend patterns
- @~/.claude/skills/preferences-schema-versioning/SKILL.md - Database migration workflows
- @~/.claude/skills/preferences-railway-oriented-programming/SKILL.md - Error handling in server functions
- @~/.claude/skills/preferences-cloudflare-wrangler-reference/SKILL.md - Comprehensive wrangler configuration
More by cameronraysmith
View allGenerate an optimal handoff prompt for Claude Code agent transitions preserving critical context.
Railway-oriented programming with Result types and workflow composition for error handling. Load when designing error handling pipelines or composing fallible operations.
Session start action to run diagnostics, synthesize project status, and identify next actions.
Style and formatting conventions for code, documentation, naming, and file organization. Load when reviewing style consistency or setting up new files.
