Deployment guide
Three production paths for a tzin app: Node container, Cloudflare Workers, and multi-node clustering.
New here? The fastest way to a deployable project is
npx create-tzin my-app— pick thenode,bunorworkerstemplate, then follow the matching section below.
1. Node.js (Docker)
Build your app
A tzin app is a standard TypeScript project with src/app.ts exporting the App:
npx create-tzin my-app --template node # src/app.ts + src/index.ts + tsconfig.json
cd my-app && npm installBuild it with the TypeScript compiler (the template's build script):
npm run build # tsc → dist/ (template script: "build": "tzin build")The compiled app lives in dist/. Serve it with the Node adapter (your own src/app.ts exports the app):
// src/main.ts — your production entrypoint
import { listen } from '@carlos-tzin/tzin'
import app from './app.js'
listen(app, { port: Number(process.env.PORT) || 3000 })Dockerfile (multi-stage, matches the node template)
Copy this into the app repo created above. It uses the template's own layout (tsconfig.json → dist/, entry src/index.ts → dist/index.js) so docker build . works with no path edits — only change the image name and PORT if needed.
# ---- build ---- — same files the node template ships
FROM node:20-slim AS build
WORKDIR /build
COPY package.json package-lock.json ./
RUN npm ci --ignore-scripts
COPY tsconfig.json ./
COPY src/ ./src/
RUN npm run build # tsc → dist/ (template script: "build": "tzin build")
# Copy your app entrypoint (the file that exports + starts the app)
COPY src/app.ts ./app.ts
COPY src/main.ts ./main.ts # if you have a separate starter
# ---- runtime ----
FROM node:20-slim
WORKDIR /app
COPY --from=build /build/dist ./dist
COPY --from=build /build/package.json ./
COPY --from=build /build/package-lock.json ./
# Production deps only — drops the template's devDependencies
# (tsx, vitest, typescript, @types/node).
ENV NODE_ENV=production
ENV PORT=3000
EXPOSE 3000
HEALTHCHECK --interval=10s --timeout=3s --start-period=3s --retries=3 \
CMD node -e "fetch('http://localhost:'+process.env.PORT+'/health').then(r=>process.exit(r.ok?0:1)).catch(()=>process.exit(1))"
# Matches the node template's `start` script ("node dist/index.js").
CMD ["node", "dist/index.js"]End-to-end on a machine with a Docker daemon (not validated in CI here):
npx create-tzin my-app --template node && cd my-app && npm install
cp /path/to/tzin/Dockerfile /path/to/tzin/.dockerignore .
docker build -t my-tzin-app .
docker run -p 3000:3000 --env PORT=3000 my-tzin-app
curl -fs localhost:3000/health # {"status":"ok"}(The template's /health returns 200 — the Dockerfile HEALTHCHECK above assumes exactly that.)
Note: If your app entrypoint does both export + listen in the same file (like
examples/node-demo.ts), compile it and run the resultingdist/*.jsdirectly. If it only exports the app (recommended), add a tinymain.tsthat callslisten().
Build and run
docker build -t my-tzin-app .
docker run -p 3000:3000 --env PORT=3000 my-tzin-appMulti-node without code changes? Not quite — one optional file
tzin has no magic env-var switch for clustering: a Hub joins a cluster only when you pass it a MessageBus. The node template ships the optional helper for exactly this — src/bus.ts:
- No
REDIS_URL→redisBus()returnsundefined, app runs standalone. REDIS_URL=redis://…→ returns an ioredis-backedMessageBus(lazyimport('ioredis'), so the dep is only needed when you scale).src/bus.tstypechecks with zero new dependencies (structural typing).
To use it, wire the hub where you create channels (or just leave the file unused — it costs nothing). Install the driver only for scaled deploys:
npm i ioredisdocker-compose (app + Redis for clustering)
services:
app:
build: .
ports: ["3000:3000"]
environment:
- NODE_ENV=production
- PORT=3000
- TZIN_BUS=redis
depends_on:
redis:
condition: service_healthy
restart: unless-stopped
redis:
image: redis:7-alpine
command: redis-server --save "" --appendonly no
ports: ["6379:6379"]
healthcheck:
test: ["CMD", "redis-cli", "ping"]
interval: 5s
timeout: 3s
retries: 5
restart: unless-stoppedScale horizontally (needs npm i ioredis + the src/bus.ts wiring above):
docker compose up -d --scale app=3Each node shares channels and presence through Redis pub/sub. See MessageBus / clustering for the wiring.
Environment variables
| Variable | Default | Description |
|---|---|---|
PORT | 3000 | HTTP listen port |
NODE_ENV | — | Set to production |
REDIS_URL | — | Set to redis://host:6379 to enable multi-node (wires src/bus.ts); unset → standalone, no deps |
2. Cloudflare Workers
Requirements
- Wrangler CLI:
npm install -D wrangler(or usenpx wrangler) - A Workers-compatible project (see
create-tzinworkers template) - Durable Objects enabled for WebSocket channels (workerd binds each socket to its creating request's I/O context)
Minimal wrangler.toml
name = "my-tzin-workers-app"
main = "src/app.ts"
compatibility_date = "2025-01-01"
workers_dev = true
[durable_objects]
bindings = [{ name = "TZIN_APP", class_name = "TzinChannels" }]
[[migrations]]
tag = "v1"
new_sqlite_classes = ["TzinChannels"]App entrypoint (Workers)
// src/app.ts
import {
createApp, contract, impl, t,
Hub, Presence, channelRoutes, wsChannels,
toDurableWorker, toWorker, TzinChannels,
} from '@carlos-tzin/tzin'
export { TzinChannels }
const health = contract({
method: 'GET',
path: '/health',
responses: { 200: t.Object({ ok: t.Boolean() }) },
})
export default toDurableWorker(() => {
const hub = new Hub()
const presence = new Presence(hub, 30_000)
const app = createApp([
impl(health, () => ({ status: 200 as const, body: { ok: true } })),
...channelRoutes(hub, { presence }),
])
return toWorker(app, { wsRoutes: [wsChannels(hub, { presence })] })
})Deploy
npx wrangler deployUse tzin deploy --target workers from the CLI if your project has wrangler.toml.
When you need Durable Objects: Any app using WebSocket channels (
wsChannels) or cross-connection broadcast must run inside a DO. Plain HTTP-only apps can usetoWorkerwithout a DO.
Local dev
npx wrangler dev
# or with miniflare directly (used by CI probe):
npm run probe:workers3. Multi-node clustering (Redis)
For horizontal scale with shared channels and presence, wire hubs over a MessageBus. Redis PUBLISH/SUBSCRIBE maps in ~15 lines:
import { createClient } from 'ioredis'
import { Hub } from '@carlos-tzin/tzin'
const redis = await createClient({ url: process.env.REDIS_URL }).connect()
const hub = new Hub({
bus: {
publish: (ch, msg) => redis.publish(ch, msg),
subscribe: (ch, fn) => {
const sub = redis.duplicate()
sub.subscribe(ch)
sub.on('message', (c, m) => c === ch && fn(m))
return () => sub.destroy()
},
},
})Each node runs the same app code; all publishes fan out to every node's local subscribers. Presence state is cluster-wide (see src/presence.ts).
Health check
Every tzin app should expose a health contract:
const health = contract({
method: 'GET',
path: '/health',
responses: { 200: t.Object({ status: t.String() }) },
})
impl(health, async () => ({
status: 200 as const,
body: { status: 'ok' },
}))The Dockerfile above assumes /health returns 200. Adjust the HEALTHCHECK CMD if your path differs.
Process management (bare Node, no Docker)
- PM2:
pm2 start dist/index.js --name tzin --env production(the node template's entry) - SIGTERM: tzin's Node adapter handles graceful shutdown; make sure your process manager forwards signals.
- Logs: stdout/stderr. For structured logging, see the logging roadmap phase.
What's next — status
- [x] Docker / compose reference —
Dockerfile(multi-stage, doc-first),.dockerignore,docker-compose.yml(app + Redis, healthchecks). No daemon here todocker build, so treat as reviewed-not-executed. - [x] Workers copy-paste project —
examples/workers-quickstart/(real files)examples/workers-quickstart.md(same files, explained). Runtime verified by the existing miniflare probe (npm run probe:workers, 5/5).
- [x] Release automation (assisted) —
scripts/release.mjs <patch|minor|major> [--dry-run]: bumps root version, syncscreate-tzintemplates to^NEXT, prepends a CHANGELOG skeleton, commits + tags. Publish itself is MANUAL (see next item) — the script only prints the commands. - [x] Tag-triggered CI —
.github/workflows/release.ymlrunsnpm ci+npm run build+npm teston everyv*tag. Publishing is NOT in CI: it happens locally with the npm token already in~/.npmrc(no secret in the repo, no OTP). If you want CI to publish, uncomment thepublishjob in the workflow and register a Trusted Publisher (OIDC) for both packages at npmjs.com. - [x]
tzin deploy --target nodeas a real artifact —src/cli.tsnow builds withtscand then:--packtarsdist + package.json (+Dockerfile / docker-compose.yml / .dockerignorewhen present) into<name>-<version>.tgz;--docker <tag>runsdocker build -t <tag>;--pushchainsdocker push. Validated here with a scratch project (build → pack → tarball listsdist/ + package.json); docker build/push paths only print their commands because this environment has no daemon.