Production Server

A zero-dependency, secure-by-default production server for your Bini.js app, powered by bini-server.

Overview

bini-server is the default production server for the Node.js hosting target. It streams your built dist/ folder, serves /api/* routes directly from src/app/api/, and adds everything vite preview intentionally leaves out - ETag caching, timeouts, graceful shutdown, and configurable body limits.

Requestbini-server
dist/static files + SPA fallbacksrc/app/api//api/* routes

It has zero runtime dependencies - only Node.js built-in modules - and works identically on Windows, macOS, and Linux.

Requirements: Node.js ≥ 20.19.0, a built dist/ folder, and API handlers under src/app/api/ (if your app uses any).

Features

Core

Static file serving

Streams dist/ with correct MIME types, ETag, and cache headers.

API routes

Serves /api/* from src/app/api/ - Hono apps and plain functions both work.

SPA fallback

Unknown routes automatically serve dist/index.html.

ETag support

304 Not Modified responses for unchanged static files.

Lazy route loading

API routes are scanned on first request for fast cold starts.

Security & Performance

CORS

Enabled by default, configurable via CORS_ENABLED (BINI_*, VITE_*, or no prefix).

Body limits

Configurable request body size limit, defaults to 10MB.

Timeouts

Configurable body-read and handler timeouts, default 30s each.

Path traversal protection

Guards against .. and // in request URLs.

Module cache

Caches imported handlers with mtime invalidation.

Port auto-increment

Starts at 3000, auto-increments if the port is busy.

Developer Experience

Auto env loading

.env files are detected and listed in the startup banner.

Interactive shortcuts

Press h for help, o to open the browser, q to quit.

Cross-platform

Works identically on Windows, macOS, and Linux.

Graceful shutdown

Handles SIGTERM + SIGINT with a timeout fallback.

Zero dependencies

Only Node.js built-in modules - nothing to audit or update.

Flexible config

Every setting supports BINI_*, VITE_*, or no-prefix env vars.

Installation

Every Bini.js web scaffold already includes bini-server. To add it to an existing project:

>_Terminal
$ npm install bini-server

Usage

1. Add scripts to package.json

{}package.json
{
  "scripts": {
    "build": "vite build",
    "start": "bini-server"
  }
}

2. Build and start

>_Terminal
$ npm run build
$ npm start

3. Terminal output

>_Terminal
  ß Bini.js (production)
  ➜  Environments: .env, .env.local
  ➜  Local:   http://localhost:3000/
  ➜  Network: http://192.168.1.5:3000/
  ➜ press h + enter to show help

Keyboard Shortcuts

While the server is running, type a key and press enter:

KeyAction
hShow available shortcuts
oOpen your app in the default browser
qQuit the server
Keyboard shortcuts are automatically disabled in non-interactive environments, like Render or CI/CD.

Environment Variables

Auto-detected .env files

At startup, bini-server automatically detects and loads, in priority order:

.env.local
.env.production.local
.env.production
.env
  • .env.local
  • .env.[NODE_ENV].local (e.g. .env.production.local)
  • .env.[NODE_ENV] (e.g. .env.production)
  • .env

All detected files are listed in the startup banner.

Naming conventions

Every setting supports three naming conventions, in priority order:

ConventionExamplePriority
BINI_*BINI_PORT=3000Highest
VITE_*VITE_PORT=3000Medium
No prefixPORT=3000Lowest

Server configuration

VariableDefaultDescription
PORT3000HTTP port to listen on
CORS_ENABLEDtrueEnable/disable CORS on API routes
API_DIRsrc/app/apiPath to API handlers directory
DIST_DIRdistPath to static files directory
BODY_TIMEOUT_SECS30Max seconds to read the request body
HANDLER_TIMEOUT_SECS30Max seconds for a handler to respond
BODY_SIZE_LIMIT10485760Max request body size in bytes (10MB)

Examples

.env
# .env
PORT=8080
CORS_ENABLED=false
API_DIR=src/api
BODY_SIZE_LIMIT=5242880  # 5MB
>_Terminal
$ PORT=3001 BINI_CORS_ENABLED=false bini-server

# Or with the VITE prefix
$ VITE_PORT=3000 VITE_CORS_ENABLED=false bini-server

Project Structure

my-app
dist
index.html
assets
src
app
api
ƒusers.ts
posts
ƒindex.ts
ƒ[id].ts
layout.tsx
main.tsx
.env
package.json
vite.config.ts
  • dist/ - built static files (required)
  • src/app/api/ - API handlers (optional)
  • .env - environment variables

API Routes

Supported formats

A Hono app (recommended):

src/app/api/users.ts
import { Hono } from 'hono'

const app = new Hono()

app.get('/users', (c) => c.json({ users: [] }))

export default app

Or a plain function:

src/app/api/hello.ts
// src/app/api/hello.ts
export default (req: Request) => {
  return Response.json({ message: 'Hello' })
}
Only .ts and .js files are supported for API routes - the same convention used by bini-router.

Dynamic routes

src
app
api
users
ƒ[id].ts
posts
ƒ[...slug].ts
/api/users/:id
/api/posts/*

Route parameters

For plain function handlers, route params are passed as JSON via the x-bini-params request header:

src/app/api/users/[id].ts
// src/app/api/users/[id].ts
export default (req: Request) => {
  const params = JSON.parse(req.headers.get('x-bini-params') || '{}')
  // params.id -> '123'
  return Response.json({ id: params.id })
}

CORS

CORS is enabled by default with these headers:

Response headers
Access-Control-Allow-Origin: *
Access-Control-Allow-Methods: GET,POST,PUT,PATCH,DELETE,OPTIONS,HEAD
Access-Control-Allow-Headers: Content-Type,Authorization,X-Request-ID

Disable it with CORS_ENABLED=false, BINI_CORS_ENABLED=false, or VITE_CORS_ENABLED=false:

.env
# .env
CORS_ENABLED=false

Static File Serving

Supported MIME types

  • HTML, CSS, JavaScript, JSON
  • Images - PNG, JPEG, GIF, SVG, WebP, AVIF, ICO
  • Fonts - WOFF, WOFF2, TTF, EOT
  • Documents - TXT, XML
  • Web manifests

Cache headers

File TypeCache Policy
/assets/*public, max-age=31536000, immutable
All other filesno-cache

ETag support

ETags are generated automatically from file size + mtimeMs:

  • Sends an ETag header on the first request
  • Handles If-None-Match for 304 Not Modified responses
  • Uses an MD5 hash (16 chars) for efficient caching

vs vite preview

Featurevite previewbini-server
Serves dist/YesYes
API routesYesYes
SPA fallbackYesYes
Auto env loadingYesYes
ETag / 304 supportNoYes
Body timeoutNo30s
Body size limitNo10MB
Handler timeoutNo30s
Graceful shutdownNoYes
Module cacheNoYes
Configurable dirsNoYes
CORS controlNoYes
Zero dependenciesNoYes
Production useNot recommendedProduction-ready

Security

FeatureDefaultConfigurable
CORSEnabledvia CORS_ENABLED
Body size limit10MBvia BODY_SIZE_LIMIT
Request timeout30svia BODY_TIMEOUT_SECS
Handler timeout30svia HANDLER_TIMEOUT_SECS
Path traversalBlockedguard in place

Testing your server

>_Terminal
# Check static files
$ curl http://localhost:3000/

# Check API routes
$ curl http://localhost:3000/api/hello

# Check ETag
$ curl -I http://localhost:3000/styles.css

# Test 304 Not Modified
$ curl -I http://localhost:3000/styles.css \
  -H "If-None-Match: [etag_from_previous_request]"

# Test CORS
$ curl -X OPTIONS http://localhost:3000/api/hello \
  -H "Origin: http://example.com"

Configuration examples

Development (all security disabled)

.env
# .env
CORS_ENABLED=true
BODY_TIMEOUT_SECS=0
HANDLER_TIMEOUT_SECS=0
BODY_SIZE_LIMIT=0
NODE_ENV=development

Production (secure defaults)

.env
# .env
CORS_ENABLED=true
BODY_TIMEOUT_SECS=30
HANDLER_TIMEOUT_SECS=30
BODY_SIZE_LIMIT=10485760
NODE_ENV=production

Internal API (no CORS)

.env
# .env
CORS_ENABLED=false
BODY_SIZE_LIMIT=5242880  # 5MB

File upload service

.env
# .env
CORS_ENABLED=true
BODY_SIZE_LIMIT=1073741824  # 1GB
BODY_TIMEOUT_SECS=300  # 5 minutes

Deployment

Ship your src/ folder. bini-server runs API handlers directly from src/app/api/ - they are not compiled into dist/. Make sure your host has access to both dist/ and src/app/api/.

Where it works

  • VPS / pm2 - deploy the full project directory
  • Railway / Render / Fly.io - automatic, since these clone your repository
  • Docker - copy both dist/ and src/ into the image

VPS / dedicated server

>_Terminal
$ npm run build
$ npm start
$ npm install -g pm2
$ pm2 start "npm start" --name my-app
$ pm2 save
$ pm2 startup

Platform as a Service

PlatformStart CommandNotes
Railwaynpm startPORT injected automatically
Rendernpm startPORT injected automatically
Fly.ionpm startSee fly.toml example below
Herokunpm startPORT injected automatically

Docker

Dockerfile
FROM node:20-alpine
WORKDIR /app
COPY package*.json ./
RUN npm install --production
COPY . .
RUN npm run build
EXPOSE 3000
CMD ["npm", "start"]

Fly.io

fly.toml
[processes]
  app = "npm start"

API Reference

Environment variable priority

  1. BINI_* (highest)
  2. VITE_* (medium)
  3. No prefix (lowest)

HTTP status codes

CodeDescription
200Success
204OPTIONS preflight success
304Not Modified (ETag match)
400Bad request URL
404Route not found
408Request timeout
413Payload too large
500Internal server error

Supported HTTP methods

GET, POST, PUT, PATCH, DELETE, OPTIONS (CORS preflight), and HEAD (with ETag support).

Was this helpful?