Getting Started
Deployment
Deploy your Surf API to production — Vercel, Cloudflare Workers, Node.js, and Docker
Deployment
Surf runs anywhere JavaScript runs. This guide covers deploying to the most common platforms with production-ready configuration.
Vercel (Next.js)#
The fastest path to production with @surfjs/next.
Setup
npm install @surfjs/core @surfjs/next// app/api/surf/[...surf]/route.tsimport { createSurf } from '@surfjs/core'import { createSurfRouteHandler } from '@surfjs/next' const surf = await createSurf({ name: 'My API', commands: { search: { description: 'Search products', params: { query: { type: 'string', required: true } }, run: async ({ query }) => db.search(query), }, },}) export const { GET, POST } = createSurfRouteHandler(surf)Deploy
vercel --prodVercel automatically detects Next.js and deploys as serverless functions. No additional configuration needed.
Edge Runtime
To run on Vercel Edge Functions, add the runtime directive:
// app/api/surf/[...surf]/route.tsexport const runtime = 'edge' // ... same setup as above// @surfjs/core uses Web Crypto API — fully edge-compatibleEnvironment Variables
Set via the Vercel dashboard or CLI:
vercel env add SURF_AUTH_SECRETvercel env add DATABASE_URLCloudflare Workers#
Using @surfjs/web with Hono for the lightest edge deployment.
Setup
npm install @surfjs/core hono// src/index.tsimport { createSurf, honoApp } from '@surfjs/core'import { Hono } from 'hono'import { cors } from 'hono/cors' const surf = await createSurf({ name: 'Edge API', commands: { search: { description: 'Search products', params: { query: { type: 'string', required: true } }, run: async ({ query }, ctx) => { // Use Workers KV, D1, or any edge-native storage return { results: [] } }, }, },}) const app = new Hono()app.use('/*', cors())app.route('/', await honoApp(surf)) export default appwrangler.toml
name = "my-surf-api"main = "src/index.ts"compatibility_date = "2024-01-01" [vars]SURF_AUTH_SECRET = "set-in-dashboard"Deploy
npx wrangler deploySecrets
npx wrangler secret put SURF_AUTH_SECRETnpx wrangler secret put DATABASE_URLNode.js (Express / Fastify / Hono)#
For traditional server deployments. See the individual adapter docs for full setup:
Express Example
// server.tsimport { createSurf } from '@surfjs/core'import express from 'express'import cors from 'cors' const surf = await createSurf({ name: 'My API', commands: { /* ... */ },}) const app = express()app.use(cors({ origin: process.env.ALLOWED_ORIGINS?.split(',') }))app.use(express.json())app.use(surf.middleware()) const port = parseInt(process.env.PORT || '3000')app.listen(port, () => { console.log(`Surf API running on port ${port}`)})Build & Run
# TypeScript buildnpx tsc # Runnode dist/server.js # Or with tsx for developmentnpx tsx src/server.tsProcess Manager (PM2)
For production Node.js deployments:
npm install -g pm2// ecosystem.config.jsmodule.exports = { apps: [{ name: 'surf-api', script: 'dist/server.js', instances: 'max', exec_mode: 'cluster', env: { NODE_ENV: 'production', PORT: 3000, }, }],}pm2 start ecosystem.config.jspm2 savepm2 startupDocker#
Dockerfile
# Build stageFROM node:20-alpine AS builderWORKDIR /appCOPY package*.json ./RUN npm ciCOPY . .RUN npm run build # Production stageFROM node:20-alpineWORKDIR /appCOPY --from=builder /app/dist ./distCOPY --from=builder /app/package*.json ./RUN npm ci --omit=devEXPOSE 3000USER nodeCMD ["node", "dist/server.js"].dockerignore
node_modules.git.envdistDockerfiledocker-compose.ymldocker-compose.yml
services: surf-api: build: . ports: - "3000:3000" environment: - NODE_ENV=production - PORT=3000 - SURF_AUTH_SECRET=${SURF_AUTH_SECRET} - DATABASE_URL=${DATABASE_URL} restart: unless-stopped healthcheck: test: ["CMD", "wget", "--spider", "-q", "http://localhost:3000/.well-known/surf.json"] interval: 30s timeout: 5s retries: 3Build & Run
docker compose up -dEnvironment Variables#
Common environment variables across all deployment targets:
| Variable | Description | Required |
|----------|-------------|----------|
| PORT | Server port (Node.js/Docker) | No (default: 3000) |
| NODE_ENV | Environment (production / development) | Recommended |
| SURF_AUTH_SECRET | Secret for auth token verification | If using auth |
| DATABASE_URL | Database connection string | If using a database |
| ALLOWED_ORIGINS | Comma-separated CORS origins | Recommended |
.env File (Development)
PORT=3000SURF_AUTH_SECRET=dev-secret-change-in-prodDATABASE_URL=postgresql://localhost:5432/mydbALLOWED_ORIGINS=http://localhost:5173,http://localhost:3000Never commit
.envfiles. Add.envto.gitignoreand use platform-specific secret management in production.
Production Checklist#
Before going live, verify each item:
CORS
Configure CORS to allow only your frontend origins:
import cors from 'cors' app.use(cors({ origin: process.env.ALLOWED_ORIGINS?.split(',') || [], methods: ['GET', 'POST'], credentials: true,}))For Hono:
import { cors } from 'hono/cors' app.use('/*', cors({ origin: process.env.ALLOWED_ORIGINS?.split(',') || [],}))Rate Limiting
Protect your API from abuse:
// Express with express-rate-limitimport rateLimit from 'express-rate-limit' app.use('/surf/execute', rateLimit({ windowMs: 60 * 1000, // 1 minute max: 100, // 100 requests per window standardHeaders: true,}))For Cloudflare Workers, use Cloudflare Rate Limiting at the edge.
Authentication
If your commands require auth, configure it at the Surf level:
const surf = await createSurf({ name: 'My API', auth: { type: 'bearer', description: 'API token' }, authVerifier: async (token) => { const valid = token === process.env.SURF_AUTH_SECRET return valid ? { valid: true, claims: { role: 'user' } } : { valid: false, reason: 'Invalid token' } }, commands: { /* ... */ },})See the Authentication guide for token generation, scoped auth, and more.
HTTPS
Always serve over HTTPS in production:
- Vercel / Cloudflare — HTTPS by default
- Node.js behind a proxy — Use nginx or Caddy as a reverse proxy with TLS
- Docker — Terminate TLS at the load balancer or reverse proxy
Health Check
The Surf manifest endpoint doubles as a health check:
curl https://myapi.com/.well-known/surf.jsonReturns 200 with the manifest if the server is healthy.
Logging
Add request logging for observability:
// Expressimport morgan from 'morgan'app.use(morgan('combined')) // Honoimport { logger } from 'hono/logger'app.use(logger())Summary
| Item | Status |
|------|--------|
| CORS configured | ☐ |
| Rate limiting enabled | ☐ |
| Auth secrets set via env vars | ☐ |
| HTTPS enabled | ☐ |
| Health check working | ☐ |
| Logging configured | ☐ |
| .env not committed | ☐ |
| Error handling in place | ☐ |