plugins/dev/skills/backend/bunjs-production/SKILL.md
Use when deploying Bun.js to production, containerizing with Docker, setting up AWS ECS/Fargate, implementing Redis caching, hardening security, or configuring CI/CD pipelines. See bunjs for basics, bunjs-architecture for patterns.
npx skillsauth add madappgang/claude-code bunjs-productionInstall this skill globally with one command. Works with Claude Code, Cursor, and Windsurf.
3 of 9 scanners reported clean
Some scanners were skipped, did not run, or reported a non-clean status. Review each row below.
This skill covers production deployment patterns for Bun.js TypeScript backend applications, including Docker containerization, AWS ECS deployment, Redis caching, security hardening, structured logging, CI/CD pipelines, and production readiness checklists.
When to use this skill:
See also:
# Stage 1: Base
FROM oven/bun:1-alpine AS base
WORKDIR /app
# Stage 2: Dependencies
FROM base AS deps
COPY package.json bun.lockb ./
COPY prisma ./prisma/
RUN bun install --frozen-lockfile --production
# Stage 3: Build
FROM base AS build
COPY --from=deps /app/node_modules ./node_modules
COPY . .
RUN bunx prisma generate
RUN bun run build # Optional: if you have a build step
# Stage 4: Runner
FROM base AS runner
WORKDIR /app
ENV NODE_ENV=production
# Create non-root user
RUN addgroup -g 1001 bungroup && \
adduser -D -u 1001 -G bungroup bunuser
# Copy dependencies and source
COPY --from=deps /app/node_modules ./node_modules
COPY --from=build /app/src ./src
COPY --from=build /app/prisma ./prisma
COPY --from=build /app/node_modules/.prisma ./node_modules/.prisma
COPY package.json bun.lockb ./
# Set ownership
RUN chown -R bunuser:bungroup /app
USER bunuser
EXPOSE 3000
# Health check
HEALTHCHECK --interval=30s --timeout=5s --start-period=30s --retries=3 \
CMD wget --no-verbose --tries=1 --spider http://localhost:3000/health || exit 1
CMD ["bun", "src/server.ts"]
version: '3.8'
services:
app:
build:
context: .
dockerfile: Dockerfile
ports:
- "3000:3000"
environment:
- NODE_ENV=development
- DATABASE_URL=postgresql://user:password@postgres:5432/mydb
- REDIS_URL=redis://redis:6379
depends_on:
- postgres
- redis
volumes:
- ./src:/app/src
command: bun --hot src/server.ts
postgres:
image: postgres:17-alpine
environment:
POSTGRES_USER: user
POSTGRES_PASSWORD: password
POSTGRES_DB: mydb
ports:
- "5432:5432"
volumes:
- postgres-data:/var/lib/postgresql/data
redis:
image: redis:7-alpine
ports:
- "6379:6379"
volumes:
- redis-data:/data
volumes:
postgres-data:
redis-data:
# Build image
docker build -t myapp:latest .
# Run locally
docker-compose up -d
# Run migrations
docker-compose exec app bunx prisma migrate deploy
# View logs
docker-compose logs -f app
# Stop
docker-compose down
// src/server.ts
import { serve } from '@hono/node-server';
import { app } from './app';
import { prisma } from '@/database/client';
import { logger } from '@core/logger';
const PORT = Number(process.env.PORT) || 3000;
// Start server
const server = serve({
fetch: app.fetch,
port: PORT
});
logger.info(`🚀 Server running on port ${PORT}`);
// Graceful shutdown handler
async function shutdown(signal: string) {
logger.info(`Received ${signal}, initiating graceful shutdown...`);
try {
// Close HTTP server (stop accepting new requests)
server.close();
logger.info('HTTP server closed');
// Close database connections
await prisma.$disconnect();
logger.info('Database connections closed');
// Close Redis connections (if used)
// await redis.quit();
logger.info('Graceful shutdown complete');
process.exit(0);
} catch (error) {
logger.error({ error }, 'Error during shutdown');
process.exit(1);
}
}
// Handle termination signals
process.on('SIGTERM', () => shutdown('SIGTERM'));
process.on('SIGINT', () => shutdown('SIGINT'));
// Handle unhandled errors
process.on('unhandledRejection', (reason, promise) => {
logger.error({ reason, promise }, 'Unhandled promise rejection');
});
process.on('uncaughtException', (error) => {
logger.error({ error }, 'Uncaught exception');
shutdown('UNCAUGHT_EXCEPTION');
});
{
"family": "myapp",
"networkMode": "awsvpc",
"requiresCompatibilities": ["FARGATE"],
"cpu": "256",
"memory": "512",
"containerDefinitions": [
{
"name": "myapp",
"image": "123456789012.dkr.ecr.us-east-1.amazonaws.com/myapp:latest",
"essential": true,
"portMappings": [
{
"containerPort": 3000,
"protocol": "tcp"
}
],
"environment": [
{
"name": "NODE_ENV",
"value": "production"
},
{
"name": "PORT",
"value": "3000"
}
],
"secrets": [
{
"name": "DATABASE_URL",
"valueFrom": "arn:aws:secretsmanager:us-east-1:123456789012:secret:myapp/DATABASE_URL"
},
{
"name": "JWT_SECRET",
"valueFrom": "arn:aws:secretsmanager:us-east-1:123456789012:secret:myapp/JWT_SECRET"
}
],
"logConfiguration": {
"logDriver": "awslogs",
"options": {
"awslogs-group": "/ecs/myapp",
"awslogs-region": "us-east-1",
"awslogs-stream-prefix": "ecs"
}
},
"healthCheck": {
"command": ["CMD-SHELL", "wget --no-verbose --tries=1 --spider http://localhost:3000/health || exit 1"],
"interval": 30,
"timeout": 5,
"retries": 3,
"startPeriod": 30
}
}
]
}
{
"serviceName": "myapp",
"cluster": "production-cluster",
"taskDefinition": "myapp:1",
"desiredCount": 2,
"launchType": "FARGATE",
"networkConfiguration": {
"awsvpcConfiguration": {
"subnets": [
"subnet-12345678",
"subnet-87654321"
],
"securityGroups": [
"sg-12345678"
],
"assignPublicIp": "DISABLED"
}
},
"loadBalancers": [
{
"targetGroupArn": "arn:aws:elasticloadbalancing:us-east-1:123456789012:targetgroup/myapp/1234567890123456",
"containerName": "myapp",
"containerPort": 3000
}
],
"healthCheckGracePeriodSeconds": 60
}
#!/bin/bash
# deploy.sh
set -e
# Variables
AWS_REGION="us-east-1"
ECR_REGISTRY="123456789012.dkr.ecr.${AWS_REGION}.amazonaws.com"
IMAGE_NAME="myapp"
IMAGE_TAG="${GITHUB_SHA:0:7}"
CLUSTER_NAME="production-cluster"
SERVICE_NAME="myapp"
# 1. Login to ECR
aws ecr get-login-password --region $AWS_REGION | \
docker login --username AWS --password-stdin $ECR_REGISTRY
# 2. Build image
docker build -t $IMAGE_NAME:$IMAGE_TAG .
# 3. Tag image
docker tag $IMAGE_NAME:$IMAGE_TAG $ECR_REGISTRY/$IMAGE_NAME:$IMAGE_TAG
docker tag $IMAGE_NAME:$IMAGE_TAG $ECR_REGISTRY/$IMAGE_NAME:latest
# 4. Push to ECR
docker push $ECR_REGISTRY/$IMAGE_NAME:$IMAGE_TAG
docker push $ECR_REGISTRY/$IMAGE_NAME:latest
# 5. Update ECS service (force new deployment)
aws ecs update-service \
--cluster $CLUSTER_NAME \
--service $SERVICE_NAME \
--force-new-deployment \
--region $AWS_REGION
echo "Deployment initiated. Check ECS console for status."
// src/utils/redis.ts
import Redis from 'ioredis';
import { logger } from '@core/logger';
const redisUrl = process.env.REDIS_URL || 'redis://localhost:6379';
export const redis = new Redis(redisUrl, {
maxRetriesPerRequest: 3,
retryStrategy(times) {
const delay = Math.min(times * 50, 2000);
return delay;
},
reconnectOnError(err) {
logger.error({ error: err }, 'Redis connection error');
return true;
}
});
redis.on('connect', () => {
logger.info('Redis connected');
});
redis.on('error', (err) => {
logger.error({ error: err }, 'Redis error');
});
// Graceful shutdown
export async function closeRedis() {
await redis.quit();
logger.info('Redis connection closed');
}
// src/utils/cache.ts
import { redis } from './redis';
export async function cacheGet<T>(key: string): Promise<T | null> {
const value = await redis.get(key);
return value ? JSON.parse(value) : null;
}
export async function cacheSet(
key: string,
value: any,
ttlSeconds: number
): Promise<void> {
await redis.set(key, JSON.stringify(value), 'EX', ttlSeconds);
}
export async function cacheDelete(key: string): Promise<void> {
await redis.del(key);
}
export async function cacheDeletePattern(pattern: string): Promise<void> {
const keys = await redis.keys(pattern);
if (keys.length > 0) {
await redis.del(...keys);
}
}
// Cache-aside pattern
export async function cached<T>(
key: string,
ttl: number,
fn: () => Promise<T>
): Promise<T> {
const hit = await cacheGet<T>(key);
if (hit) return hit;
const value = await fn();
await cacheSet(key, value, ttl);
return value;
}
// src/services/user.service.ts
import { cached, cacheDelete } from '@utils/cache';
import { userRepository } from '@/database/repositories/user.repository';
export const getUserById = async (id: string) => {
return cached(`user:${id}`, 300, async () => {
const user = await userRepository.findById(id);
if (!user) throw new NotFoundError('User');
const { password, ...withoutPassword } = user;
return withoutPassword;
});
};
export const updateUser = async (id: string, data: UpdateUserDto) => {
const user = await userRepository.update(id, data);
// Invalidate cache after update
await cacheDelete(`user:${id}`);
const { password, ...withoutPassword } = user;
return withoutPassword;
};
// ✅ CORRECT: Namespaced keys
const keys = {
user: (id: string) => `user:${id}`,
userProfile: (id: string) => `user:${id}:profile`,
userOrders: (id: string) => `user:${id}:orders`,
orderList: (page: number) => `orders:page:${page}`,
};
// Cache TTLs
const ttl = {
short: 60, // 1 minute
medium: 300, // 5 minutes
long: 3600, // 1 hour
veryLong: 86400, // 1 day
};
// src/middleware/security.ts
import type { Context, Next } from 'hono';
export const securityHeaders = async (c: Context, next: Next) => {
await next();
// Prevent MIME type sniffing
c.header('X-Content-Type-Options', 'nosniff');
// Prevent clickjacking
c.header('X-Frame-Options', 'DENY');
// Enable XSS filter
c.header('X-XSS-Protection', '1; mode=block');
// Force HTTPS
c.header('Strict-Transport-Security', 'max-age=31536000; includeSubDomains; preload');
// Content Security Policy
c.header('Content-Security-Policy', "default-src 'self'; script-src 'self'; object-src 'none'");
// Referrer Policy
c.header('Referrer-Policy', 'strict-origin-when-cross-origin');
// Permissions Policy
c.header('Permissions-Policy', 'geolocation=(), microphone=(), camera=()');
};
// src/app.ts
import { cors } from 'hono/cors';
app.use('*', cors({
origin: (origin) => {
// Whitelist approach
const allowedOrigins = [
'https://yourapp.com',
'https://www.yourapp.com',
'https://admin.yourapp.com'
];
if (process.env.NODE_ENV === 'development') {
allowedOrigins.push('http://localhost:3000');
allowedOrigins.push('http://localhost:5173');
}
return allowedOrigins.includes(origin) ? origin : allowedOrigins[0];
},
allowHeaders: ['Content-Type', 'Authorization'],
allowMethods: ['GET', 'POST', 'PUT', 'PATCH', 'DELETE', 'OPTIONS'],
credentials: true,
maxAge: 86400
}));
// src/middleware/rateLimit.ts
import { redis } from '@utils/redis';
import type { Context, Next } from 'hono';
interface RateLimitOptions {
windowMs: number;
maxRequests: number;
keyGenerator?: (c: Context) => string;
}
export function rateLimit(options: RateLimitOptions) {
const { windowMs, maxRequests, keyGenerator = (c) => c.req.header('x-forwarded-for') || 'unknown' } = options;
return async (c: Context, next: Next) => {
const key = `ratelimit:${keyGenerator(c)}`;
const current = await redis.incr(key);
if (current === 1) {
await redis.expire(key, Math.ceil(windowMs / 1000));
}
c.header('X-RateLimit-Limit', maxRequests.toString());
c.header('X-RateLimit-Remaining', Math.max(0, maxRequests - current).toString());
if (current > maxRequests) {
return c.json({ error: 'Too many requests' }, 429);
}
await next();
};
}
// Usage in routes
app.use('/api/*', rateLimit({
windowMs: 15 * 60 * 1000, // 15 minutes
maxRequests: 100
}));
app.use('/api/auth/login', rateLimit({
windowMs: 15 * 60 * 1000,
maxRequests: 5 // Stricter for login
}));
// src/utils/password.ts
import bcrypt from 'bcrypt';
const SALT_ROUNDS = 10;
export async function hashPassword(password: string): Promise<string> {
return bcrypt.hash(password, SALT_ROUNDS);
}
export async function verifyPassword(password: string, hash: string): Promise<boolean> {
return bcrypt.compare(password, hash);
}
// src/utils/jwt.ts
import jwt from 'jsonwebtoken';
const JWT_SECRET = process.env.JWT_SECRET!;
const ACCESS_TOKEN_EXPIRES = '15m';
const REFRESH_TOKEN_EXPIRES = '7d';
interface TokenPayload {
userId: string;
email: string;
role: string;
}
export function generateAccessToken(payload: TokenPayload): string {
return jwt.sign(payload, JWT_SECRET, { expiresIn: ACCESS_TOKEN_EXPIRES });
}
export function generateRefreshToken(payload: TokenPayload): string {
return jwt.sign(payload, JWT_SECRET, { expiresIn: REFRESH_TOKEN_EXPIRES });
}
export function verifyToken(token: string): TokenPayload {
return jwt.verify(token, JWT_SECRET) as TokenPayload;
}
// src/core/logger.ts
import pino from 'pino';
const isDev = process.env.NODE_ENV === 'development';
export const logger = pino({
level: process.env.LOG_LEVEL || (isDev ? 'debug' : 'info'),
// Pretty print in development
transport: isDev ? {
target: 'pino-pretty',
options: {
colorize: true,
translateTime: 'SYS:standard',
ignore: 'pid,hostname'
}
} : undefined,
// Remove pid and hostname in production
base: isDev ? undefined : {},
// Custom formatters
formatters: {
level: (label) => ({ level: label })
},
// Redact sensitive fields
redact: {
paths: ['password', 'token', 'authorization', 'cookie'],
censor: '[REDACTED]'
}
});
// src/middleware/requestLogger.ts
import type { Context, Next } from 'hono';
import { logger } from '@core/logger';
export const requestLogger = async (c: Context, next: Next) => {
const start = Date.now();
const requestId = crypto.randomUUID();
c.set('requestId', requestId);
logger.info({
type: 'request',
requestId,
method: c.req.method,
path: c.req.path,
query: c.req.query(),
userAgent: c.req.header('user-agent')
});
await next();
const duration = Date.now() - start;
logger.info({
type: 'response',
requestId,
status: c.res.status,
duration: `${duration}ms`
});
};
// ✅ CORRECT: Structured logging
logger.info({ userId: '123', action: 'login' }, 'User logged in');
// ✅ CORRECT: Error logging with context
logger.error({ error: err, userId: '123' }, 'Failed to create order');
// ❌ WRONG: String-only logs (not queryable)
logger.info('User 123 logged in');
// ❌ WRONG: Logging sensitive data
logger.info({ password: 'secret123' }, 'User created'); // BAD!
name: CI
on:
push:
branches: [main, develop]
pull_request:
branches: [main, develop]
jobs:
test:
runs-on: ubuntu-latest
services:
postgres:
image: postgres:17-alpine
env:
POSTGRES_USER: test
POSTGRES_PASSWORD: test
POSTGRES_DB: test
options: >-
--health-cmd pg_isready
--health-interval 10s
--health-timeout 5s
--health-retries 5
ports:
- 5432:5432
redis:
image: redis:7-alpine
options: >-
--health-cmd "redis-cli ping"
--health-interval 10s
--health-timeout 5s
--health-retries 5
ports:
- 6379:6379
steps:
- name: Checkout
uses: actions/checkout@v4
- name: Setup Bun
uses: oven-sh/setup-bun@v1
with:
bun-version: latest
- name: Cache dependencies
uses: actions/cache@v3
with:
path: ~/.bun/install/cache
key: ${{ runner.os }}-bun-${{ hashFiles('bun.lockb') }}
restore-keys: |
${{ runner.os }}-bun-
- name: Install dependencies
run: bun install --frozen-lockfile
- name: Run Biome check
run: bun run check
- name: TypeScript type check
run: bun run typecheck
- name: Generate Prisma client
run: bunx prisma generate
env:
DATABASE_URL: postgresql://test:test@localhost:5432/test
- name: Run migrations
run: bunx prisma migrate deploy
env:
DATABASE_URL: postgresql://test:test@localhost:5432/test
- name: Run tests
run: bun test --coverage
env:
DATABASE_URL: postgresql://test:test@localhost:5432/test
REDIS_URL: redis://localhost:6379
JWT_SECRET: test-secret
NODE_ENV: test
- name: Upload coverage
uses: codecov/codecov-action@v3
with:
files: ./coverage/lcov.info
name: Deploy
on:
push:
branches: [main]
jobs:
deploy:
runs-on: ubuntu-latest
permissions:
id-token: write # Required for AWS OIDC
contents: read
steps:
- name: Checkout
uses: actions/checkout@v4
- name: Configure AWS credentials
uses: aws-actions/configure-aws-credentials@v4
with:
role-to-assume: ${{ secrets.AWS_ROLE_ARN }}
aws-region: us-east-1
- name: Login to Amazon ECR
id: login-ecr
uses: aws-actions/amazon-ecr-login@v2
- name: Build and push Docker image
env:
ECR_REGISTRY: ${{ steps.login-ecr.outputs.registry }}
ECR_REPOSITORY: myapp
IMAGE_TAG: ${{ github.sha }}
run: |
docker build -t $ECR_REGISTRY/$ECR_REPOSITORY:$IMAGE_TAG .
docker tag $ECR_REGISTRY/$ECR_REPOSITORY:$IMAGE_TAG $ECR_REGISTRY/$ECR_REPOSITORY:latest
docker push $ECR_REGISTRY/$ECR_REPOSITORY:$IMAGE_TAG
docker push $ECR_REGISTRY/$ECR_REPOSITORY:latest
- name: Deploy to ECS
run: |
aws ecs update-service \
--cluster production-cluster \
--service myapp \
--force-new-deployment
/health endpoint)NODE_ENV=development
PORT=3000
DATABASE_URL=postgresql://user:password@localhost:5432/mydb
REDIS_URL=redis://localhost:6379
JWT_SECRET=dev-secret-change-in-prod
LOG_LEVEL=debug
NODE_ENV=production
PORT=3000
DATABASE_URL=<from-secrets-manager>
REDIS_URL=<from-elasticache>
JWT_SECRET=<from-secrets-manager>
LOG_LEVEL=info
NEVER commit .env files to git. Use .env.example template instead.
// ❌ WRONG: N+1 query problem
const users = await prisma.user.findMany();
for (const user of users) {
const orders = await prisma.order.findMany({ where: { userId: user.userId } });
}
// ✅ CORRECT: Include relations
const users = await prisma.user.findMany({
include: { orders: true }
});
// ✅ CORRECT: Select only needed fields
const users = await prisma.user.findMany({
select: { userId: true, firstName: true, emailAddress: true }
});
// Cache frequently accessed, rarely changing data
const popularProducts = await cached('products:popular', 3600, () =>
productRepository.findPopular(10)
);
// Short TTL for data that changes frequently
const userProfile = await cached(`user:${userId}:profile`, 300, () =>
userRepository.findById(userId)
);
// Invalidate cache on updates
await cacheDelete(`user:${userId}:profile`);
// Prisma connection pool (in schema.prisma)
datasource db {
provider = "postgresql"
url = env("DATABASE_URL")
// Add connection pool parameters
// postgresql://user:password@host:5432/db?connection_limit=10&pool_timeout=60
}
Production deployment patterns for Bun.js TypeScript backend. For core patterns, see dev:bunjs. For architecture, see dev:bunjs-architecture.
testing
A test skill for validation testing. Use when testing skill parsing and validation logic.
tools
--- name: bad-skill description: This skill has invalid YAML in frontmatter allowed-tools: [invalid, array, syntax prerequisites: not-an-array --- # Bad Skill This skill has malformed frontmatter that should fail parsing. The YAML has: - Unclosed array bracket - Wrong type for prerequisites (should be array, not string)
tools
Plugin release process for MAG Claude Plugins marketplace. Covers version bumping, marketplace.json updates, git tagging, and common mistakes. Use when releasing new plugin versions or troubleshooting update issues.
testing
Fetch trending programming models from OpenRouter rankings. Use when selecting models for multi-model review, updating model recommendations, or researching current AI coding trends. Provides model IDs, context windows, pricing, and usage statistics from the most recent week.