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.
It has zero runtime dependencies - only Node.js built-in modules - and works identically on Windows, macOS, and Linux.
≥ 20.19.0, a built dist/ folder, and API handlers under src/app/api/ (if your app uses any).Features
Core
Streams dist/ with correct MIME types, ETag, and cache headers.
Serves /api/* from src/app/api/ - Hono apps and plain functions both work.
Unknown routes automatically serve dist/index.html.
304 Not Modified responses for unchanged static files.
API routes are scanned on first request for fast cold starts.
Security & Performance
Enabled by default, configurable via CORS_ENABLED (BINI_*, VITE_*, or no prefix).
Configurable request body size limit, defaults to 10MB.
Configurable body-read and handler timeouts, default 30s each.
Guards against .. and // in request URLs.
Caches imported handlers with mtime invalidation.
Starts at 3000, auto-increments if the port is busy.
Developer Experience
.env files are detected and listed in the startup banner.
Press h for help, o to open the browser, q to quit.
Works identically on Windows, macOS, and Linux.
Handles SIGTERM + SIGINT with a timeout fallback.
Only Node.js built-in modules - nothing to audit or update.
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:
$ npm install bini-server
Usage
1. Add scripts to package.json
{
"scripts": {
"build": "vite build",
"start": "bini-server"
}
}2. Build and start
$ npm run build $ npm start
3. Terminal output
ß 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:
| Key | Action |
|---|---|
| h | Show available shortcuts |
| o | Open your app in the default browser |
| q | Quit the server |
Environment Variables
Auto-detected .env files
At startup, bini-server automatically detects and loads, in priority order:
.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:
| Convention | Example | Priority |
|---|---|---|
| BINI_* | BINI_PORT=3000 | Highest |
| VITE_* | VITE_PORT=3000 | Medium |
| No prefix | PORT=3000 | Lowest |
Server configuration
| Variable | Default | Description |
|---|---|---|
| PORT | 3000 | HTTP port to listen on |
| CORS_ENABLED | true | Enable/disable CORS on API routes |
| API_DIR | src/app/api | Path to API handlers directory |
| DIST_DIR | dist | Path to static files directory |
| BODY_TIMEOUT_SECS | 30 | Max seconds to read the request body |
| HANDLER_TIMEOUT_SECS | 30 | Max seconds for a handler to respond |
| BODY_SIZE_LIMIT | 10485760 | Max request body size in bytes (10MB) |
Examples
# .env
PORT=8080
CORS_ENABLED=false
API_DIR=src/api
BODY_SIZE_LIMIT=5242880 # 5MB$ PORT=3001 BINI_CORS_ENABLED=false bini-server
# Or with the VITE prefix
$ VITE_PORT=3000 VITE_CORS_ENABLED=false bini-serverProject Structure
dist/- built static files (required)src/app/api/- API handlers (optional).env- environment variables
API Routes
Supported formats
A Hono app (recommended):
import { Hono } from 'hono'
const app = new Hono()
app.get('/users', (c) => c.json({ users: [] }))
export default appOr a plain function:
// src/app/api/hello.ts
export default (req: Request) => {
return Response.json({ message: 'Hello' })
}.ts and .js files are supported for API routes - the same convention used by bini-router.Dynamic routes
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
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:
Access-Control-Allow-Origin: *
Access-Control-Allow-Methods: GET,POST,PUT,PATCH,DELETE,OPTIONS,HEAD
Access-Control-Allow-Headers: Content-Type,Authorization,X-Request-IDDisable it with CORS_ENABLED=false, BINI_CORS_ENABLED=false, or VITE_CORS_ENABLED=false:
# .env
CORS_ENABLED=falseStatic 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 Type | Cache Policy |
|---|---|
| /assets/* | public, max-age=31536000, immutable |
| All other files | no-cache |
ETag support
ETags are generated automatically from file size + mtimeMs:
- Sends an
ETagheader on the first request - Handles
If-None-Matchfor 304 Not Modified responses - Uses an MD5 hash (16 chars) for efficient caching
vs vite preview
| Feature | vite preview | bini-server |
|---|---|---|
| Serves dist/ | Yes | Yes |
| API routes | Yes | Yes |
| SPA fallback | Yes | Yes |
| Auto env loading | Yes | Yes |
| ETag / 304 support | No | Yes |
| Body timeout | No | 30s |
| Body size limit | No | 10MB |
| Handler timeout | No | 30s |
| Graceful shutdown | No | Yes |
| Module cache | No | Yes |
| Configurable dirs | No | Yes |
| CORS control | No | Yes |
| Zero dependencies | No | Yes |
| Production use | Not recommended | Production-ready |
Security
| Feature | Default | Configurable |
|---|---|---|
| CORS | Enabled | via CORS_ENABLED |
| Body size limit | 10MB | via BODY_SIZE_LIMIT |
| Request timeout | 30s | via BODY_TIMEOUT_SECS |
| Handler timeout | 30s | via HANDLER_TIMEOUT_SECS |
| Path traversal | Blocked | guard in place |
Testing your server
# 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
CORS_ENABLED=true
BODY_TIMEOUT_SECS=0
HANDLER_TIMEOUT_SECS=0
BODY_SIZE_LIMIT=0
NODE_ENV=developmentProduction (secure defaults)
# .env
CORS_ENABLED=true
BODY_TIMEOUT_SECS=30
HANDLER_TIMEOUT_SECS=30
BODY_SIZE_LIMIT=10485760
NODE_ENV=productionInternal API (no CORS)
# .env
CORS_ENABLED=false
BODY_SIZE_LIMIT=5242880 # 5MBFile upload service
# .env
CORS_ENABLED=true
BODY_SIZE_LIMIT=1073741824 # 1GB
BODY_TIMEOUT_SECS=300 # 5 minutesDeployment
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/andsrc/into the image
VPS / dedicated server
$ npm run build $ npm start $ npm install -g pm2 $ pm2 start "npm start" --name my-app $ pm2 save $ pm2 startup
Platform as a Service
| Platform | Start Command | Notes |
|---|---|---|
| Railway | npm start | PORT injected automatically |
| Render | npm start | PORT injected automatically |
| Fly.io | npm start | See fly.toml example below |
| Heroku | npm start | PORT injected automatically |
Docker
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
[processes]
app = "npm start"API Reference
Environment variable priority
BINI_*(highest)VITE_*(medium)- No prefix (lowest)
HTTP status codes
| Code | Description |
|---|---|
| 200 | Success |
| 204 | OPTIONS preflight success |
| 304 | Not Modified (ETag match) |
| 400 | Bad request URL |
| 404 | Route not found |
| 408 | Request timeout |
| 413 | Payload too large |
| 500 | Internal server error |
Supported HTTP methods
GET, POST, PUT, PATCH, DELETE, OPTIONS (CORS preflight), and HEAD (with ETag support).