Deployment
Deployment
There are two targets, and both ship from the same repository.
| Vercel (serverless) | Always-on (container or process) | |
|---|---|---|
| Setup | spfn add vercel |
spfn build && spfn start, or the generated Docker files |
| Where the backend runs | Vercel Functions on the same origin as the frontend | a long-lived process you host |
| Background jobs | not processed — enqueuing works, nothing drains the queue | in-process worker runs |
| WebSocket events | no | yes |
| Periodic DB health check | off | on |
Pick Vercel when the product is request/response and you want no infrastructure to operate. Pick always-on when background jobs, WebSockets, or the health check matter. Nothing stops you from running both: the same application, deployed twice, with jobs on the always-on side.
Vercel
spfn add vercel
That scaffolds src/app/api/backend/[[...route]]/route.ts (a hono/vercel adapter),
vercel.json, and an .npmrc naming the registry the @spfn scope resolves to. The SPFN app
mounts under /api/backend, so frontend and backend share one origin — set SPFN_API_URL to
https://<your-domain>/api/backend.
Set two environment variables on the Vercel project: GITEA_NPM_TOKEN with the registry token,
and NPM_RC with the block spfn add vercel prints. The token cannot live in the project
.npmrc: pnpm 10 and later refuse to expand an environment variable in a credential that came
from a committed file, drop the line with a warning, and the install then fails as unauthorized.
NPM_RC becomes the build container's user-level ~/.npmrc, which pnpm does still expand.
Three things to know:
- It runs on the Node runtime, not edge, because SPFN needs
pgand nativebcrypt. honomust be a direct dependency of the app, sohono/vercelresolves.- Seed and RBAC provisioning move to a deploy-time step (
provisionInfrastructure) rather than running per cold start, and the job worker does not run at all. If your app enqueues jobs, drain them from a scheduled endpoint (Vercel Cron calling a route that processes a batch) or run an always-on deployment alongside.
If you use the Vercel Supabase integration, the adapter maps the injected POSTGRES_URL
onto DATABASE_URL for you.
Deploying on the free tiers (Vercel Hobby + Supabase Free)? spfn cloud manages
them: cloud status shows usage against the plan limits, cloud keepalive stops
the Supabase project from pausing when idle, and cloud env pull/push move keys
between the providers and your local env without printing a value. See
CLI → spfn cloud.
The rest of this guide covers the always-on path.
Prerequisites
Minimum versions the framework requires:
| Minimum | Note | |
|---|---|---|
| Node.js | 20+ | @spfn/core runs on @hono/node-server 2, which requires it |
| PostgreSQL | 14+ | required; not optional |
| Redis | — | optional, only for features that use it |
The bundled files pin newer versions than the minimum on purpose: the generated Dockerfile
builds on node:22-alpine and the compose file runs postgres:16-alpine and Redis 7. Those
are what SPFN tests against, not a floor you have to meet.
For the always-on path you also need Docker and Docker Compose, unless you run
spfn build && spfn start directly on a host.
Docker Compose Setup
Superfunction provides a pre-configured docker-compose.yml for running PostgreSQL and Redis. Redis is optional and can be removed if you don't need caching or session storage:
# docker-compose.yml
version: '3.8'
services:
# PostgreSQL
postgres:
image: postgres:16-alpine
container_name: spfn-postgres
environment:
POSTGRES_USER: spfn
POSTGRES_PASSWORD: spfn
POSTGRES_DB: spfn_dev
ports:
- '5432:5432'
volumes:
- postgres_data:/var/lib/postgresql/data
healthcheck:
test: ['CMD-SHELL', 'pg_isready -U spfn -d spfn_dev']
interval: 5s
timeout: 5s
retries: 5
# Redis
redis:
image: redis:7-alpine
container_name: spfn-redis
ports:
- "6379:6379"
command: redis-server --appendonly yes
volumes:
- redis_data:/data
healthcheck:
test: ["CMD", "redis-cli", "ping"]
interval: 2s
timeout: 3s
retries: 5
volumes:
postgres_data:
redis_data:
networks:
default:
name: spfn-network
Starting Services
# Start PostgreSQL and Redis
docker-compose up -d
# Check service status
docker-compose ps
# View logs
docker-compose logs -f
# Stop services
docker-compose down
Application Dockerfile
Superfunction provides a production-ready Dockerfile optimized for both Next.js and API server:
# Production Dockerfile for Superfunction
FROM node:22-alpine
WORKDIR /app
# Install pnpm
RUN corepack enable pnpm
# Copy dependency files
COPY package.json pnpm-lock.yaml* ./
# Install dependencies
RUN pnpm install --frozen-lockfile --prod=false
# Copy source code
COPY . .
# Build application
RUN pnpm run spfn:build
# Remove dev dependencies (optional, reduces image size)
RUN pnpm prune --prod
# Environment
ENV NODE_ENV=production
# Expose ports (3790 for Next.js, 8790 for API)
EXPOSE 3790 8790
# Health check
HEALTHCHECK --interval=30s --timeout=3s --start-period=5s --retries=3 \
CMD node -e "require('http').get('http://localhost:8790/_core/health', (r) => {process.exit(r.statusCode === 200 ? 0 : 1)})"
# Start application
CMD ["pnpm", "run", "spfn:start"]
Note: Key Features
- Alpine-based: Minimal image size (~150MB)
- Layer caching: Dependencies cached separately from source code
- Production pruning: Dev dependencies removed after build
- Health checks: Built-in readiness probes
- Dual ports: Exposes both Next.js (3790) and API (8790)
Environment Configuration
Create separate environment files for different stages:
Development (.env.local)
# Database
DATABASE_URL="postgresql://spfn:spfn@localhost:5432/spfn_dev"
# Redis (Optional - remove if not needed)
REDIS_URL="redis://localhost:6379"
# API
NEXT_PUBLIC_API_URL="http://localhost:8790"
APP_PORT=3790
API_PORT=8790
# Next.js
NODE_ENV=development
Production (.env.production)
# Database (use production credentials)
DATABASE_URL="postgresql://user:password@postgres-host:5432/spfn_prod"
# Redis (Optional - remove if not needed)
REDIS_URL="redis://redis-host:6379"
# API
NEXT_PUBLIC_API_URL="https://api.yourdomain.com"
APP_PORT=3790
API_PORT=8790
# Next.js
NODE_ENV=production
# Proxy → backend trust (see "Client IP behind a proxy" below).
# Set the SAME value on the Next.js app and the API backend.
SPFN_PROXY_SECRET=your-shared-proxy-secret
# Number of trusted proxies in front (LB + nginx = 2). Default 1.
TRUSTED_PROXY_HOPS=1
# Optional: Monitoring
SENTRY_DSN=your-sentry-dsn
LOG_LEVEL=info
⚠️ Warning: Security Warning
Never commit
.env.productionto version control. Use environment variable injection in your CI/CD pipeline or secrets management system.
For a managed workflow, spfn secret stores deployed secrets in encrypted SOPS files
(secrets/<env>.enc.json, backend chosen by .sops.yaml — age / GCP KMS / AWS KMS)
that are safe to commit; your GitOps step decrypts them into env at deploy time. Local
secrets go to the OS keychain instead. See CLI → spfn secret.
Building for Production
Local Build
# Build Docker image
docker build -t spfn-app:latest \
--build-arg CI_BOT_TOKEN=${CI_BOT_TOKEN} \
--build-arg APP_PORT=3790 \
.
# Run container
docker run -d \
--name spfn-app \
--network spfn-network \
-p 3790:3790 \
-e DATABASE_URL="postgresql://spfn:spfn@spfn-postgres:5432/spfn_dev" \
-e REDIS_URL="redis://spfn-redis:6379" \
spfn-app:latest
# Check logs
docker logs -f spfn-app
With Docker Compose
Create a docker-compose.production.yml that includes your application:
version: '3.8'
services:
postgres:
image: postgres:16-alpine
container_name: spfn-postgres
environment:
POSTGRES_USER: ${POSTGRES_USER:-spfn}
POSTGRES_PASSWORD: ${POSTGRES_PASSWORD:-spfn}
POSTGRES_DB: ${POSTGRES_DB:-spfn_prod}
volumes:
- postgres_data:/var/lib/postgresql/data
healthcheck:
test: ['CMD-SHELL', 'pg_isready -U ${POSTGRES_USER:-spfn}']
interval: 5s
timeout: 5s
retries: 5
redis:
image: redis:7-alpine
container_name: spfn-redis
command: redis-server --appendonly yes
volumes:
- redis_data:/data
healthcheck:
test: ["CMD", "redis-cli", "ping"]
interval: 2s
timeout: 3s
retries: 5
app:
build:
context: .
dockerfile: Dockerfile
args:
CI_BOT_TOKEN: ${CI_BOT_TOKEN}
APP_PORT: 3790
container_name: spfn-app
ports:
- "3790:3790"
environment:
DATABASE_URL: postgresql://${POSTGRES_USER:-spfn}:${POSTGRES_PASSWORD:-spfn}@postgres:5432/${POSTGRES_DB:-spfn_prod}
REDIS_URL: redis://redis:6379
NODE_ENV: production
depends_on:
postgres:
condition: service_healthy
redis:
condition: service_healthy
restart: unless-stopped
volumes:
postgres_data:
redis_data:
networks:
default:
name: spfn-network
# Build and start all services
docker-compose -f docker-compose.production.yml up -d --build
# View logs
docker-compose -f docker-compose.production.yml logs -f app
# Stop all services
docker-compose -f docker-compose.production.yml down
Database Migrations
Superfunction provides CLI commands for managing database migrations:
# Generate migration from schema changes
npx spfn db generate
# Apply migrations to database
npx spfn db migrate
# Development workflow
npx spfn db generate # Create migration file
npx spfn db migrate # Apply to local database
# Production: Run migrations inside container
docker exec spfn-app npx spfn db migrate
# Or run migrations as a separate container
docker run --rm \
--network spfn-network \
-e DATABASE_URL="postgresql://spfn:spfn@spfn-postgres:5432/spfn_prod" \
spfn-app:latest \
npx spfn db migrate
Migrations gate the boot
A server whose database is behind the code it serves does not fail at startup — it fails
on the first request that touches a missing column, as an opaque 500. So it refuses to
start instead. spfn start (and startServer() directly) compares the migrations each
installed function package ships against what the database records as applied, prints the
ones still waiting, and exits.
A deploy that stops here is one that never served the errors. Put the migration step before the container starts, not after it.
The escape hatch is an environment variable, because a container cannot be handed a CLI flag:
SPFN_ALLOW_PENDING_MIGRATIONS=true # start anyway; the pending list is logged as a warning
The check is skipped entirely when the app initializes no database or no package ships migrations, and a database that cannot be reached is reported as "could not verify" — never as drift.
CI/CD Pipeline Example
Example GitHub Actions workflow for automated deployment:
name: Deploy to Production
on:
push:
branches: [main]
jobs:
deploy:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- name: Set up Docker Buildx
uses: docker/setup-buildx-action@v3
- name: Login to Container Registry
uses: docker/login-action@v3
with:
registry: your-registry.com
username: ${{ secrets.REGISTRY_USERNAME }}
password: ${{ secrets.REGISTRY_PASSWORD }}
- name: Build and push
uses: docker/build-push-action@v5
with:
context: .
push: true
tags: your-registry.com/spfn-app:latest
build-args: |
CI_BOT_TOKEN=${{ secrets.CI_BOT_TOKEN }}
cache-from: type=registry,ref=your-registry.com/spfn-app:buildcache
cache-to: type=registry,ref=your-registry.com/spfn-app:buildcache,mode=max
- name: Deploy to server
uses: appleboy/ssh-action@v1.0.0
with:
host: ${{ secrets.SERVER_HOST }}
username: ${{ secrets.SERVER_USER }}
key: ${{ secrets.SERVER_SSH_KEY }}
script: |
cd /opt/spfn-app
docker-compose pull
docker-compose up -d
docker-compose exec -T app npx spfn db migrate
Production Considerations
1. Resource Limits
Set appropriate resource limits in your docker-compose file:
services:
app:
# ... other config
deploy:
resources:
limits:
cpus: '2'
memory: 2G
reservations:
cpus: '1'
memory: 1G
2. Health Checks
Add health checks to ensure services are ready:
services:
app:
# ... other config
healthcheck:
test: ["CMD", "wget", "--quiet", "--tries=1", "--spider", "http://localhost:8790/_core/health"]
interval: 30s
timeout: 10s
retries: 3
start_period: 40s
Port 8790 is the SPFN server, not 3790. This example used to probe
http://localhost:3790/api/health — a Next.js path that no example or scaffold ever
creates, so the container would have stayed unhealthy forever. The frontend's readiness is
not what a probe wants to know either: the SPFN server is what holds the database and
cache connections that health reports on.
Which health path a probe should use
Point new manifests at /_core/health. That path belongs to @spfn/core, is
registered before your app's routes, and cannot be claimed by a route you declare — so
what answers it never depends on what your app happens to define.
/health is gone, and this is a breaking change — a deployment probing it must move.
@spfn/core registers nothing there any more:
| Your app | GET /health answered by |
GET /_core/health |
|---|---|---|
declares no GET /health |
410, naming /_core/health |
the built-in endpoint |
declares GET /health |
your route, like any other | the built-in endpoint |
set healthCheck.path: '/health' |
the built-in endpoint | the built-in endpoint |
set healthCheck.enabled: false |
nothing (404) | nothing (404) |
Two ways to migrate, and the first is the one to prefer:
- Move the probe to
/_core/health— in thereadinessProbe.httpGet.pathof your manifest, theHEALTHCHECKof your Dockerfile, and your load balancer's target group. No path an app declares can take it, so it stays true through every later release. - Restore the old address with
.healthCheck({ path: '/health' })when the probe path is frozen somewhere you cannot reach. The built-in then answers on both.
The 410 is a signpost for one release and is removed after that. It carries the new address in its body and the server logs a warning the first time it is hit, because a readiness probe failure surfaces neither to an operator — a Kubernetes event says the probe failed and stops there.
healthCheck.path adds an address; it never moves /_core/health.
When detailed health is enabled, the SPFN server's health response also reports migration state per function package, so a readiness probe can catch drift the local boot gate never sees — a pod that came up before a migration ran, for instance:
{
"status": "ok",
"migrations": {
"status": "pending",
"pending": 1,
"targets": [{ "name": "@spfn/auth", "total": 13, "applied": 12, "pending": 1,
"pendingTags": ["20260805143152_client_identity"] }]
}
}
Reporting drift does not change the overall status on its own — a probe that should
hold a drifted pod out of rotation asserts migrations.pending === 0 itself.
3. Logging
Configure logging drivers for production:
services:
app:
# ... other config
logging:
driver: "json-file"
options:
max-size: "10m"
max-file: "3"
4. Reverse Proxy
Use Nginx or Caddy as a reverse proxy for SSL termination:
services:
nginx:
image: nginx:alpine
ports:
- "80:80"
- "443:443"
volumes:
- ./nginx.conf:/etc/nginx/nginx.conf:ro
- ./ssl:/etc/nginx/ssl:ro
depends_on:
- app
app:
# ... other config
# Don't expose port directly
expose:
- "3790"
5. Client IP behind a proxy
Requests reach the SPFN backend through the RPC proxy (createRpcProxy), not directly from the
browser. The proxy does not forward the raw X-Forwarded-For header; it resolves the real
client IP from the forwarded chain and re-emits it under a dedicated header,
x-spfn-proxy-client-ip. The backend (getClientIp, used by rate limiting) trusts that header
only on requests it can verify came through the proxy — otherwise a direct caller could
spoof any IP.
To make verified client IPs work in production, set two environment variables:
SPFN_PROXY_SECRET— the same value on the Next.js app and the API backend. The proxy HMAC-signs each forwarded request; the backend's proxy-guard verifies the signature and then trustsx-spfn-proxy-client-ip. Without it, requests are unsigned, the backend won't trust the header, and the visitor IP falls back to the TCP peer address or'unknown'.TRUSTED_PROXY_HOPS— how many trusted proxies sit in front of the app (e.g. load balancer + nginx =2; default1). This tells the proxy how many rightmostX-Forwarded-Forentries are your own infra so it picks the correct client entry.
Don't work around a missing client IP by forwarding raw
X-Forwarded-Forfrom the proxy — that header is client-controllable and reintroduces IP spoofing. SetSPFN_PROXY_SECRETinstead. Rotate with a grace window viaSPFN_PROXY_SECRET_PREVIOUSon the backend.
For the full mechanism, see the @spfn/core/nextjs package README (Header forwarding & client
IP) and @spfn/core/middleware (proxy-guard + getClientIp).
Monitoring and Debugging
Container Logs
# View all container logs
docker-compose logs -f
# View specific service logs
docker-compose logs -f app
# View last 100 lines
docker-compose logs --tail=100 app
# View logs with timestamps
docker-compose logs -f -t app
Container Shell Access
# Access container shell
docker exec -it spfn-app sh
# Run commands in container
docker exec spfn-app pnpm run db:migrate
docker exec spfn-app node --version
# Check container resource usage
docker stats spfn-app
Database Connection
# Connect to PostgreSQL
docker exec -it spfn-postgres psql -U spfn -d spfn_prod
# Run SQL from host
docker exec -i spfn-postgres psql -U spfn -d spfn_prod < backup.sql
# Create database backup
docker exec spfn-postgres pg_dump -U spfn spfn_prod > backup.sql
Troubleshooting
Container Won't Start
# Check container status
docker-compose ps
# View detailed logs
docker-compose logs app
# Inspect container
docker inspect spfn-app
# Check if services are healthy
docker-compose ps --format json | jq '.[].Health'
Database Connection Issues
# Check if PostgreSQL is ready
docker exec spfn-postgres pg_isready -U spfn
# Test connection from app container
docker exec spfn-app sh -c 'apt-get update && apt-get install -y postgresql-client && psql $DATABASE_URL -c "SELECT 1"'
# Check network connectivity
docker exec spfn-app ping spfn-postgres
Build Cache Issues
# Clear Docker build cache
docker builder prune -af
# Rebuild without cache
docker-compose build --no-cache
# Remove all containers and volumes
docker-compose down -v
✅ Success: Next Steps
Your Superfunction application is now deployed! Consider adding:
- Monitoring with Sentry or similar tools
- Automated backups for PostgreSQL
- Load balancing for horizontal scaling
- CDN for static assets