Tri-target deployment: Vercel (Node.js), Vercel (Bun experimental), and Koyeb (Bun container).
Strategy: Same codebase, different configurations. See stack/deployment.md for decision rationale.
Quick Start
# Vercel (Node.js) — Stable
bun run build
# Deploy via Vercel dashboard or CLI
# Vercel (Bun) — Experimental
bun run build
# Add "bunVersion": "1.x" to vercel.json
# Koyeb (Bun) — Container
DEPLOY_TARGET=bun bun run build
# Deploy via Dockerfile
Adapter Configuration
Dynamic Adapter Selection
// svelte.config.js
import adapterVercel from '@sveltejs/adapter-vercel';
import adapterBun from 'svelte-adapter-bun';
const target = process.env.DEPLOY_TARGET;
const adapter = target === 'bun'
? adapterBun({ precompress: true })
: adapterVercel();
export default {
kit: {
adapter,
},
};
Adapter Dependencies
Dev dependencies:
"@sveltejs/adapter-vercel": "^5.x",
"svelte-adapter-bun": "^0.5.x"
See development-environment.md for installation workflow.
Package.json Scripts
{
"scripts": {
"build": "vite build",
"build:vercel": "vite build",
"build:bun": "DEPLOY_TARGET=bun vite build",
"preview": "vite preview",
"preview:bun": "DEPLOY_TARGET=bun bun run build && bun ./build/index.js"
}
}
Vercel Deployment (Node.js)
Setup
- Connect repository to Vercel dashboard
- Framework preset: SvelteKit (auto-detected)
- Build command:
bun run build(default) - Output directory:
.vercel/output(auto-configured)
vercel.json
{
"framework": "sveltekit",
"regions": ["iad1"],
"crons": [
{
"path": "/api/cron/session-cleanup",
"schedule": "0 3 * * *"
}
]
}
Vercel cron calls are authenticated with CRON_SECRET. See Scheduled Jobs below.
Environment Variables
Set in Vercel dashboard → Settings → Environment Variables:
| Variable | Required | Notes |
|---|---|---|
DATABASE_URL |
Yes | Neon connection string |
RATE_LIMIT_SECRET |
Yes | 32+ char secret |
CRON_SECRET |
Yes | Bearer token for cron endpoints |
R2_ENDPOINT |
If using R2 | Cloudflare R2 endpoint |
R2_ACCESS_KEY_ID |
If using R2 | R2 credentials |
R2_SECRET_ACCESS_KEY |
If using R2 | R2 credentials |
Adapter Options
// svelte.config.js
import adapter from '@sveltejs/adapter-vercel';
export default {
kit: {
adapter: adapter({
// Deploy to edge (faster, limited Node.js APIs)
runtime: 'edge',
// Or use Node.js runtime (full API support)
runtime: 'nodejs22.x',
// Split routes into separate functions
split: true,
// Memory limit (MB)
memory: 1024,
// Timeout (seconds)
maxDuration: 10,
}),
},
};
Preview Deploys
Vercel creates preview deployments for every PR:
https://velociraptor-<hash>-<team>.vercel.app
Configure preview environment variables separately in Vercel dashboard.
Vercel Deployment (Bun) — Experimental
Warning: Vercel's Bun runtime officially supports Next.js, Express, Hono, and Nitro. SvelteKit is not officially listed but may work. Test thoroughly before production use.
Why Try This?
- 28% lower latency compared to Node.js (Vercel benchmarks)
- Same Vercel DX (preview deploys, dashboard, analytics)
- One config line to enable
- Easy to revert if issues arise
Setup
Same as Node.js deployment, plus one addition to vercel.json:
{
"framework": "sveltekit",
"bunVersion": "1.x",
"regions": ["iad1"],
"crons": [
{
"path": "/api/cron/session-cleanup",
"schedule": "0 3 * * *"
}
]
}
The key addition is "bunVersion": "1.x" which enables Bun runtime for all functions.
What Changes
| Aspect | Node.js | Bun |
|---|---|---|
| Runtime | Node.js 20 | Bun 1.x |
| Cold starts | ~200-500ms | ~150-350ms (estimated) |
| Adapter | adapter-vercel |
adapter-vercel (same) |
| Build | bun run build |
bun run build (same) |
| Config | Default | Add bunVersion |
Testing Strategy
- Deploy to preview first — Don't go straight to production
- Test all routes — Server functions, API endpoints, load functions
- Check edge cases — File uploads, WebSockets, streaming
- Monitor errors — Watch Vercel logs for Bun-specific issues
- Compare performance — Measure latency vs Node.js deployment
Reverting to Node.js
If issues arise, simply remove the bunVersion line:
{
"framework": "sveltekit",
"regions": ["iad1"]
}
Redeploy and you're back on Node.js.
Known Limitations
| Feature | Status |
|---|---|
| SvelteKit SSR | Should work (uses adapter-vercel) |
| API routes | Should work |
| Edge runtime | Unknown (test needed) |
| Streaming | Should work |
| WebSockets | Unknown (test needed) |
| Native Node modules | May have issues |
Reporting Issues
If SvelteKit + Bun works well, consider:
- Sharing results in Vercel Community
- Opening issue to request official SvelteKit support
Koyeb Deployment (Bun)
Dockerfile
# syntax=docker/dockerfile:1
# ============================================
# Base image
# ============================================
FROM oven/bun:1-alpine AS base
WORKDIR /app
# ============================================
# Install dependencies
# ============================================
FROM base AS deps
# Copy package files
COPY package.json bun.lockb ./
# Install all dependencies (including devDependencies for build)
RUN bun install --frozen-lockfile
# ============================================
# Build stage
# ============================================
FROM base AS builder
# Copy dependencies
COPY --from=deps /app/node_modules ./node_modules
COPY . .
# Set build-time environment
ENV NODE_ENV=production
ENV DEPLOY_TARGET=bun
# Build the application
RUN bun run build
# ============================================
# Production dependencies only
# ============================================
FROM base AS prod-deps
COPY package.json bun.lockb ./
RUN bun install --frozen-lockfile --production
# ============================================
# Production image
# ============================================
FROM base AS runner
# Security: run as non-root user
USER bun
# Copy production dependencies
COPY --from=prod-deps --chown=bun:bun /app/node_modules ./node_modules
# Copy built application
COPY --from=builder --chown=bun:bun /app/build ./build
COPY --from=builder --chown=bun:bun /app/package.json ./
# Environment variables for reverse proxy
ENV NODE_ENV=production
ENV PROTOCOL_HEADER=x-forwarded-proto
ENV HOST_HEADER=x-forwarded-host
ENV PORT=3000
EXPOSE 3000
# Start the application
CMD ["bun", "run", "./build/index.js"]
.dockerignore
node_modules
.svelte-kit
build
.env*
.git
.gitignore
*.md
Dockerfile
.dockerignore
Koyeb Setup
-
Sign up at koyeb.com (no credit card required)
-
Create app → Select GitHub repository
-
Builder: Dockerfile
-
Instance type: Nano (free tier)
- 512 MB RAM
- 0.1 vCPU
- Frankfurt or Washington D.C.
-
Environment variables: Add in Koyeb dashboard
-
Port: 3000
Environment Variables
Set in Koyeb dashboard → Service → Settings → Environment:
| Variable | Required | Notes |
|---|---|---|
DATABASE_URL |
Yes | Neon connection string |
RATE_LIMIT_SECRET |
Yes | 32+ char secret |
ORIGIN |
Yes | https://your-app.koyeb.app |
R2_ENDPOINT |
If using R2 | Cloudflare R2 endpoint |
R2_ACCESS_KEY_ID |
If using R2 | R2 credentials |
R2_SECRET_ACCESS_KEY |
If using R2 | R2 credentials |
Adapter Options
// svelte.config.js
import adapter from 'svelte-adapter-bun';
export default {
kit: {
adapter: adapter({
// Enable gzip/brotli compression
precompress: true,
// Development mode logging
development: false,
// Dynamic imports for routes
dynamic_origin: true,
// WebSocket support (experimental)
websocket: false,
}),
},
};
Local Testing
Test the Docker build locally:
# Build image
podman build -t velociraptor .
# Run container
podman run -p 3000:3000 \
-e DATABASE_URL="postgresql://..." \
-e ORIGIN="http://localhost:3000" \
velociraptor
# Open http://localhost:3000
Scheduled Jobs
Recurring background work runs differently per platform. The same job registry serves both.
How it works
| Platform | Trigger | Entry point |
|---|---|---|
| Vercel | HTTP cron via vercel.json |
GET /api/cron/[job] |
| Container | setInterval in hooks.server.ts |
Direct function call |
Platform detection — src/lib/server/platform/index.ts reads $env/dynamic/private. If VERCEL is set, platform.persistent = false and the scheduler is a no-op. If CONTAINER=1, platform.persistent = true and the scheduler starts.
Job registry — src/lib/server/jobs/index.ts maps slugs to execute() functions. Both trigger paths call the same registry.
// src/lib/server/jobs/index.ts
export const jobs: Record<string, Job> = {
'session-cleanup': { execute: sessionCleanup },
};
Adding a job
- Write a pure function in
src/lib/server/jobs/:
// src/lib/server/jobs/my-job.ts
export async function myJob(): Promise<number> {
// do work, return count of affected rows
return affectedCount;
}
- Register it:
// src/lib/server/jobs/index.ts
import { myJob } from './my-job';
export const jobs: Record<string, Job> = {
'session-cleanup': { execute: sessionCleanup },
'my-job': { execute: myJob }, // add this line
};
- Add a Vercel cron entry (Vercel only):
{
"crons": [
{ "path": "/api/cron/session-cleanup", "schedule": "0 3 * * *" },
{ "path": "/api/cron/my-job", "schedule": "0 4 * * *" }
]
}
Container mode runs all registered jobs on the same interval (JOB_INTERVAL_MS, default 3 hours). Vercel crons can have independent schedules.
HTTP endpoint
GET /api/cron/[job] requires a bearer token:
Authorization: Bearer <CRON_SECRET>
Vercel sends this automatically when CRON_SECRET is set in the dashboard. The CRON_SECRET value must match across vercel.json config and the dashboard environment variable.
Returns:
{ "success": true, "deleted": 42 }
Returns 401 if the token is missing or wrong. Returns 404 if the job slug is not in the registry.
Scheduler behaviour (container)
- Runs 5 seconds after startup (catches expired sessions immediately)
- Repeats every
JOB_INTERVAL_MSmilliseconds (default:10800000= 3 hours) globalThis.__v10r_schedulerprevents duplicate intervals on HMR restartstimer.unref()lets the process exit cleanly without a pending interval- Clears on
SIGTERMfor graceful shutdown
Environment variables
| Variable | Platform | Notes |
|---|---|---|
CRON_SECRET |
Both | Bearer token for HTTP cron endpoint. Generate with openssl rand -base64 32 |
CONTAINER |
Container | Set to 1 in compose.yaml. Tells platform detector this is a persistent process |
JOB_INTERVAL_MS |
Container | Interval in ms. Omit to use the 3-hour default |
VERCEL |
Vercel | Set automatically by Vercel. Disables in-process scheduler |
CI/CD Pipeline
GitLab CI (All Targets)
# .gitlab-ci.yml
stages:
- build
- deploy
variables:
BUN_VERSION: "1.1"
# Build for Vercel
build:vercel:
stage: build
image: oven/bun:1
script:
- bun install --frozen-lockfile
- bun run build
artifacts:
paths:
- .vercel/output
only:
- main
# Build for Koyeb
build:koyeb:
stage: build
image: docker:24
services:
- docker:24-dind
script:
- docker build -t $CI_REGISTRY_IMAGE:$CI_COMMIT_SHA .
- docker push $CI_REGISTRY_IMAGE:$CI_COMMIT_SHA
only:
- main
# Deploy to Vercel
deploy:vercel:
stage: deploy
image: node:20-alpine
script:
- npx vercel --prod --token=$VERCEL_TOKEN
needs:
- build:vercel
only:
- main
# Deploy to Koyeb
deploy:koyeb:
stage: deploy
image: curlimages/curl:latest
script:
- |
curl -X POST "https://app.koyeb.com/v1/services/$KOYEB_SERVICE_ID/redeploy" \
-H "Authorization: Bearer $KOYEB_API_TOKEN"
needs:
- build:koyeb
only:
- main
GitHub Actions (All Targets)
# .github/workflows/deploy.yml
name: Deploy
on:
push:
branches: [main]
jobs:
deploy-vercel:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- uses: oven-sh/setup-bun@v2
with:
bun-version: latest
- run: bun install --frozen-lockfile
- run: bun run build
- uses: amondnet/vercel-action@v25
with:
vercel-token: ${{ secrets.VERCEL_TOKEN }}
vercel-org-id: ${{ secrets.VERCEL_ORG_ID }}
vercel-project-id: ${{ secrets.VERCEL_PROJECT_ID }}
vercel-args: '--prod'
deploy-koyeb:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- name: Build and push Docker image
run: |
echo "${{ secrets.KOYEB_DOCKER_TOKEN }}" | docker login -u koyeb --password-stdin
docker build -t koyeb.io/${{ secrets.KOYEB_APP }}:${{ github.sha }} .
docker push koyeb.io/${{ secrets.KOYEB_APP }}:${{ github.sha }}
- name: Deploy to Koyeb
run: |
curl -X PATCH "https://app.koyeb.com/v1/services/${{ secrets.KOYEB_SERVICE_ID }}" \
-H "Authorization: Bearer ${{ secrets.KOYEB_API_TOKEN }}" \
-H "Content-Type: application/json" \
-d '{"definition":{"docker":{"image":"koyeb.io/${{ secrets.KOYEB_APP }}:${{ github.sha }}"}}}'
Database Migrations
Vercel
Add to build command in Vercel dashboard:
bun run db:push && bun run build
Or use Vercel's build hooks:
{
"buildCommand": "bun run db:push && bun run build"
}
Koyeb
Run migrations before starting the app in Dockerfile:
# Add migration step
CMD ["sh", "-c", "bun run db:push && bun run ./build/index.js"]
Or use a separate migration job:
# Run migration manually
koyeb service exec <service> -- bun run db:push
Health Checks
Koyeb Health Check
Configure in Koyeb dashboard → Service → Health checks:
| Setting | Value |
|---|---|
| Path | /api/health |
| Port | 3000 |
| Period | 30s |
| Timeout | 5s |
Health Endpoint
// src/routes/api/health/+server.ts
import { json } from '@sveltejs/kit';
import { db } from '$lib/server/db';
import { sql } from 'drizzle-orm';
export async function GET() {
try {
// Check database connection
await db.execute(sql`SELECT 1`);
return json({
status: 'healthy',
timestamp: new Date().toISOString(),
});
} catch (error) {
return json(
{ status: 'unhealthy', error: 'Database connection failed' },
{ status: 503 }
);
}
}
Cold Starts
Vercel
- Edge runtime: ~50ms cold start
- Node.js runtime: ~200-500ms cold start
- Mitigate: Use edge runtime for latency-sensitive routes
Koyeb
- Free tier: Service sleeps after inactivity
- Cold start: 10-30 seconds
- Mitigate: Implement loading states, use keep-alive ping
// Keep-alive cron (external service like cron-job.org)
// Ping /api/health every 5 minutes to prevent sleep
Debugging
Vercel Logs
# View logs (requires Vercel CLI on host machine)
vercel logs <deployment-url>
# Stream logs
vercel logs <deployment-url> --follow
Note: Vercel CLI is installed globally on your host machine, not in the container.
Koyeb Logs
# Install Koyeb CLI
curl -fsSL https://raw.githubusercontent.com/koyeb/koyeb-cli/master/install.sh | sh
# View logs
koyeb service logs <service-name>
# Stream logs
koyeb service logs <service-name> --follow
Environment Variable Checklist
| Variable | Vercel | Koyeb | Required |
|---|---|---|---|
DATABASE_URL |
Dashboard | Dashboard | Yes |
ORIGIN |
Auto | Manual | Yes (Koyeb) |
RATE_LIMIT_SECRET |
Dashboard | Dashboard | Yes |
CRON_SECRET |
Dashboard | Dashboard | If using crons |
CONTAINER |
Not used | compose.yaml |
Container only |
JOB_INTERVAL_MS |
Not used | Dashboard | Container only, optional |
R2_ENDPOINT |
Dashboard | Dashboard | If using R2 |
R2_ACCESS_KEY_ID |
Dashboard | Dashboard | If using R2 |
R2_SECRET_ACCESS_KEY |
Dashboard | Dashboard | If using R2 |
PROTOCOL_HEADER |
Auto | Dockerfile | Koyeb only |
HOST_HEADER |
Auto | Dockerfile | Koyeb only |
File Structure
/
├── Dockerfile # Koyeb container build
├── .dockerignore # Docker build exclusions
├── vercel.json # Vercel configuration (crons, regions)
├── compose.yaml # Local dev (sets CONTAINER=1)
├── svelte.config.js # Adapter selection
├── .gitlab-ci.yml # GitLab CI/CD
├── .github/
│ └── workflows/
│ └── deploy.yml # GitHub Actions
└── src/
├── hooks.server.ts # Imports scheduler (bare import activates it)
├── lib/server/
│ ├── platform/
│ │ ├── types.ts # PlatformId, PlatformInfo
│ │ └── index.ts # Platform detection (VERCEL vs CONTAINER)
│ └── jobs/
│ ├── index.ts # Job registry (slug → execute fn)
│ ├── scheduler.ts# In-process scheduler (containers only)
│ └── session-cleanup.ts # Deletes expired sessions
└── routes/api/cron/
└── [job]/
└── +server.ts # HTTP trigger (Vercel crons)
Summary
| Step | Vercel (Node.js) | Vercel (Bun) | Koyeb (Bun) |
|---|---|---|---|
| Adapter | adapter-vercel |
adapter-vercel |
svelte-adapter-bun |
| Build | bun run build |
bun run build |
DEPLOY_TARGET=bun bun run build |
| Config | Default | Add bunVersion: "1.x" |
Dockerfile |
| Deploy | Git push | Git push | Dockerfile → dashboard |
| Status | Stable | Experimental | Stable |
| Logs | vercel logs |
vercel logs |
koyeb service logs |
| Scheduled jobs | HTTP cron via vercel.json |
HTTP cron via vercel.json |
setInterval in process |
Related
- stack/deployment.md - Decision rationale
- rate-limiting.md - Rate limiting configuration
- middleware.md - Request handling
- error-handling.md - Error pages and logging