@donkeylabs/server 2.0.19 → 2.0.21
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/docs/caching-strategies.md +677 -0
- package/docs/dev-experience.md +656 -0
- package/docs/hot-reload-limitations.md +166 -0
- package/docs/load-testing.md +974 -0
- package/docs/plugin-registry-design.md +1064 -0
- package/docs/production.md +1229 -0
- package/docs/workflows.md +90 -3
- package/package.json +1 -1
- package/src/admin/routes.ts +153 -0
- package/src/core/cron.ts +90 -9
- package/src/core/index.ts +31 -0
- package/src/core/job-adapter-kysely.ts +176 -73
- package/src/core/job-adapter-sqlite.ts +10 -0
- package/src/core/jobs.ts +112 -17
- package/src/core/migrations/workflows/002_add_metadata_column.ts +28 -0
- package/src/core/process-adapter-kysely.ts +62 -21
- package/src/core/storage-adapter-local.test.ts +199 -0
- package/src/core/storage.test.ts +197 -0
- package/src/core/workflow-adapter-kysely.ts +66 -19
- package/src/core/workflow-executor.ts +239 -0
- package/src/core/workflow-proxy.ts +238 -0
- package/src/core/workflow-socket.ts +449 -0
- package/src/core/workflow-state-machine.ts +593 -0
- package/src/core/workflows.test.ts +758 -0
- package/src/core/workflows.ts +705 -595
- package/src/core.ts +17 -6
- package/src/index.ts +14 -0
- package/src/testing/database.test.ts +263 -0
- package/src/testing/database.ts +173 -0
- package/src/testing/e2e.test.ts +189 -0
- package/src/testing/e2e.ts +272 -0
- package/src/testing/index.ts +18 -0
|
@@ -0,0 +1,1229 @@
|
|
|
1
|
+
# Production Deployment Guide
|
|
2
|
+
|
|
3
|
+
Deploying DonkeyLabs applications to production environments with Docker, monitoring, and best practices.
|
|
4
|
+
|
|
5
|
+
## Table of Contents
|
|
6
|
+
|
|
7
|
+
- [Docker Deployment](#docker-deployment)
|
|
8
|
+
- [Environment Configuration](#environment-configuration)
|
|
9
|
+
- [Database Migrations](#database-migrations)
|
|
10
|
+
- [Health Checks](#health-checks)
|
|
11
|
+
- [Logging & Monitoring](#logging--monitoring)
|
|
12
|
+
- [Performance Optimization](#performance-optimization)
|
|
13
|
+
- [Security Hardening](#security-hardening)
|
|
14
|
+
- [CI/CD Pipelines](#cicd-pipelines)
|
|
15
|
+
- [Scaling Strategies](#scaling-strategies)
|
|
16
|
+
- [Troubleshooting Production](#troubleshooting-production)
|
|
17
|
+
|
|
18
|
+
---
|
|
19
|
+
|
|
20
|
+
## Docker Deployment
|
|
21
|
+
|
|
22
|
+
### Basic Dockerfile
|
|
23
|
+
|
|
24
|
+
```dockerfile
|
|
25
|
+
# Dockerfile
|
|
26
|
+
FROM oven/bun:1.0-alpine
|
|
27
|
+
|
|
28
|
+
WORKDIR /app
|
|
29
|
+
|
|
30
|
+
# Copy package files
|
|
31
|
+
COPY package.json bun.lockb ./
|
|
32
|
+
COPY packages/server/package.json ./packages/server/
|
|
33
|
+
COPY packages/cli/package.json ./packages/cli/
|
|
34
|
+
|
|
35
|
+
# Install dependencies
|
|
36
|
+
RUN bun install --frozen-lockfile
|
|
37
|
+
|
|
38
|
+
# Copy source code
|
|
39
|
+
COPY . .
|
|
40
|
+
|
|
41
|
+
# Generate types (if needed for build)
|
|
42
|
+
RUN bun run gen:types || true
|
|
43
|
+
|
|
44
|
+
# Build for production (if using SvelteKit)
|
|
45
|
+
RUN bun run build || true
|
|
46
|
+
|
|
47
|
+
# Expose port
|
|
48
|
+
EXPOSE 3000
|
|
49
|
+
|
|
50
|
+
# Health check
|
|
51
|
+
HEALTHCHECK --interval=30s --timeout=3s --start-period=5s --retries=3 \
|
|
52
|
+
CMD curl -f http://localhost:3000/health || exit 1
|
|
53
|
+
|
|
54
|
+
# Start server
|
|
55
|
+
CMD ["bun", "run", "src/server/index.ts"]
|
|
56
|
+
```
|
|
57
|
+
|
|
58
|
+
### Multi-Stage Build (Optimized)
|
|
59
|
+
|
|
60
|
+
```dockerfile
|
|
61
|
+
# Dockerfile.production
|
|
62
|
+
# Stage 1: Dependencies
|
|
63
|
+
FROM oven/bun:1.0-alpine AS deps
|
|
64
|
+
WORKDIR /app
|
|
65
|
+
COPY package.json bun.lockb ./
|
|
66
|
+
RUN bun install --frozen-lockfile --production
|
|
67
|
+
|
|
68
|
+
# Stage 2: Builder
|
|
69
|
+
FROM oven/bun:1.0-alpine AS builder
|
|
70
|
+
WORKDIR /app
|
|
71
|
+
COPY --from=deps /app/node_modules ./node_modules
|
|
72
|
+
COPY . .
|
|
73
|
+
RUN bun run gen:types
|
|
74
|
+
RUN bun run build
|
|
75
|
+
|
|
76
|
+
# Stage 3: Production
|
|
77
|
+
FROM oven/bun:1.0-alpine AS runner
|
|
78
|
+
WORKDIR /app
|
|
79
|
+
|
|
80
|
+
# Create non-root user
|
|
81
|
+
RUN addgroup -g 1001 -S nodejs
|
|
82
|
+
RUN adduser -S bunuser -u 1001
|
|
83
|
+
|
|
84
|
+
# Copy only necessary files
|
|
85
|
+
COPY --from=builder --chown=bunuser:bunuser /app/dist ./dist
|
|
86
|
+
COPY --from=builder --chown=bunuser:bunuser /app/node_modules ./node_modules
|
|
87
|
+
COPY --from=builder --chown=bunuser:bunuser /app/package.json ./package.json
|
|
88
|
+
|
|
89
|
+
# Switch to non-root user
|
|
90
|
+
USER bunuser
|
|
91
|
+
|
|
92
|
+
EXPOSE 3000
|
|
93
|
+
|
|
94
|
+
ENV NODE_ENV=production
|
|
95
|
+
ENV PORT=3000
|
|
96
|
+
|
|
97
|
+
HEALTHCHECK --interval=30s --timeout=3s --start-period=5s --retries=3 \
|
|
98
|
+
CMD curl -f http://localhost:3000/health || exit 1
|
|
99
|
+
|
|
100
|
+
CMD ["bun", "run", "dist/server/index.js"]
|
|
101
|
+
```
|
|
102
|
+
|
|
103
|
+
### Docker Compose
|
|
104
|
+
|
|
105
|
+
```yaml
|
|
106
|
+
# docker-compose.yml
|
|
107
|
+
version: '3.8'
|
|
108
|
+
|
|
109
|
+
services:
|
|
110
|
+
app:
|
|
111
|
+
build:
|
|
112
|
+
context: .
|
|
113
|
+
dockerfile: Dockerfile
|
|
114
|
+
ports:
|
|
115
|
+
- "3000:3000"
|
|
116
|
+
environment:
|
|
117
|
+
- NODE_ENV=production
|
|
118
|
+
- DATABASE_URL=postgresql://user:pass@db:5432/app
|
|
119
|
+
- REDIS_URL=redis://redis:6379
|
|
120
|
+
- JWT_SECRET=${JWT_SECRET}
|
|
121
|
+
depends_on:
|
|
122
|
+
db:
|
|
123
|
+
condition: service_healthy
|
|
124
|
+
redis:
|
|
125
|
+
condition: service_healthy
|
|
126
|
+
restart: unless-stopped
|
|
127
|
+
healthcheck:
|
|
128
|
+
test: ["CMD", "curl", "-f", "http://localhost:3000/health"]
|
|
129
|
+
interval: 30s
|
|
130
|
+
timeout: 3s
|
|
131
|
+
retries: 3
|
|
132
|
+
|
|
133
|
+
db:
|
|
134
|
+
image: postgres:15-alpine
|
|
135
|
+
environment:
|
|
136
|
+
- POSTGRES_USER=user
|
|
137
|
+
- POSTGRES_PASSWORD=${DB_PASSWORD}
|
|
138
|
+
- POSTGRES_DB=app
|
|
139
|
+
volumes:
|
|
140
|
+
- postgres_data:/var/lib/postgresql/data
|
|
141
|
+
healthcheck:
|
|
142
|
+
test: ["CMD-SHELL", "pg_isready -U user -d app"]
|
|
143
|
+
interval: 5s
|
|
144
|
+
timeout: 5s
|
|
145
|
+
retries: 5
|
|
146
|
+
restart: unless-stopped
|
|
147
|
+
|
|
148
|
+
redis:
|
|
149
|
+
image: redis:7-alpine
|
|
150
|
+
volumes:
|
|
151
|
+
- redis_data:/data
|
|
152
|
+
healthcheck:
|
|
153
|
+
test: ["CMD", "redis-cli", "ping"]
|
|
154
|
+
interval: 5s
|
|
155
|
+
timeout: 3s
|
|
156
|
+
retries: 5
|
|
157
|
+
restart: unless-stopped
|
|
158
|
+
|
|
159
|
+
nginx:
|
|
160
|
+
image: nginx:alpine
|
|
161
|
+
ports:
|
|
162
|
+
- "80:80"
|
|
163
|
+
- "443:443"
|
|
164
|
+
volumes:
|
|
165
|
+
- ./nginx.conf:/etc/nginx/nginx.conf:ro
|
|
166
|
+
- ./ssl:/etc/nginx/ssl:ro
|
|
167
|
+
depends_on:
|
|
168
|
+
- app
|
|
169
|
+
restart: unless-stopped
|
|
170
|
+
|
|
171
|
+
volumes:
|
|
172
|
+
postgres_data:
|
|
173
|
+
redis_data:
|
|
174
|
+
```
|
|
175
|
+
|
|
176
|
+
### Nginx Configuration
|
|
177
|
+
|
|
178
|
+
```nginx
|
|
179
|
+
# nginx.conf
|
|
180
|
+
upstream app {
|
|
181
|
+
server app:3000;
|
|
182
|
+
keepalive 32;
|
|
183
|
+
}
|
|
184
|
+
|
|
185
|
+
server {
|
|
186
|
+
listen 80;
|
|
187
|
+
server_name _;
|
|
188
|
+
|
|
189
|
+
# Redirect to HTTPS in production
|
|
190
|
+
return 301 https://$server_name$request_uri;
|
|
191
|
+
}
|
|
192
|
+
|
|
193
|
+
server {
|
|
194
|
+
listen 443 ssl http2;
|
|
195
|
+
server_name your-domain.com;
|
|
196
|
+
|
|
197
|
+
ssl_certificate /etc/nginx/ssl/cert.pem;
|
|
198
|
+
ssl_certificate_key /etc/nginx/ssl/key.pem;
|
|
199
|
+
ssl_protocols TLSv1.2 TLSv1.3;
|
|
200
|
+
ssl_ciphers HIGH:!aNULL:!MD5;
|
|
201
|
+
|
|
202
|
+
# Security headers
|
|
203
|
+
add_header X-Frame-Options "SAMEORIGIN" always;
|
|
204
|
+
add_header X-Content-Type-Options "nosniff" always;
|
|
205
|
+
add_header X-XSS-Protection "1; mode=block" always;
|
|
206
|
+
|
|
207
|
+
# Gzip compression
|
|
208
|
+
gzip on;
|
|
209
|
+
gzip_vary on;
|
|
210
|
+
gzip_proxied any;
|
|
211
|
+
gzip_comp_level 6;
|
|
212
|
+
gzip_types text/plain text/css text/xml application/json application/javascript application/rss+xml;
|
|
213
|
+
|
|
214
|
+
# API routes
|
|
215
|
+
location ~ ^/[a-zA-Z][a-zA-Z0-9_.]*$ {
|
|
216
|
+
proxy_pass http://app;
|
|
217
|
+
proxy_http_version 1.1;
|
|
218
|
+
proxy_set_header Connection "";
|
|
219
|
+
proxy_set_header Host $host;
|
|
220
|
+
proxy_set_header X-Real-IP $remote_addr;
|
|
221
|
+
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
|
|
222
|
+
proxy_set_header X-Forwarded-Proto $scheme;
|
|
223
|
+
|
|
224
|
+
# Timeouts
|
|
225
|
+
proxy_connect_timeout 60s;
|
|
226
|
+
proxy_send_timeout 60s;
|
|
227
|
+
proxy_read_timeout 60s;
|
|
228
|
+
|
|
229
|
+
# Buffering
|
|
230
|
+
proxy_buffering on;
|
|
231
|
+
proxy_buffer_size 4k;
|
|
232
|
+
proxy_buffers 8 4k;
|
|
233
|
+
}
|
|
234
|
+
|
|
235
|
+
# SSE endpoints (no buffering)
|
|
236
|
+
location /sse {
|
|
237
|
+
proxy_pass http://app;
|
|
238
|
+
proxy_http_version 1.1;
|
|
239
|
+
proxy_set_header Connection "";
|
|
240
|
+
proxy_set_header Host $host;
|
|
241
|
+
proxy_buffering off;
|
|
242
|
+
proxy_cache off;
|
|
243
|
+
proxy_read_timeout 86400s;
|
|
244
|
+
}
|
|
245
|
+
|
|
246
|
+
# Static files (SvelteKit)
|
|
247
|
+
location / {
|
|
248
|
+
proxy_pass http://app;
|
|
249
|
+
proxy_http_version 1.1;
|
|
250
|
+
proxy_set_header Connection "";
|
|
251
|
+
proxy_set_header Host $host;
|
|
252
|
+
proxy_cache_bypass $http_upgrade;
|
|
253
|
+
}
|
|
254
|
+
|
|
255
|
+
# Health check endpoint (exposed)
|
|
256
|
+
location /health {
|
|
257
|
+
proxy_pass http://app;
|
|
258
|
+
access_log off;
|
|
259
|
+
}
|
|
260
|
+
}
|
|
261
|
+
```
|
|
262
|
+
|
|
263
|
+
---
|
|
264
|
+
|
|
265
|
+
## Environment Configuration
|
|
266
|
+
|
|
267
|
+
### Environment Variables
|
|
268
|
+
|
|
269
|
+
```bash
|
|
270
|
+
# .env.production
|
|
271
|
+
NODE_ENV=production
|
|
272
|
+
PORT=3000
|
|
273
|
+
|
|
274
|
+
# Database
|
|
275
|
+
DATABASE_URL=postgresql://user:password@host:5432/dbname
|
|
276
|
+
DATABASE_POOL_SIZE=20
|
|
277
|
+
DATABASE_TIMEOUT=30000
|
|
278
|
+
|
|
279
|
+
# Redis (optional, for distributed cache)
|
|
280
|
+
REDIS_URL=redis://localhost:6379
|
|
281
|
+
REDIS_POOL_SIZE=10
|
|
282
|
+
|
|
283
|
+
# Security
|
|
284
|
+
JWT_SECRET=your-super-secret-jwt-key-min-32-chars
|
|
285
|
+
ENCRYPTION_KEY=your-encryption-key-32-chars-long
|
|
286
|
+
|
|
287
|
+
# Logging
|
|
288
|
+
LOG_LEVEL=info
|
|
289
|
+
LOG_FORMAT=json
|
|
290
|
+
|
|
291
|
+
# Monitoring
|
|
292
|
+
METRICS_ENABLED=true
|
|
293
|
+
METRICS_PORT=9090
|
|
294
|
+
TRACING_ENABLED=true
|
|
295
|
+
|
|
296
|
+
# Rate Limiting
|
|
297
|
+
RATE_LIMIT_ENABLED=true
|
|
298
|
+
RATE_LIMIT_WINDOW_MS=60000
|
|
299
|
+
RATE_LIMIT_MAX_REQUESTS=100
|
|
300
|
+
|
|
301
|
+
# Cache
|
|
302
|
+
CACHE_TTL_MS=300000
|
|
303
|
+
CACHE_MAX_SIZE=10000
|
|
304
|
+
|
|
305
|
+
# Email (if using email plugin)
|
|
306
|
+
SMTP_HOST=smtp.example.com
|
|
307
|
+
SMTP_PORT=587
|
|
308
|
+
SMTP_USER=notifications@example.com
|
|
309
|
+
SMTP_PASS=your-smtp-password
|
|
310
|
+
|
|
311
|
+
# External APIs
|
|
312
|
+
STRIPE_SECRET_KEY=sk_live_...
|
|
313
|
+
STRIPE_WEBHOOK_SECRET=whsec_...
|
|
314
|
+
```
|
|
315
|
+
|
|
316
|
+
### Configuration Validation
|
|
317
|
+
|
|
318
|
+
```typescript
|
|
319
|
+
// src/server/config.ts
|
|
320
|
+
import { z } from "zod";
|
|
321
|
+
|
|
322
|
+
const configSchema = z.object({
|
|
323
|
+
NODE_ENV: z.enum(["development", "production", "test"]),
|
|
324
|
+
PORT: z.string().transform(Number).default("3000"),
|
|
325
|
+
DATABASE_URL: z.string().url(),
|
|
326
|
+
DATABASE_POOL_SIZE: z.string().transform(Number).default("20"),
|
|
327
|
+
JWT_SECRET: z.string().min(32),
|
|
328
|
+
LOG_LEVEL: z.enum(["debug", "info", "warn", "error"]).default("info"),
|
|
329
|
+
RATE_LIMIT_ENABLED: z.string().transform(Boolean).default("true"),
|
|
330
|
+
CACHE_TTL_MS: z.string().transform(Number).default("300000"),
|
|
331
|
+
});
|
|
332
|
+
|
|
333
|
+
export const config = configSchema.parse(process.env);
|
|
334
|
+
```
|
|
335
|
+
|
|
336
|
+
### Production Server Setup
|
|
337
|
+
|
|
338
|
+
```typescript
|
|
339
|
+
// src/server/index.ts
|
|
340
|
+
import { AppServer } from "@donkeylabs/server";
|
|
341
|
+
import { Kysely } from "kysely";
|
|
342
|
+
import { PostgresDialect } from "kysely";
|
|
343
|
+
import { Pool } from "pg";
|
|
344
|
+
import { config } from "./config";
|
|
345
|
+
|
|
346
|
+
const db = new Kysely<Database>({
|
|
347
|
+
dialect: new PostgresDialect({
|
|
348
|
+
pool: new Pool({
|
|
349
|
+
connectionString: config.DATABASE_URL,
|
|
350
|
+
max: config.DATABASE_POOL_SIZE,
|
|
351
|
+
}),
|
|
352
|
+
}),
|
|
353
|
+
});
|
|
354
|
+
|
|
355
|
+
const server = new AppServer({
|
|
356
|
+
port: config.PORT,
|
|
357
|
+
db,
|
|
358
|
+
|
|
359
|
+
// Production logging
|
|
360
|
+
logger: {
|
|
361
|
+
level: config.LOG_LEVEL,
|
|
362
|
+
format: "json", // Structured logging for log aggregation
|
|
363
|
+
redact: ["password", "token", "secret", "authorization"],
|
|
364
|
+
},
|
|
365
|
+
|
|
366
|
+
// Rate limiting
|
|
367
|
+
rateLimiter: config.RATE_LIMIT_ENABLED ? {
|
|
368
|
+
windowMs: 60000,
|
|
369
|
+
maxRequests: 100,
|
|
370
|
+
keyGenerator: (req) => req.headers.get("x-forwarded-for") || "unknown",
|
|
371
|
+
} : undefined,
|
|
372
|
+
|
|
373
|
+
// Production cache
|
|
374
|
+
cache: {
|
|
375
|
+
defaultTtlMs: config.CACHE_TTL_MS,
|
|
376
|
+
maxSize: 10000,
|
|
377
|
+
},
|
|
378
|
+
|
|
379
|
+
// Admin dashboard (disabled in production or protected)
|
|
380
|
+
admin: config.NODE_ENV === "production" ? false : {
|
|
381
|
+
enabled: true,
|
|
382
|
+
auth: {
|
|
383
|
+
username: process.env.ADMIN_USER,
|
|
384
|
+
password: process.env.ADMIN_PASS,
|
|
385
|
+
},
|
|
386
|
+
},
|
|
387
|
+
});
|
|
388
|
+
|
|
389
|
+
// Graceful shutdown
|
|
390
|
+
process.on("SIGTERM", async () => {
|
|
391
|
+
console.log("SIGTERM received, shutting down gracefully");
|
|
392
|
+
await server.shutdown();
|
|
393
|
+
process.exit(0);
|
|
394
|
+
});
|
|
395
|
+
|
|
396
|
+
process.on("SIGINT", async () => {
|
|
397
|
+
console.log("SIGINT received, shutting down gracefully");
|
|
398
|
+
await server.shutdown();
|
|
399
|
+
process.exit(0);
|
|
400
|
+
});
|
|
401
|
+
|
|
402
|
+
await server.start();
|
|
403
|
+
```
|
|
404
|
+
|
|
405
|
+
---
|
|
406
|
+
|
|
407
|
+
## Database Migrations
|
|
408
|
+
|
|
409
|
+
### Migration Strategy
|
|
410
|
+
|
|
411
|
+
**Option 1: Run Migrations on Startup**
|
|
412
|
+
|
|
413
|
+
```typescript
|
|
414
|
+
// src/server/index.ts
|
|
415
|
+
const server = new AppServer({ ... });
|
|
416
|
+
|
|
417
|
+
// Run migrations before starting
|
|
418
|
+
await server.migrate();
|
|
419
|
+
await server.start();
|
|
420
|
+
```
|
|
421
|
+
|
|
422
|
+
**Option 2: Separate Migration Job (Recommended)**
|
|
423
|
+
|
|
424
|
+
```typescript
|
|
425
|
+
// scripts/migrate.ts
|
|
426
|
+
import { Kysely } from "kysely";
|
|
427
|
+
import { runMigrations } from "@donkeylabs/server";
|
|
428
|
+
import { db } from "./src/server/db";
|
|
429
|
+
|
|
430
|
+
async function main() {
|
|
431
|
+
console.log("Running migrations...");
|
|
432
|
+
await runMigrations(db, {
|
|
433
|
+
migrationsDir: "./src/server/plugins/**/migrations",
|
|
434
|
+
dryRun: process.env.DRY_RUN === "true",
|
|
435
|
+
});
|
|
436
|
+
console.log("Migrations complete");
|
|
437
|
+
await db.destroy();
|
|
438
|
+
}
|
|
439
|
+
|
|
440
|
+
main().catch(console.error);
|
|
441
|
+
```
|
|
442
|
+
|
|
443
|
+
```bash
|
|
444
|
+
# Run migrations
|
|
445
|
+
bun scripts/migrate.ts
|
|
446
|
+
|
|
447
|
+
# Dry run (preview)
|
|
448
|
+
DRY_RUN=true bun scripts/migrate.ts
|
|
449
|
+
```
|
|
450
|
+
|
|
451
|
+
### Kubernetes Job
|
|
452
|
+
|
|
453
|
+
```yaml
|
|
454
|
+
# k8s/migration-job.yaml
|
|
455
|
+
apiVersion: batch/v1
|
|
456
|
+
kind: Job
|
|
457
|
+
metadata:
|
|
458
|
+
name: db-migration
|
|
459
|
+
spec:
|
|
460
|
+
template:
|
|
461
|
+
spec:
|
|
462
|
+
containers:
|
|
463
|
+
- name: migrate
|
|
464
|
+
image: your-app:latest
|
|
465
|
+
command: ["bun", "scripts/migrate.ts"]
|
|
466
|
+
env:
|
|
467
|
+
- name: DATABASE_URL
|
|
468
|
+
valueFrom:
|
|
469
|
+
secretKeyRef:
|
|
470
|
+
name: app-secrets
|
|
471
|
+
key: database-url
|
|
472
|
+
restartPolicy: Never
|
|
473
|
+
backoffLimit: 3
|
|
474
|
+
```
|
|
475
|
+
|
|
476
|
+
### Migration Rollback
|
|
477
|
+
|
|
478
|
+
```typescript
|
|
479
|
+
// scripts/rollback.ts
|
|
480
|
+
import { db } from "./src/server/db";
|
|
481
|
+
|
|
482
|
+
async function rollback(steps: number = 1) {
|
|
483
|
+
console.log(`Rolling back ${steps} migration(s)...`);
|
|
484
|
+
await db.migration.rollback({ steps });
|
|
485
|
+
console.log("Rollback complete");
|
|
486
|
+
await db.destroy();
|
|
487
|
+
}
|
|
488
|
+
|
|
489
|
+
rollback(parseInt(process.argv[2] || "1"));
|
|
490
|
+
```
|
|
491
|
+
|
|
492
|
+
---
|
|
493
|
+
|
|
494
|
+
## Health Checks
|
|
495
|
+
|
|
496
|
+
### Basic Health Check
|
|
497
|
+
|
|
498
|
+
```typescript
|
|
499
|
+
// src/server/routes/health/index.ts
|
|
500
|
+
import { createRouter, defineRoute } from "@donkeylabs/server";
|
|
501
|
+
import { z } from "zod";
|
|
502
|
+
|
|
503
|
+
export const healthRouter = createRouter("health");
|
|
504
|
+
|
|
505
|
+
healthRouter.route("check").typed(defineRoute({
|
|
506
|
+
output: z.object({
|
|
507
|
+
status: z.enum(["healthy", "unhealthy"]),
|
|
508
|
+
timestamp: z.string(),
|
|
509
|
+
version: z.string(),
|
|
510
|
+
checks: z.object({
|
|
511
|
+
database: z.enum(["ok", "error"]),
|
|
512
|
+
cache: z.enum(["ok", "error"]),
|
|
513
|
+
}),
|
|
514
|
+
}),
|
|
515
|
+
handle: async (_, ctx) => {
|
|
516
|
+
const checks = {
|
|
517
|
+
database: "ok" as const,
|
|
518
|
+
cache: "ok" as const,
|
|
519
|
+
};
|
|
520
|
+
|
|
521
|
+
// Check database
|
|
522
|
+
try {
|
|
523
|
+
await ctx.db.selectFrom("users").select("id").limit(1).execute();
|
|
524
|
+
} catch {
|
|
525
|
+
checks.database = "error";
|
|
526
|
+
}
|
|
527
|
+
|
|
528
|
+
// Check cache
|
|
529
|
+
try {
|
|
530
|
+
await ctx.core.cache.set("health:check", "ok", 1000);
|
|
531
|
+
await ctx.core.cache.get("health:check");
|
|
532
|
+
} catch {
|
|
533
|
+
checks.cache = "error";
|
|
534
|
+
}
|
|
535
|
+
|
|
536
|
+
const isHealthy = checks.database === "ok" && checks.cache === "ok";
|
|
537
|
+
|
|
538
|
+
return {
|
|
539
|
+
status: isHealthy ? "healthy" : "unhealthy",
|
|
540
|
+
timestamp: new Date().toISOString(),
|
|
541
|
+
version: process.env.npm_package_version || "unknown",
|
|
542
|
+
checks,
|
|
543
|
+
};
|
|
544
|
+
},
|
|
545
|
+
}));
|
|
546
|
+
```
|
|
547
|
+
|
|
548
|
+
### Readiness Probe
|
|
549
|
+
|
|
550
|
+
```typescript
|
|
551
|
+
healthRouter.route("ready").typed(defineRoute({
|
|
552
|
+
output: z.object({ ready: z.boolean() }),
|
|
553
|
+
handle: async (_, ctx) => {
|
|
554
|
+
// Check if server is ready to accept traffic
|
|
555
|
+
const isReady = ctx.core.jobs.isInitialized &&
|
|
556
|
+
ctx.core.events.isConnected;
|
|
557
|
+
|
|
558
|
+
return { ready: isReady };
|
|
559
|
+
},
|
|
560
|
+
}));
|
|
561
|
+
```
|
|
562
|
+
|
|
563
|
+
### Liveness Probe
|
|
564
|
+
|
|
565
|
+
```typescript
|
|
566
|
+
healthRouter.route("live").typed(defineRoute({
|
|
567
|
+
output: z.object({ alive: z.boolean() }),
|
|
568
|
+
handle: async () => {
|
|
569
|
+
// Simple check - if we can respond, we're alive
|
|
570
|
+
return { alive: true };
|
|
571
|
+
},
|
|
572
|
+
}));
|
|
573
|
+
```
|
|
574
|
+
|
|
575
|
+
---
|
|
576
|
+
|
|
577
|
+
## Logging & Monitoring
|
|
578
|
+
|
|
579
|
+
### Structured Logging
|
|
580
|
+
|
|
581
|
+
```typescript
|
|
582
|
+
// Production logging configuration
|
|
583
|
+
const server = new AppServer({
|
|
584
|
+
logger: {
|
|
585
|
+
level: "info",
|
|
586
|
+
format: "json",
|
|
587
|
+
redact: ["password", "token", "secret", "authorization", "cookie"],
|
|
588
|
+
serializers: {
|
|
589
|
+
req: (req) => ({
|
|
590
|
+
method: req.method,
|
|
591
|
+
url: req.url,
|
|
592
|
+
headers: {
|
|
593
|
+
"user-agent": req.headers.get("user-agent"),
|
|
594
|
+
"x-request-id": req.headers.get("x-request-id"),
|
|
595
|
+
},
|
|
596
|
+
}),
|
|
597
|
+
},
|
|
598
|
+
},
|
|
599
|
+
});
|
|
600
|
+
```
|
|
601
|
+
|
|
602
|
+
### Request Logging
|
|
603
|
+
|
|
604
|
+
```typescript
|
|
605
|
+
// Middleware for request logging
|
|
606
|
+
router.middleware.use(createMiddleware(
|
|
607
|
+
async (req, ctx, next) => {
|
|
608
|
+
const start = Date.now();
|
|
609
|
+
const requestId = crypto.randomUUID();
|
|
610
|
+
|
|
611
|
+
ctx.core.logger.info({
|
|
612
|
+
msg: "Request started",
|
|
613
|
+
requestId,
|
|
614
|
+
method: req.method,
|
|
615
|
+
url: req.url,
|
|
616
|
+
});
|
|
617
|
+
|
|
618
|
+
const response = await next();
|
|
619
|
+
|
|
620
|
+
const duration = Date.now() - start;
|
|
621
|
+
ctx.core.logger.info({
|
|
622
|
+
msg: "Request completed",
|
|
623
|
+
requestId,
|
|
624
|
+
status: response.status,
|
|
625
|
+
duration,
|
|
626
|
+
});
|
|
627
|
+
|
|
628
|
+
return response;
|
|
629
|
+
}
|
|
630
|
+
));
|
|
631
|
+
```
|
|
632
|
+
|
|
633
|
+
### Metrics Collection
|
|
634
|
+
|
|
635
|
+
```typescript
|
|
636
|
+
// src/server/metrics.ts
|
|
637
|
+
import { createService } from "@donkeylabs/server";
|
|
638
|
+
|
|
639
|
+
export const metricsService = createService("metrics", async (ctx) => {
|
|
640
|
+
const counters = new Map<string, number>();
|
|
641
|
+
const histograms = new Map<string, number[]>();
|
|
642
|
+
|
|
643
|
+
return {
|
|
644
|
+
increment(name: string, tags?: Record<string, string>) {
|
|
645
|
+
const key = this.formatKey(name, tags);
|
|
646
|
+
counters.set(key, (counters.get(key) || 0) + 1);
|
|
647
|
+
},
|
|
648
|
+
|
|
649
|
+
timing(name: string, value: number, tags?: Record<string, string>) {
|
|
650
|
+
const key = this.formatKey(name, tags);
|
|
651
|
+
const values = histograms.get(key) || [];
|
|
652
|
+
values.push(value);
|
|
653
|
+
histograms.set(key, values);
|
|
654
|
+
},
|
|
655
|
+
|
|
656
|
+
formatKey(name: string, tags?: Record<string, string>): string {
|
|
657
|
+
if (!tags) return name;
|
|
658
|
+
const tagStr = Object.entries(tags)
|
|
659
|
+
.map(([k, v]) => `${k}:${v}`)
|
|
660
|
+
.join(",");
|
|
661
|
+
return `${name}{${tagStr}}`;
|
|
662
|
+
},
|
|
663
|
+
|
|
664
|
+
getReport() {
|
|
665
|
+
return {
|
|
666
|
+
counters: Object.fromEntries(counters),
|
|
667
|
+
histograms: Object.fromEntries(
|
|
668
|
+
Array.from(histograms.entries()).map(([k, v]) => [
|
|
669
|
+
k,
|
|
670
|
+
{
|
|
671
|
+
count: v.length,
|
|
672
|
+
min: Math.min(...v),
|
|
673
|
+
max: Math.max(...v),
|
|
674
|
+
avg: v.reduce((a, b) => a + b, 0) / v.length,
|
|
675
|
+
p95: v.sort((a, b) => a - b)[Math.floor(v.length * 0.95)],
|
|
676
|
+
},
|
|
677
|
+
])
|
|
678
|
+
),
|
|
679
|
+
};
|
|
680
|
+
},
|
|
681
|
+
};
|
|
682
|
+
});
|
|
683
|
+
```
|
|
684
|
+
|
|
685
|
+
### Prometheus Endpoint
|
|
686
|
+
|
|
687
|
+
```typescript
|
|
688
|
+
// src/server/routes/metrics/index.ts
|
|
689
|
+
import { createRouter, defineRoute } from "@donkeylabs/server";
|
|
690
|
+
|
|
691
|
+
export const metricsRouter = createRouter("metrics");
|
|
692
|
+
|
|
693
|
+
metricsRouter.route("prometheus").typed(defineRoute({
|
|
694
|
+
output: z.string(),
|
|
695
|
+
handle: async (_, ctx) => {
|
|
696
|
+
const report = ctx.services.metrics.getReport();
|
|
697
|
+
|
|
698
|
+
let output = "";
|
|
699
|
+
|
|
700
|
+
// Counters
|
|
701
|
+
for (const [key, value] of Object.entries(report.counters)) {
|
|
702
|
+
output += `# TYPE ${key.split("{")[0]} counter\n`;
|
|
703
|
+
output += `${key} ${value}\n`;
|
|
704
|
+
}
|
|
705
|
+
|
|
706
|
+
// Histograms
|
|
707
|
+
for (const [key, stats] of Object.entries(report.histograms)) {
|
|
708
|
+
const baseName = key.split("{")[0];
|
|
709
|
+
output += `# TYPE ${baseName} histogram\n`;
|
|
710
|
+
output += `${key}_count ${stats.count}\n`;
|
|
711
|
+
output += `${key}_sum ${stats.avg * stats.count}\n`;
|
|
712
|
+
}
|
|
713
|
+
|
|
714
|
+
return output;
|
|
715
|
+
},
|
|
716
|
+
}));
|
|
717
|
+
```
|
|
718
|
+
|
|
719
|
+
### Error Tracking (Sentry)
|
|
720
|
+
|
|
721
|
+
```typescript
|
|
722
|
+
// src/server/errors.ts
|
|
723
|
+
import * as Sentry from "@sentry/node";
|
|
724
|
+
|
|
725
|
+
Sentry.init({
|
|
726
|
+
dsn: process.env.SENTRY_DSN,
|
|
727
|
+
environment: process.env.NODE_ENV,
|
|
728
|
+
release: process.env.npm_package_version,
|
|
729
|
+
tracesSampleRate: 0.1,
|
|
730
|
+
});
|
|
731
|
+
|
|
732
|
+
const server = new AppServer({
|
|
733
|
+
onError: (error, ctx) => {
|
|
734
|
+
Sentry.captureException(error, {
|
|
735
|
+
extra: {
|
|
736
|
+
user: ctx?.user,
|
|
737
|
+
requestId: ctx?.requestId,
|
|
738
|
+
},
|
|
739
|
+
});
|
|
740
|
+
},
|
|
741
|
+
});
|
|
742
|
+
```
|
|
743
|
+
|
|
744
|
+
---
|
|
745
|
+
|
|
746
|
+
## Performance Optimization
|
|
747
|
+
|
|
748
|
+
### Database Connection Pooling
|
|
749
|
+
|
|
750
|
+
```typescript
|
|
751
|
+
import { Pool } from "pg";
|
|
752
|
+
|
|
753
|
+
const pool = new Pool({
|
|
754
|
+
connectionString: process.env.DATABASE_URL,
|
|
755
|
+
max: 20, // Maximum connections
|
|
756
|
+
min: 5, // Minimum connections
|
|
757
|
+
acquireTimeoutMillis: 3000, // Timeout for acquiring connection
|
|
758
|
+
idleTimeoutMillis: 30000, // Close idle connections
|
|
759
|
+
connectionTimeoutMillis: 2000,
|
|
760
|
+
});
|
|
761
|
+
```
|
|
762
|
+
|
|
763
|
+
### Response Compression
|
|
764
|
+
|
|
765
|
+
```typescript
|
|
766
|
+
// Enable in nginx (see nginx.conf above)
|
|
767
|
+
// Or use middleware
|
|
768
|
+
import { createMiddleware } from "@donkeylabs/server";
|
|
769
|
+
import { gzip } from "node:zlib";
|
|
770
|
+
import { promisify } from "node:util";
|
|
771
|
+
|
|
772
|
+
const gzipAsync = promisify(gzip);
|
|
773
|
+
|
|
774
|
+
const compressionMiddleware = createMiddleware(
|
|
775
|
+
async (req, ctx, next) => {
|
|
776
|
+
const response = await next();
|
|
777
|
+
|
|
778
|
+
// Only compress JSON responses > 1KB
|
|
779
|
+
const contentType = response.headers.get("content-type");
|
|
780
|
+
if (contentType?.includes("application/json")) {
|
|
781
|
+
const body = await response.text();
|
|
782
|
+
if (body.length > 1024) {
|
|
783
|
+
const compressed = await gzipAsync(Buffer.from(body));
|
|
784
|
+
return new Response(compressed, {
|
|
785
|
+
status: response.status,
|
|
786
|
+
headers: {
|
|
787
|
+
...Object.fromEntries(response.headers),
|
|
788
|
+
"content-encoding": "gzip",
|
|
789
|
+
"content-length": compressed.length.toString(),
|
|
790
|
+
},
|
|
791
|
+
});
|
|
792
|
+
}
|
|
793
|
+
}
|
|
794
|
+
|
|
795
|
+
return response;
|
|
796
|
+
}
|
|
797
|
+
);
|
|
798
|
+
```
|
|
799
|
+
|
|
800
|
+
### Caching Strategy
|
|
801
|
+
|
|
802
|
+
```typescript
|
|
803
|
+
// API response caching
|
|
804
|
+
router.route("users.list").typed(defineRoute({
|
|
805
|
+
input: z.object({ page: z.number().optional() }),
|
|
806
|
+
output: z.array(userSchema),
|
|
807
|
+
handle: async (input, ctx) => {
|
|
808
|
+
const cacheKey = `users:list:page:${input.page || 1}`;
|
|
809
|
+
|
|
810
|
+
return ctx.core.cache.getOrSet(
|
|
811
|
+
cacheKey,
|
|
812
|
+
async () => {
|
|
813
|
+
return ctx.db
|
|
814
|
+
.selectFrom("users")
|
|
815
|
+
.selectAll()
|
|
816
|
+
.limit(50)
|
|
817
|
+
.offset((input.page || 0) * 50)
|
|
818
|
+
.execute();
|
|
819
|
+
},
|
|
820
|
+
60000 // 1 minute
|
|
821
|
+
);
|
|
822
|
+
},
|
|
823
|
+
}));
|
|
824
|
+
```
|
|
825
|
+
|
|
826
|
+
---
|
|
827
|
+
|
|
828
|
+
## Security Hardening
|
|
829
|
+
|
|
830
|
+
### Security Headers
|
|
831
|
+
|
|
832
|
+
```typescript
|
|
833
|
+
// Security headers middleware
|
|
834
|
+
import { createMiddleware } from "@donkeylabs/server";
|
|
835
|
+
|
|
836
|
+
const securityHeadersMiddleware = createMiddleware(
|
|
837
|
+
async (req, ctx, next) => {
|
|
838
|
+
const response = await next();
|
|
839
|
+
|
|
840
|
+
response.headers.set("X-Content-Type-Options", "nosniff");
|
|
841
|
+
response.headers.set("X-Frame-Options", "DENY");
|
|
842
|
+
response.headers.set("X-XSS-Protection", "1; mode=block");
|
|
843
|
+
response.headers.set("Referrer-Policy", "strict-origin-when-cross-origin");
|
|
844
|
+
response.headers.set("Permissions-Policy", "geolocation=(), microphone=(), camera=()");
|
|
845
|
+
|
|
846
|
+
// CSP (adjust for your needs)
|
|
847
|
+
response.headers.set(
|
|
848
|
+
"Content-Security-Policy",
|
|
849
|
+
"default-src 'self'; script-src 'self' 'unsafe-inline'; style-src 'self' 'unsafe-inline';"
|
|
850
|
+
);
|
|
851
|
+
|
|
852
|
+
return response;
|
|
853
|
+
}
|
|
854
|
+
);
|
|
855
|
+
```
|
|
856
|
+
|
|
857
|
+
### Rate Limiting
|
|
858
|
+
|
|
859
|
+
```typescript
|
|
860
|
+
const server = new AppServer({
|
|
861
|
+
rateLimiter: {
|
|
862
|
+
windowMs: 60000, // 1 minute
|
|
863
|
+
maxRequests: 100, // 100 requests per window
|
|
864
|
+
keyGenerator: (req) => {
|
|
865
|
+
// Use forwarded IP if behind proxy
|
|
866
|
+
return req.headers.get("x-forwarded-for")?.split(",")[0] ||
|
|
867
|
+
req.headers.get("x-real-ip") ||
|
|
868
|
+
"unknown";
|
|
869
|
+
},
|
|
870
|
+
skip: (req) => {
|
|
871
|
+
// Skip rate limiting for health checks
|
|
872
|
+
return req.url?.includes("/health");
|
|
873
|
+
},
|
|
874
|
+
},
|
|
875
|
+
});
|
|
876
|
+
```
|
|
877
|
+
|
|
878
|
+
### Input Validation
|
|
879
|
+
|
|
880
|
+
```typescript
|
|
881
|
+
import { z } from "zod";
|
|
882
|
+
|
|
883
|
+
// Strict validation schemas
|
|
884
|
+
const createUserSchema = z.object({
|
|
885
|
+
email: z.string().email().max(255),
|
|
886
|
+
name: z.string().min(1).max(100).regex(/^[\w\s-]+$/),
|
|
887
|
+
password: z.string().min(8).max(100),
|
|
888
|
+
role: z.enum(["user", "admin"]).default("user"),
|
|
889
|
+
});
|
|
890
|
+
|
|
891
|
+
router.route("users.create").typed(defineRoute({
|
|
892
|
+
input: createUserSchema,
|
|
893
|
+
handle: async (input, ctx) => {
|
|
894
|
+
// Input is already validated
|
|
895
|
+
return ctx.plugins.users.create(input);
|
|
896
|
+
},
|
|
897
|
+
}));
|
|
898
|
+
```
|
|
899
|
+
|
|
900
|
+
### SQL Injection Prevention
|
|
901
|
+
|
|
902
|
+
```typescript
|
|
903
|
+
// Always use parameterized queries (Kysely does this automatically)
|
|
904
|
+
// NEVER concatenate user input into SQL
|
|
905
|
+
|
|
906
|
+
// ✅ Good - parameterized
|
|
907
|
+
await ctx.db
|
|
908
|
+
.selectFrom("users")
|
|
909
|
+
.where("email", "=", input.email) // Safe
|
|
910
|
+
.execute();
|
|
911
|
+
|
|
912
|
+
// ❌ Bad - SQL injection risk
|
|
913
|
+
await ctx.db.executeQuery(
|
|
914
|
+
sql`SELECT * FROM users WHERE email = '${input.email}'` // Dangerous!
|
|
915
|
+
);
|
|
916
|
+
```
|
|
917
|
+
|
|
918
|
+
---
|
|
919
|
+
|
|
920
|
+
## CI/CD Pipelines
|
|
921
|
+
|
|
922
|
+
### GitHub Actions
|
|
923
|
+
|
|
924
|
+
```yaml
|
|
925
|
+
# .github/workflows/deploy.yml
|
|
926
|
+
name: Deploy
|
|
927
|
+
|
|
928
|
+
on:
|
|
929
|
+
push:
|
|
930
|
+
branches: [main]
|
|
931
|
+
pull_request:
|
|
932
|
+
branches: [main]
|
|
933
|
+
|
|
934
|
+
jobs:
|
|
935
|
+
test:
|
|
936
|
+
runs-on: ubuntu-latest
|
|
937
|
+
|
|
938
|
+
services:
|
|
939
|
+
postgres:
|
|
940
|
+
image: postgres:15
|
|
941
|
+
env:
|
|
942
|
+
POSTGRES_PASSWORD: postgres
|
|
943
|
+
options: >-
|
|
944
|
+
--health-cmd pg_isready
|
|
945
|
+
--health-interval 10s
|
|
946
|
+
--health-timeout 5s
|
|
947
|
+
--health-retries 5
|
|
948
|
+
ports:
|
|
949
|
+
- 5432:5432
|
|
950
|
+
|
|
951
|
+
steps:
|
|
952
|
+
- uses: actions/checkout@v3
|
|
953
|
+
|
|
954
|
+
- name: Setup Bun
|
|
955
|
+
uses: oven-sh/setup-bun@v1
|
|
956
|
+
with:
|
|
957
|
+
bun-version: latest
|
|
958
|
+
|
|
959
|
+
- name: Install dependencies
|
|
960
|
+
run: bun install
|
|
961
|
+
|
|
962
|
+
- name: Type check
|
|
963
|
+
run: bun --bun tsc --noEmit
|
|
964
|
+
|
|
965
|
+
- name: Run migrations
|
|
966
|
+
env:
|
|
967
|
+
DATABASE_URL: postgres://postgres:postgres@localhost:5432/test
|
|
968
|
+
run: bun scripts/migrate.ts
|
|
969
|
+
|
|
970
|
+
- name: Run tests
|
|
971
|
+
env:
|
|
972
|
+
DATABASE_URL: postgres://postgres:postgres@localhost:5432/test
|
|
973
|
+
run: bun test
|
|
974
|
+
|
|
975
|
+
build:
|
|
976
|
+
needs: test
|
|
977
|
+
runs-on: ubuntu-latest
|
|
978
|
+
if: github.ref == 'refs/heads/main'
|
|
979
|
+
|
|
980
|
+
steps:
|
|
981
|
+
- uses: actions/checkout@v3
|
|
982
|
+
|
|
983
|
+
- name: Set up Docker Buildx
|
|
984
|
+
uses: docker/setup-buildx-action@v2
|
|
985
|
+
|
|
986
|
+
- name: Login to Container Registry
|
|
987
|
+
uses: docker/login-action@v2
|
|
988
|
+
with:
|
|
989
|
+
registry: ghcr.io
|
|
990
|
+
username: ${{ github.actor }}
|
|
991
|
+
password: ${{ secrets.GITHUB_TOKEN }}
|
|
992
|
+
|
|
993
|
+
- name: Build and push
|
|
994
|
+
uses: docker/build-push-action@v4
|
|
995
|
+
with:
|
|
996
|
+
context: .
|
|
997
|
+
push: true
|
|
998
|
+
tags: |
|
|
999
|
+
ghcr.io/${{ github.repository }}:latest
|
|
1000
|
+
ghcr.io/${{ github.repository }}:${{ github.sha }}
|
|
1001
|
+
cache-from: type=gha
|
|
1002
|
+
cache-to: type=gha,mode=max
|
|
1003
|
+
|
|
1004
|
+
deploy:
|
|
1005
|
+
needs: build
|
|
1006
|
+
runs-on: ubuntu-latest
|
|
1007
|
+
if: github.ref == 'refs/heads/main'
|
|
1008
|
+
|
|
1009
|
+
steps:
|
|
1010
|
+
- name: Deploy to production
|
|
1011
|
+
uses: appleboy/ssh-action@master
|
|
1012
|
+
with:
|
|
1013
|
+
host: ${{ secrets.SSH_HOST }}
|
|
1014
|
+
username: ${{ secrets.SSH_USER }}
|
|
1015
|
+
key: ${{ secrets.SSH_KEY }}
|
|
1016
|
+
script: |
|
|
1017
|
+
cd /opt/app
|
|
1018
|
+
docker-compose pull
|
|
1019
|
+
docker-compose up -d
|
|
1020
|
+
docker system prune -f
|
|
1021
|
+
```
|
|
1022
|
+
|
|
1023
|
+
---
|
|
1024
|
+
|
|
1025
|
+
## Scaling Strategies
|
|
1026
|
+
|
|
1027
|
+
### Horizontal Scaling
|
|
1028
|
+
|
|
1029
|
+
```yaml
|
|
1030
|
+
# docker-compose.scale.yml
|
|
1031
|
+
version: '3.8'
|
|
1032
|
+
|
|
1033
|
+
services:
|
|
1034
|
+
app:
|
|
1035
|
+
build: .
|
|
1036
|
+
deploy:
|
|
1037
|
+
replicas: 3
|
|
1038
|
+
environment:
|
|
1039
|
+
- NODE_ENV=production
|
|
1040
|
+
- DATABASE_URL=postgresql://...
|
|
1041
|
+
depends_on:
|
|
1042
|
+
- db
|
|
1043
|
+
- redis
|
|
1044
|
+
|
|
1045
|
+
nginx:
|
|
1046
|
+
image: nginx:alpine
|
|
1047
|
+
ports:
|
|
1048
|
+
- "80:80"
|
|
1049
|
+
volumes:
|
|
1050
|
+
- ./nginx.conf:/etc/nginx/nginx.conf:ro
|
|
1051
|
+
depends_on:
|
|
1052
|
+
- app
|
|
1053
|
+
|
|
1054
|
+
db:
|
|
1055
|
+
image: postgres:15-alpine
|
|
1056
|
+
volumes:
|
|
1057
|
+
- postgres_data:/var/lib/postgresql/data
|
|
1058
|
+
|
|
1059
|
+
redis:
|
|
1060
|
+
image: redis:7-alpine
|
|
1061
|
+
volumes:
|
|
1062
|
+
- redis_data:/data
|
|
1063
|
+
|
|
1064
|
+
volumes:
|
|
1065
|
+
postgres_data:
|
|
1066
|
+
redis_data:
|
|
1067
|
+
```
|
|
1068
|
+
|
|
1069
|
+
### Load Balancing with Nginx
|
|
1070
|
+
|
|
1071
|
+
```nginx
|
|
1072
|
+
upstream app {
|
|
1073
|
+
least_conn; # Least connections load balancing
|
|
1074
|
+
server app_1:3000 max_fails=3 fail_timeout=30s;
|
|
1075
|
+
server app_2:3000 max_fails=3 fail_timeout=30s;
|
|
1076
|
+
server app_3:3000 max_fails=3 fail_timeout=30s;
|
|
1077
|
+
keepalive 32;
|
|
1078
|
+
}
|
|
1079
|
+
```
|
|
1080
|
+
|
|
1081
|
+
### Database Read Replicas
|
|
1082
|
+
|
|
1083
|
+
```typescript
|
|
1084
|
+
// Connection to read replicas
|
|
1085
|
+
const readDb = new Kysely<Database>({
|
|
1086
|
+
dialect: new PostgresDialect({
|
|
1087
|
+
pool: new Pool({
|
|
1088
|
+
connectionString: process.env.DATABASE_READ_URL,
|
|
1089
|
+
max: 20,
|
|
1090
|
+
}),
|
|
1091
|
+
}),
|
|
1092
|
+
});
|
|
1093
|
+
|
|
1094
|
+
// Use read replica for read operations
|
|
1095
|
+
router.route("users.list").typed(defineRoute({
|
|
1096
|
+
handle: async (_, ctx) => {
|
|
1097
|
+
// Read from replica
|
|
1098
|
+
return readDb.selectFrom("users").selectAll().execute();
|
|
1099
|
+
},
|
|
1100
|
+
}));
|
|
1101
|
+
```
|
|
1102
|
+
|
|
1103
|
+
---
|
|
1104
|
+
|
|
1105
|
+
## Troubleshooting Production
|
|
1106
|
+
|
|
1107
|
+
### Debugging Checklist
|
|
1108
|
+
|
|
1109
|
+
```bash
|
|
1110
|
+
# 1. Check container status
|
|
1111
|
+
docker-compose ps
|
|
1112
|
+
docker-compose logs --tail=100 app
|
|
1113
|
+
|
|
1114
|
+
# 2. Check health endpoint
|
|
1115
|
+
curl http://localhost:3000/health
|
|
1116
|
+
|
|
1117
|
+
# 3. Check database connectivity
|
|
1118
|
+
docker-compose exec app bun -e "
|
|
1119
|
+
import { db } from './src/server/db';
|
|
1120
|
+
await db.selectFrom('users').limit(1).execute();
|
|
1121
|
+
console.log('DB OK');
|
|
1122
|
+
"
|
|
1123
|
+
|
|
1124
|
+
# 4. Check resource usage
|
|
1125
|
+
docker stats
|
|
1126
|
+
|
|
1127
|
+
# 5. Check nginx logs
|
|
1128
|
+
docker-compose logs nginx
|
|
1129
|
+
```
|
|
1130
|
+
|
|
1131
|
+
### Common Issues
|
|
1132
|
+
|
|
1133
|
+
**Database connection pool exhausted:**
|
|
1134
|
+
```typescript
|
|
1135
|
+
// Increase pool size
|
|
1136
|
+
const pool = new Pool({
|
|
1137
|
+
max: 50, // Increase from default
|
|
1138
|
+
// ...
|
|
1139
|
+
});
|
|
1140
|
+
```
|
|
1141
|
+
|
|
1142
|
+
**Memory leaks:**
|
|
1143
|
+
```typescript
|
|
1144
|
+
// Add heap dump endpoint for debugging
|
|
1145
|
+
if (process.env.NODE_ENV === "production") {
|
|
1146
|
+
router.route("debug.heap").typed(defineRoute({
|
|
1147
|
+
handle: async () => {
|
|
1148
|
+
const heap = require("v8").getHeapStatistics();
|
|
1149
|
+
return {
|
|
1150
|
+
used: heap.used_heap_size,
|
|
1151
|
+
total: heap.total_heap_size,
|
|
1152
|
+
limit: heap.heap_size_limit,
|
|
1153
|
+
};
|
|
1154
|
+
},
|
|
1155
|
+
}));
|
|
1156
|
+
}
|
|
1157
|
+
```
|
|
1158
|
+
|
|
1159
|
+
**Slow queries:**
|
|
1160
|
+
```typescript
|
|
1161
|
+
// Enable query logging
|
|
1162
|
+
const db = new Kysely<Database>({
|
|
1163
|
+
dialect: new PostgresDialect({ pool }),
|
|
1164
|
+
log: (event) => {
|
|
1165
|
+
if (event.level === "query" && event.queryDurationMillis > 100) {
|
|
1166
|
+
console.warn("Slow query:", event.query.sql, `${event.queryDurationMillis}ms`);
|
|
1167
|
+
}
|
|
1168
|
+
},
|
|
1169
|
+
});
|
|
1170
|
+
```
|
|
1171
|
+
|
|
1172
|
+
### Graceful Degradation
|
|
1173
|
+
|
|
1174
|
+
```typescript
|
|
1175
|
+
// Fallback when services are down
|
|
1176
|
+
router.route("dashboard").typed(defineRoute({
|
|
1177
|
+
handle: async (_, ctx) => {
|
|
1178
|
+
try {
|
|
1179
|
+
const stats = await ctx.core.cache.getOrSet(
|
|
1180
|
+
"dashboard:stats",
|
|
1181
|
+
() => computeStats(ctx),
|
|
1182
|
+
60000
|
|
1183
|
+
);
|
|
1184
|
+
return { stats };
|
|
1185
|
+
} catch (error) {
|
|
1186
|
+
// Return stale data or fallback
|
|
1187
|
+
const staleStats = await ctx.core.cache.get("dashboard:stats");
|
|
1188
|
+
if (staleStats) {
|
|
1189
|
+
ctx.core.logger.warn("Using stale dashboard stats");
|
|
1190
|
+
return { stats: staleStats, stale: true };
|
|
1191
|
+
}
|
|
1192
|
+
|
|
1193
|
+
// Ultimate fallback
|
|
1194
|
+
return { stats: null, error: "Service temporarily unavailable" };
|
|
1195
|
+
}
|
|
1196
|
+
},
|
|
1197
|
+
}));
|
|
1198
|
+
```
|
|
1199
|
+
|
|
1200
|
+
---
|
|
1201
|
+
|
|
1202
|
+
## Production Checklist
|
|
1203
|
+
|
|
1204
|
+
Before deploying to production:
|
|
1205
|
+
|
|
1206
|
+
- [ ] Environment variables configured
|
|
1207
|
+
- [ ] Database migrations tested
|
|
1208
|
+
- [ ] Health checks implemented
|
|
1209
|
+
- [ ] Logging structured (JSON)
|
|
1210
|
+
- [ ] Error tracking configured (Sentry)
|
|
1211
|
+
- [ ] Metrics collection enabled
|
|
1212
|
+
- [ ] Rate limiting enabled
|
|
1213
|
+
- [ ] Security headers configured
|
|
1214
|
+
- [ ] SSL/TLS certificates
|
|
1215
|
+
- [ ] Docker multi-stage build
|
|
1216
|
+
- [ ] Graceful shutdown handlers
|
|
1217
|
+
- [ ] Backup strategy
|
|
1218
|
+
- [ ] Monitoring dashboards
|
|
1219
|
+
- [ ] Runbook created
|
|
1220
|
+
- [ ] Load testing completed
|
|
1221
|
+
|
|
1222
|
+
---
|
|
1223
|
+
|
|
1224
|
+
## Additional Resources
|
|
1225
|
+
|
|
1226
|
+
- [Docker Best Practices](https://docs.docker.com/develop/dev-best-practices/)
|
|
1227
|
+
- [PostgreSQL Tuning](https://wiki.postgresql.org/wiki/Tuning_Your_PostgreSQL_Server)
|
|
1228
|
+
- [Nginx Load Balancing](https://nginx.org/en/docs/http/load_balancing.html)
|
|
1229
|
+
- [Kubernetes Deployment](https://kubernetes.io/docs/concepts/workloads/controllers/deployment/)
|