С любовью ко всем разработчикам на свете ❤️
Deploying a point0 app is two steps: build it, then run the built server entry
with Bun. There is no point0 start command — production runs the bundle
directly, and that one process serves the API, the SSR, and the static client.
point0 build # → dist/ (server + client), production by default
NODE_ENV=production bun run ./dist/server/index.server.jsA minimal deploy is a Bun image, dist/, and NODE_ENV=production. The rest of
this page covers the build output, Docker, env, and production error behavior.
point0 build is the production build. It runs generate first,
then bundles the client and the server in parallel into dist/:
point0 build # production build into dist/
point0 build --watch # rebuild on change (a built bundle, not a server)
point0 build --side client # build only one side (see the deploy gotcha below)Build is production by default — it loads the production .env cascade and
sets the mode to production before importing your engine, so you don't need
cross-env NODE_ENV=production in front of it (a shell-exported NODE_ENV
still wins). If NODE_ENV ends up something other than production, the build
warns:
Building with NODE_ENV=development, not "production": the client gets inline
sourcemaps and unminified bundles. Intentional? If not, set NODE_ENV=productionA fresh deploy needs no separate point0 generate before point0 build — build
always generates first. (Examples still run point0 generate in their setup
for typecheck and dev.)
See Build for the full flag list and the build internals; the table at the bottom of this page lists the deploy-relevant flags.
dist/
client/ # the browser bundle, served at /
_point0/assets/<hash>.png # asset bytes (only copy)
_point0/<scope>/build-version.json # the build identity (deploy invalidation)
_point0/<scope>/build-assets.json # content-hashed file list (server-only; asset caching)
**.js
server/
index.server.js # the production server entry — run this
**.jstarget: 'browser', code-split). In
production it is minified with external sourcemaps.target: 'bun', minified, code-split). vite,
esbuild, and friends are marked external, so a Bun-only app runs in
production without them installed.dist/client at
/_point0/assets/<hash>.<ext>. The production server serves these statically;
it does not use the dev-only asset route. Details on Assets.The server entry filename comes from your engine entry map. The basic example
uses entry: { main: './index.server.ts' }, which builds to
dist/server/index.server.js.
Deploy gotcha — build both sides. URL-mode asset bytes are written by the client build.
point0 build --side serveralone does not populatedist/client, so/_point0/assets/<hash>404s. A real deploy builds both sides — which is the default. Only?fileassets are self-contained on a server-only build.
The server entry runs the engine. In the basic example it is two files: a
preload import, then app.server:
// dist source: index.server.ts
// The preload must finish first: it registers the point0 compiler plugins (and
// env consts), so everything app.server imports goes through them.
await import('./preload.js')
await import('./app.server.js')
export {}// dist source: app.server.ts
// Validate server env before anything else so a misconfigured server fails fast.
import '@/lib/env/server'
import { engine } from '@/engine.js'
await engine.serve()
// app.server is not only the api entry point — workers, crons, and initializers
// can live here too.In the built server the preload step is inert (the code is already
compiled, so there is no plugin to register), but the call stays in the bundle.
The order — preload before app — only matters when running source directly; see
Engine runtime.
engine.serve(options?) prepares the engine and starts a Bun server that serves
the API, SSR, and the client. If your engine declares a requiredCtx, serve
requires an options argument carrying it; otherwise the argument is optional.
The options object is Bun's Serve config (hostname, idleTimeout, TLS, a
custom fetch / websocket, …) — the same shape as the bunServeConfig engine
option. Calling serve() twice is a no-op (it returns early if a server is
already running), which matters for dev re-serve, not production.
The port resolves from your engine config, defaulting to 3000:
// examples/basic/src/engine.ts
export const engine = Engine.create({
// ...
server: {
port: process.env.SERVER_PORT || process.env.PORT, // → 3000 when unset
},
// ...
})Hosting platforms typically inject PORT in production — read it from the
environment, do not hardcode it. In a Docker compose setup you set it yourself
(e.g. PORT=3094).
Production binds once. In production the server binds the port a single time and fails immediately on conflict — no retries, and it never kills the process holding the port. (The retry/handover logic is dev-only.) A port already in use in production is a hard
EADDRINUSEfailure.
point0 sets no bind hostname — the serve config carries only the port — so the
address falls through to Bun.serve's default, 0.0.0.0 (all interfaces): the
right default behind a proxy or in a container. To pin it (e.g. 127.0.0.1 for
local-only), pass hostname through the bunServeConfig engine option or as a
serve() argument; both forward straight to Bun.serve.
The running server installs a process-exit handler that catches SIGINT,
SIGTERM, and SIGHUP and stops the Bun server — a normal container stop
drains the HTTP listener with no extra wiring. The handler does not call
engine.dispose(): dispose exists and tears down clients too, but the richer
shutdown coordinator is dev-orchestrator only. In production a client has
nothing server-side to dispose, so this is rarely a gap. If you hold resources
that need an explicit close on shutdown (a DB pool, a worker), add your own
SIGTERM/SIGINT handler in app.server that runs your teardown (and
engine.dispose() if you want it).
The simplest image: a Bun base, install, build, run the entry.
FROM oven/bun:1
WORKDIR /app
COPY . .
RUN bun install && bun run build
CMD ["bun", "run", "./dist/server/index.server.js"]For an app with Prisma or native addons (sharp, Prisma's engines), use a
multi-stage build. point0 bundles the server into dist/, but two things the
bundle can't carry are prisma migrate deploy (needs the Prisma CLI + schema)
and native addons — so the runtime stage keeps a production-only node_modules:
# ── build stage ── full install, then build. Only its dist/ ships.
FROM oven/bun:1 AS build
WORKDIR /app
RUN apt-get update && apt-get install -y --no-install-recommends ca-certificates openssl
COPY package.json bun.lock ./
RUN bun install --frozen-lockfile
COPY . .
RUN bunx prisma generate # throwaway URL — no DB needed at build time
RUN bun run build
# ── runtime stage ── dist + a production node_modules + the Prisma schema.
FROM oven/bun:1 AS runtime
WORKDIR /app
ENV NODE_ENV=production
RUN apt-get update && apt-get install -y --no-install-recommends ca-certificates openssl
COPY --from=build /app/package.json /app/bun.lock ./
RUN bun install --frozen-lockfile --production
COPY --from=build /app/dist ./dist
COPY --from=build /app/prisma ./prisma
COPY --from=build /app/prisma.config.ts ./prisma.config.ts
# Apply pending migrations, then start.
CMD ["sh", "-c", "bunx prisma migrate deploy && bun run start"]The prisma generate call at build time uses a throwaway database URL, so the
image builds anywhere without a real DATABASE_URL. A pure point0 app with no
Prisma and no native addons can ship dist/ and Bun alone.
For local Docker, the examples ship a docker-compose.yml and docker:build /
docker:up / docker:down scripts. A production-grade compose (see Start0)
adds a db service, an env_file cascade, a healthcheck, and depends_on
ordering.
All config — server and client — is read from the environment at runtime:
the server injects the client-safe keys into the page on each request, so
nothing is baked into the bundle. The same image deploys to any environment; you
set the variables on whatever runs the server (a platform's env config, a
compose env_file, the shell that starts the process). point0 reads them from
there; it does not fetch or manage secrets itself.
Two rules that bite in production:
NODE_ENV=production explicitly. point0's CLI can't help a process it
doesn't start. Running bun dist/server/index.server.js without
NODE_ENV gets Bun's own development .env cascade and skips production-only
behavior (minify tier, the error projection below). Set it via the Dockerfile
(ENV NODE_ENV=production), compose, or cross-env in your start script.app.server, not in the engine file. The engine file is
imported raw (no plugins) in some processes, so it must be side-effect free —
no env validation at module scope. Eager validation lives in app.server
(import '@/lib/env/server' as the first line), which runs at runtime.point0 loads Bun's native .env cascade by NODE_ENV: .env → .env.<mode> →
.env.local → .env.<mode>.local (shell-exported vars always win). A real
production variable set looks like DATABASE_URL, SERVER_URL + CLIENT_URL
(the public domain), PORT (injected), NODE_ENV=production, plus your
secrets. Full env model on Env.
When a point throws and the error component renders into SSR HTML, point0 projects the error differently by environment — and never renders the stack in production:
// packages/core/src/point0.ts — the default error component
const { stack, ...json } = _point0_env.mode.is.production
? this._Error.serializePublic(error) // no stack, no meta
: this._Error.serializePrivate(error) // full operator view
// In production stackToShow is forced undefined — the stack is never rendered
// into the SSR HTML, not even as a fallback to the live error.stack.
const stackToShow = _point0_env.mode.is.production
? undefined
: stack || error.stackserializePublic → { message, code?, redirect? }. No
stack, no meta, no class name. The stack is never written into the HTML the
client receives. (When there is no stack, nothing is rendered — no empty
<pre>.)serializePrivate → the full chain (status, meta,
stack, cause, …) and renders the stack in a <pre>.serializePrivate, regardless of environment — so the
operator still sees the full stack in server logs in production; only the SSR
HTML omits it.The gate is _point0_env.mode.is.production (i.e. NODE_ENV === 'production'),
which is one more reason to set NODE_ENV=production on deploy: forget it, and
the public could see server stacks. The public/private split, your error class,
and .errorClass(...) are covered on Error handling.
Every deploy changes the client chunk hashes. A tab loaded before the deploy
still holds the old URLs — its next client navigation would request a chunk that
no longer exists on the server. Point0 recovers from this out of the box; this
section is what the build and the server do, so you can keep it working on any
hosting. The client-side behavior and its stale option live on
Navigation.
The build stamps every client build with a deterministic version — a hash of its content-hashed file names — and emits it in two places:
dist/client/_point0/<scope>/build-version.json — fetched by a running client
(the fetch itself is cache: 'no-store', so no cache config is needed for
correctness) to confirm a suspected deploy. It ships inside the client output,
so it is served from the same base as the chunks on any host.dist/client/index.html as window.__POINT0_CLIENT_BUILD_VERSION__,
plus a small inline guard that reloads the document once (per version) if the
ENTRY script itself fails to load — the case where a cached document
references a dead entry and the app never boots. The SSR document inherits
both scripts, since it is assembled from that html.The built server then:
x-point0-client-build: <scope>:<version> on every response it can
attribute to a client scope — the running clients compare it against their own
version and switch to full document navigations once a new build appears;text/html MIME type would make the import failure
undiagnosable).None of this needs configuration, and in dev it is all inert — nothing is bundled, so there is no build to be stale against.
Cache headers are yours. Point0's handlers leave Cache-Control unset —
caching policy belongs to the app. The split that plays best with deploys:
content-hashed files (/chunk-<hash>.js, /_point0/assets/*) →
public, max-age=31536000, immutable; html documents → no-cache. Set it in a
middleware on the served response (or at your proxy/CDN); the
stale handshake itself works without any of it.
Serving the client from a static host or CDN? Ship the whole dist/client
including _point0/, keep html no-cache, and do not SPA-fallback missing
paths under the chunk directories to index.html — a missing chunk must 404.
The version handshake header is optional there; the chunk-failure path alone
recovers stale tabs.
Rolling deploys. With several instances serving different builds mid-rollout (one answers with the new version, another still serves the old chunks), the worst case is an extra full-page navigation — the mechanism only ever downgrades a client navigation to a document load, it never loops: a document navigation is attempted once per new version, and only after the version check confirms the build actually changed. Platforms that keep previous deployments' assets serveable (CDN in front of hashed files, overlapping asset dirs) avoid even that, since old chunks keep resolving — a nice-to-have, not a requirement.
Old tabs also keep calling your API until their next navigation — routine backward compatibility for the duration of a deploy window applies, as with any SPA.
point0 has no opinion on how many instances of the server you run — that is up to where and how you deploy it. The only thing to watch is database migrations.
Running prisma migrate deploy && bun run start in the start command is fine
when a single instance starts. If you run more than one instance, move
migrate deploy out of the start command into a deploy-time step (a platform
pre-deploy hook, a separate job), so the instances don't race on migrations.
point0 has no built-in healthcheck endpoint. If your load balancer or compose
healthcheck wants one, add it as an app point: a small action point that
returns 200, e.g.
root.lets.action('GET', '/api/health').action(() => new Response('OK')). The
path is yours to choose (Start0 ships one at /api/health as an example).
| Command | What it does |
|---|---|
point0 build | Production build → dist/ (generate, then client + server in parallel). |
point0 generate | Regenerate points/routes/meta. Not needed before build (build does it). |
point0 prune | Clear temporary/cache directories before a clean build. |
bun run ./dist/server/index.server.js | Run the built server in production (set NODE_ENV=production). |
| Flag | Effect |
|---|---|
-w, --watch [glob] | Rebuild on change; watches the entry import graph (plus any glob). Not a server. |
--side <server|client> | Build only one side. Deploy both — --side server alone leaves url assets unserved. |
--scope <scope> | Limit to one client scope. |
-C, --no-clean | Don't clean dist/ first (build cleans by default). |
-P, --no-publicdir | Don't build the publicdir. |
-k, --keep-alive | Don't exit after build — keep long-lived build plugins (e.g. a bundle analyzer) running. |
--env <name=value> | Set an env pair for the build (repeatable). |
--mode <mode> | Override the NODE_ENV mode (build defaults to production). |