Zum Hauptinhalt springen

Tri-target deployment design: Vercel (Node.js), Vercel (Bun experimental), and Koyeb (Bun container).

Status: only the Vercel Node.js target is wired. The sveltekit() options in vite.config.ts hardcode adapter-vercel; DEPLOY_TARGET, svelte-adapter-bun, and the Dockerfile referenced below do not exist yet. The Bun/Koyeb sections are design intent for the planned container target, kept because the job system and env parity were built to support them.

Strategy: Same codebase, different configurations. See stack/ops/deployment.md for decision rationale and current status.


Quick Start

# Vercel (Node.js) — Stable, the only target wired today. Never deployed by hand:
# Stas says "ship it" → squash into GitHub main → GitLab sync → Vercel builds GitLab main
bun run build   # the same build runs locally/in the cloud as `bun run validate:build`

# Vercel (Bun) — PLANNED, experimental
bun run build
# Add "bunVersion": "1.x" to vercel.json

# Koyeb (Bun) — PLANNED; DEPLOY_TARGET and the Dockerfile do not exist yet
DEPLOY_TARGET=bun bun run build
# Deploy via Dockerfile

Adapter Configuration

Dynamic Adapter Selection (planned)

Today vite.config.ts imports @sveltejs/adapter-vercel unconditionally and passes it to sveltekit({ adapter }). The switch below is the shape the container target will introduce:

// vite.config.ts
import adapterVercel from '@sveltejs/adapter-vercel';
import { sveltekit } from '@sveltejs/kit/vite';
import adapterBun from 'svelte-adapter-bun';

const target = process.env.DEPLOY_TARGET;

const adapter = target === 'bun'
  ? adapterBun({ precompress: true })
  : adapterVercel({ runtime: 'nodejs22.x', regions: ['fra1'] });

export default defineConfig({
  plugins: [sveltekit({ adapter /* , version, csp, preprocess */ })],
});

Adapter Dependencies

Dev dependencies (svelte-adapter-bun is not installed until the container target lands, and must support SvelteKit 3 first):

"@sveltejs/adapter-vercel": "^7.0.1",
"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

  1. Git source: the GitLab mirror gitlab.com/ApeDevil/v10r, production branch main. GitHub is the development authority; gitlab-sync.yml copies its main there (shipping.md).
  2. Framework preset: SvelteKit (auto-detected)
  3. Build command: bun run build (default)
  4. Output directory: .vercel/output (auto-configured)
  5. Node.js version: 22.x — the engine the gate runs on (Runtime parity)

Function region

The functions run in fra1, declared as the adapter default in vite.config.ts (sveltekit({ adapter: adapter({ runtime: 'nodejs22.x', regions: ['fra1'] }) })) — next to Neon in eu-central-1, so the two blocking database hops of a request stay in one region. Route-level config exports (maxDuration on the AI routes) merge over the defaults and keep the region. Before the declaration the deployment ran on Vercel's default iad1 (measured 2026-09-13 from x-vercel-id: fra1::iad1::…); x-vercel-id reads edge::function::id and is the check.

vercel.json

vercel.json holds three things: the git.deploymentEnabled guard ("**": false, "main": true — only main builds), the no-cache header for /service-worker.js, and the cron schedules. Read the file; it is short and is the only copy.

Vercel cron calls are authenticated with CRON_SECRET. See Scheduled Jobs below.

Environment Variables

The model. src/env.ts declares every variable the app reads, all optional at the schema level; each reader decides what unset means (fail at import, degrade a feature, or apply a code default). .env.example lists them in three tiers — required, feature switches, and commented overrides whose defaults are right. A variable is set by hand only when it is a secret, differs per deployment, or is an account id no code can know. Everything else is a code default, derived, or supplied by the platform. Since every deployment is a production deployment (below), each value is set for Production (Preview may mirror it for a manual vercel deploy).

Set by hand in Vercel → Settings → Environment Variables:

Variable Notes
NEON_DATABASE_URL_PROD Neon connection string (direct, non-pooled). Neon's Vercel integration injects DATABASE_URL instead — nothing reads that
BETTER_AUTH_SECRET ≥32 chars. Root of every derived key (security/subkey.ts)
BETTER_AUTH_URL The canonical site URL, https://www.v10r.dev. Required outside dev; auth, passkeys, the Discord redirect URI and the Web Push contact derive from it (auth/site-url.ts). Not derivable: VERCEL_PROJECT_PRODUCTION_URL is the apex, which redirects
GITHUB_CLIENT_ID / GITHUB_CLIENT_SECRET, GOOGLE_CLIENT_ID / GOOGLE_CLIENT_SECRET OAuth apps; required at import
ENCRYPTION_KEY 64-char hex (openssl rand -hex 32). Seals every stored credential — the AI provider keys (Admin → AI → Models) and name-check vendor secrets (Admin → Name check). Placeholders are rejected at boot; losing it invalidates all of them. Those credentials themselves are not environment variables
ADMIN_USER_ID Comma-separated admin user ids
ALTCHA_HMAC_KEY ≥32 chars; the build fails without it
UPSTASH_REDIS_REST_URL / UPSTASH_REDIS_REST_TOKEN Rate limiting and the captcha replay store. Without them the limiter fails closed in production
CRON_SECRET Vercel sends it as the bearer token on every cron call (Scheduled Jobs)
Feature blocks of .env.example §2 Neo4j, R2, Resend, Telegram, Discord (client id/secret, bot token), VAPID keypair, MCP tokens and salt, Neon Management API, DB monitoring, External Service Lab, PRIVACY_CONTACT_EMAIL. Each block is all-or-nothing; unset, that feature is off

Supplied by Vercel, never set by hand: VERCEL (disables the in-process scheduler — platform/index.ts), VERCEL_ENV (preview-only passkey switch, robots, MCP telemetry lane), VERCEL_GIT_COMMIT_SHA (build version, MCP server info), NODE_ENV=production. They require "Automatically expose System Environment Variables", which is on by default.

Not set on Vercel because the code default is right: BOT_DETECTION_MODE (live, the only mode a build accepts), VAPID_SUBJECT (the https site URL), NEON_PARENT_BRANCH_ID (the dev branch's parent), DBOPS_AUTO_REFRESH_ENABLED (off), the subkey overrides. Meaningless on Vercel: JOB_INTERVAL_MS, DELIVERY_INTERVAL_MS, JOBS_DEV_ENABLED, ANALYTICS_DEV_TRACKING (container or dev only). Only while rotating: ENCRYPTION_KEY_PREVIOUS (Rotation).

The runtime-lab service on Render needs one variable, SERVICE_TOKEN; Render injects PORT and the service binds 0.0.0.0 with a default of 8080 for local runs (external-service.md). The GitLab sync workflow needs one GitHub secret, GITLAB_PROMOTION_TOKEN, in environment gitlab-promotion.

Adapter Options

// vite.config.ts — inside sveltekit({ ... })
import adapter from '@sveltejs/adapter-vercel';

sveltekit({
  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,
  }),
});

No preview deployments

Feature branches never deploy: the vercel.json guard switches the git integration off for every branch but main, and nothing is pushed to GitLab except main. A feature is seen on its branch (cloud or local session), then shipped. Every deployment in the project is a production deployment. The code still treats VERCEL_ENV === 'preview' defensively, for a manual vercel deploy.


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 (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",
  "crons": [
    { "path": "/api/cron/due", "schedule": "0 3 * * *" },
    { "path": "/api/cron/bot-ranges-refresh", "schedule": "45 4 * * *" }
  ]
}

The key addition is "bunVersion": "1.x" which enables Bun runtime for all functions.

What Changes

Aspect Node.js Bun
Runtime Node.js 22 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

  1. Trial on a separate Vercel project first — v10r has no preview deployments, and production is the only target
  2. Test all routes — Server functions, API endpoints, load functions
  3. Check edge cases — File uploads, WebSockets, streaming
  4. Monitor errors — Watch Vercel logs for Bun-specific issues
  5. Compare performance — Measure latency vs Node.js deployment

Reverting to Node.js

If issues arise, simply remove the bunVersion line:

{
  "framework": "sveltekit"
}

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.lock ./

# 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.lock ./
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

  1. Sign up at koyeb.com (no credit card required)

  2. Create app → Select GitHub repository

  3. Builder: Dockerfile

  4. Instance type: Nano (free tier)

    • 512 MB RAM
    • 0.1 vCPU
    • Frankfurt or Washington D.C.
  5. Environment variables: Add in Koyeb dashboard

  6. Port: 3000

Environment Variables

Set in Koyeb dashboard → Service → Settings → Environment:

Variable Required Notes
NEON_DATABASE_URL_PROD Yes Neon connection string
UPSTASH_REDIS_REST_URL Yes Upstash Redis REST URL — enables rate limiting. Without it the limiter passes through in dev and fails closed in prod
UPSTASH_REDIS_REST_TOKEN Yes Upstash Redis REST token (paired with the URL above)
ORIGIN Yes https://your-app.koyeb.app
R2_ACCOUNT_ID / R2_BUCKET_NAME If using R2 R2 account and bucket (the endpoint is derived from the account id)
R2_ACCESS_KEY_ID If using R2 R2 credentials
R2_SECRET_ACCESS_KEY If using R2 R2 credentials

Adapter Options

// vite.config.ts — inside sveltekit({ ... })
import adapter from 'svelte-adapter-bun';

sveltekit({
  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 NEON_DATABASE_URL_PROD="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/due once a day (everything due, in registry order); GET /api/cron/[job] for standalone jobs
Container setInterval in hooks.server.ts Direct function call

Platform detection — src/lib/server/platform/index.ts reads VERCEL, FLY_APP_NAME and RAILWAY_ENVIRONMENT from $app/env/private (declared in src/env.ts). If VERCEL is set, platform.persistent = false and the scheduler is a no-op. FLY_APP_NAME and RAILWAY_ENVIRONMENT map to persistent platforms (fly, railway). Otherwise it falls through to the default { id: 'container', persistent: true } and the scheduler starts — persistence is the default, not a marker variable.

Job registry — src/lib/server/jobs/index.ts maps slugs to execute() functions plus a cadence. Both trigger paths call the same registry; jobsDueOn(date) is what the daily sweep runs.

// src/lib/server/jobs/index.ts
export const jobs: Record<string, Job> = {
  'session-cleanup': { execute: sessionCleanup, cadence: 'daily' },
};

Adding a job

  1. 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;
}
  1. Register it:
// src/lib/server/jobs/index.ts
import { myJob } from './my-job';

export const jobs: Record<string, Job> = {
  'session-cleanup': { execute: sessionCleanup, cadence: 'daily' },
  'my-job': { execute: myJob, cadence: 'daily' },   // add this line — position = run order
};
  1. Nothing else for Vercel: the daily /api/cron/due sweep picks it up. Only a job that is too slow for the sweep (external fetches measured in tens of seconds) gets standalone: true and its own entry:
{
  "crons": [
    { "path": "/api/cron/due", "schedule": "0 3 * * *" },
    { "path": "/api/cron/my-slow-job", "schedule": "0 5 * * *" }
  ]
}

Container mode runs all registered jobs on the same interval (JOB_INTERVAL_MS, default 3 hours). Vercel crons can have independent schedules.

Sub-daily crons need Vercel Pro — Hobby allows daily only. Every cron in vercel.json is daily or weekly to stay within Hobby; dbops-reaper is a daily job inside the /api/cron/due sweep (src/lib/server/jobs/index.ts). See data/neon-branch-refresh.md.

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_MS milliseconds (default: 10800000 = 3 hours)
  • globalThis.__v10r_scheduler prevents duplicate intervals on HMR restarts
  • timer.unref() lets the process exit cleanly without a pending interval
  • Clears on SIGTERM for graceful shutdown

Environment variables

Variable Platform Notes
CRON_SECRET Both Bearer token for HTTP cron endpoint. Generate with openssl rand -base64 32
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

Reference only — this project intentionally has no CI (solo dev; validate locally with bun run validate, see CLAUDE.md "Verification"). There is no .gitlab-ci.yml; the one GitHub workflow, .github/workflows/gitlab-sync.yml, copies GitHub main to GitLab main and tests nothing (shipping.md). The pipelines below are templates for a future multi-target setup, not active config.

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:22-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

Not used in v10r. The Vercel build is bun run build only; the schema is pushed from a local session (bun run db:push needs the real database credentials, so a cloud session cannot run it). Push a schema change before shipping the code that needs it. The shape below is what a project that wants push-on-deploy would add — it would run on every production build:

{
  "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

Illustrative — /api/health is not yet built. There is no src/routes/api/health/ route today; the live platform/quota health surface is the admin monitoring layer (src/lib/server/monitoring/*). To use the Koyeb health check below, create the route first; until then /api/health 404s.

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 { db } from '#lib/server/db/index.ts';
import { sql } from 'drizzle-orm';

export async function GET() {
  try {
    // Check database connection
    await db.execute(sql`SELECT 1`);

    return Response.json({
      status: 'healthy',
      timestamp: new Date().toISOString(),
    });
  } catch (error) {
    return Response.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 (Vercel CLI, local host only; cloud sessions use the Vercel MCP: get_runtime_logs)
vercel logs <deployment-url>

# Stream logs
vercel logs <deployment-url> --follow

Note: locally the Vercel CLI is installed on the host, 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

Vercel's list is Environment Variables above; this table only adds what a planned container target (Koyeb) changes.

Variable Vercel Koyeb (planned)
Everything in Environment Variables Dashboard Dashboard
ORIGIN Not used Dashboard (svelte-adapter-bun)
PROTOCOL_HEADER / HOST_HEADER Not used Dockerfile
JOB_INTERVAL_MS Not used Optional (3 h default)
VERCEL, VERCEL_ENV, VERCEL_GIT_COMMIT_SHA Platform Absent — container scheduler runs

File Structure

/
├── Dockerfile              # Koyeb container build
├── .dockerignore           # Docker build exclusions
├── vercel.json             # Vercel configuration (crons)
├── compose.yaml            # Local dev (loads .env)
├── vite.config.ts          # sveltekit({...}): adapter selection, function region
└── src/
    ├── hooks.server.ts     # Imports scheduler (bare import activates it)
    ├── lib/server/
    │   ├── platform/
    │   │   ├── types.ts    # PlatformId, PlatformInfo
    │   │   └── index.ts    # Platform detection (VERCEL / FLY / RAILWAY, else container default)
    │   └── 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 ship → GitLab sync → Vercel builds main same as Node.js Dockerfile → dashboard
Status Live Planned (experimental) Planned
Logs vercel logs vercel logs koyeb service logs
Scheduled jobs HTTP cron via vercel.json HTTP cron via vercel.json setInterval in process

← Back to Blueprint

Geht dieses Pattern noch besser? Sag uns, wie.

Feedback geben