-
Notifications
You must be signed in to change notification settings - Fork 0
Deployment
One image, N roles. Build once; the ROLE env var selects behavior. No role-specific Dockerfile, no per-role dependency set, no drift between what you tested and what runs.
v1.1.0 As of 2026-08. Stable API — semver from here (Upgrading). All three build targets ship — x build --target docker, x build --target binary, x build --target static — and so do the compose files and the Helm chart. Milestone 11 is 🚧 on one thing (roadmap): the two-platform proof — the demo app on Compose and on K8s from one image, with a rolling restart invisible to connected clients.
docker build -t myapp . # once
ROLE=web myapp # ← same image
ROLE=sync myapp
ROLE=worker myapp
ROLE=scheduler myapp
ROLE=migrate myapp # pre-deploy hook
ROLE=replicator myapp
| Role | Does | Scales on | Notes |
|---|---|---|---|
web |
SSR + static + RPC (actions/queries over HTTP) | RPS | behind CDN, stateless, N replicas, no local state |
sync |
live queries + fanout over WebSockets | concurrent connections | stateless, no sticky sessions — a client may reconnect to any node |
worker |
jobs + steps | queue depth | one pool per named queue; WORKER_QUEUES=default,integrations
|
scheduler |
cron dispatch → enqueue only | fixed 1 | Postgres advisory-lock leader election; a second instance is a warm standby, not a duplicate |
migrate |
run-once, pre-deploy | n/a | applies migrations through the ledger and exits; never binds a port. X_MIGRATE_CONCURRENT is reserved and not thrown — no advisory lock is taken, so serialise overlapping deploys yourself (Known gaps) |
replicator |
logical replication → change feed → matcher → NATS | 1 per database | owns the replication slot; a second instance would double-deliver, so it takes an advisory lock and exits if held |
- No role holds durable state. Everything survivable is in Postgres, NATS, or object storage.
- A role that cannot get its lock exits non-zero with a typed error rather than running degraded.
-
There is no
ROLE=all. Those six names are the whole set; anything else isX_ROLE_UNKNOWNat boot. For dev,x devco-locatesweb,sync,workerandschedulerin one process — role isolation is simulated, not skipped, and--roleopts thereplicatorin.
PORT selects the bind port, default 3000. Empty or whitespace falls back to the default; anything else must be an integer in 0–65535 or the process refuses with X_PORT_INVALID rather than quietly binding 3000 and failing the platform's health probe with nothing in the log that names the cause. The production entry is the scaffolded apps/web/server.ts → CLI reference.
Every role exposes both, on every replica. Both return { ok, role, buildId, checks: [...] } — never a bare 200 OK with no body.
| Endpoint | Answers | Fails when | Consumer |
|---|---|---|---|
/healthz |
"is this process alive?" | event loop wedged, unhandled fatal state | liveness probe → restart |
/readyz |
"should traffic come here?" | DB unreachable, NATS down, migration version mismatch, draining | readiness probe → remove from rotation |
| Role |
/readyz additionally checks |
|---|---|
web |
DB pool healthy, build ID matches the migration version |
sync |
replication feed lag under threshold, NATS subscribed |
worker |
queue reachable, at least one pool claiming |
scheduler |
holds the leader lock (a standby reports not-ready, by design) |
replicator |
slot active, WAL lag under threshold |
Identical in every role. Framework behavior, not a deployment guide.
SIGTERM
1. /readyz → 503 (LB stops sending new work; wait ≥ 2× probe interval)
2. stop accepting new work (HTTP: close listener; worker: stop claiming; sync: stop new subscribes)
3. finish in-flight work (bounded by DRAIN_TIMEOUT, default 30s)
4. role-specific handoff (see table)
5. flush OTel spans + logs
6. close pools, release advisory locks, exit 0
| Role | Step 4 handoff |
|---|---|
web |
let in-flight requests and streaming responses finish; a stream past the deadline gets a typed truncation, not a socket reset |
sync |
send every client a reconnect frame with a per-client backoff delay (see below), then close cleanly |
worker |
finish the current step, persist it, and release the job's lease so another worker resumes at the next step — never mid-step |
scheduler |
release the leader lock immediately so the standby promotes within one lock interval |
replicator |
flush the change feed to NATS up to the last confirmed LSN, then release the slot |
Exceeding DRAIN_TIMEOUT throws X_SHUTDOWN_TIMEOUT; requests arriving during the drain get X_DRAINING.
Closing 50,000 sockets at once means 50,000 simultaneous reconnects, all resubscribing, all asking "what changed since my LSN?" — a self-inflicted DDoS landing during a deploy when capacity is already reduced, and it is fractal: surviving nodes overload, drop connections, and the herd re-forms.
{ type: 'reconnect', afterMs: 1830, resumeFrom: '0/1A2B3C4', reason: 'drain' }
| Property | Effect |
|---|---|
Per-client afterMs, jittered over a window |
reconnects arrive spread out, not as a spike |
| Server chooses the window from live connection count | 500 clients drain in a second; 500k spread over minutes |
resumeFrom LSN |
reconnect is a delta from the change buffer, not a resubscribe-and-refetch |
| Clients redistribute | the LB places them across remaining nodes; no sticky session to honour |
| Client-side backoff is a floor, not the mechanism | a client that loses the socket without a frame still backs off exponentially with jitter |
Tune with realtime.drain in Configuration. The reconnect benchmark that gated topology now exists: 50,000 sockets, a SIGKILLed sync node with no drain and no reconnect frame — all 50,000 reconnected on their own backoff, 49,981 consistent, p50 54.0s / p90 105.5s, 156,851 connect attempts shed before any query path. That is the floor this section's frame is meant to beat, and it was measured on one node (Realtime).
x build --target docker # one image, all roles (default)
x build --target binary # single Bun-compiled executable, no runtime install
x build --target static # site/ output only: HTML, assets, sitemap, feeds
| Target | Output | Use |
|---|---|---|
docker |
one OCI image, ROLE selects behavior |
the normal path |
binary |
.x/app — bun build --compile, all roles inside. Crashes at import As of 2026-08 (Known gaps) |
VMs, systemd, air-gapped, a CLI-shaped product |
static |
.x/static — 0kb-JS pages, hashed assets, sitemap.xml, robots.txt, feeds |
CDN / object storage, deployed independently |
All targets share one build ID (content hash), stamped into the image, the HTML, the assets, sw.js, and x.manifest.json.
$ x build --target docker
✓ typecheck + boundaries ✓ site/ 12 routes static 0kb js
✓ app/ 31 routes stream ✓ static assets avif+webp, 214 files
✓ sw.js precache 1.9MB / 3MB ✓ manifest + openapi emitted
✓ image myapp:8f2a1c9 118MB build id 8f2a1c9
x build runs six of x verify's steps first — typecheck, lint, boundaries, filesize, package-shape, errors — and produces no artifact if any fail. It also refuses before spawning the builder when the target's entry file is missing (X_BUILD_ENTRY_MISSING) → CLI reference.
# docker/docker-compose.dev.yml — only needed for parity checks; `x dev` needs none of this
services:
app: { build: ., environment: { ROLE: web }, ports: ['3000:3000'] }
postgres: { image: postgres:17, ports: ['5432:5432'] }
nats: { image: nats:2, command: '-js', ports: ['4222:4222'] }
minio: { image: minio/minio, command: 'server /data', ports: ['9000:9000'] }x dev uses embedded Postgres, in-process NATS, and a local directory for S3 — Docker is not required to develop. This file exists for parity debugging and CI jobs that want real services.
# docker/compose.prod.yml
x-app: &app
image: myapp:${BUILD_ID}
env_file: .env.prod
restart: unless-stopped
services:
migrate: { <<: *app, environment: { ROLE: migrate }, restart: 'no' }
web: { <<: *app, environment: { ROLE: web }, deploy: { replicas: 3 },
depends_on: { migrate: { condition: service_completed_successfully } } }
sync: { <<: *app, environment: { ROLE: sync }, deploy: { replicas: 2 } }
worker: { <<: *app, environment: { ROLE: worker, WORKER_QUEUES: 'default,integrations' },
deploy: { replicas: 4 } }
scheduler: { <<: *app, environment: { ROLE: scheduler }, deploy: { replicas: 1 } }
replicator: { <<: *app, environment: { ROLE: replicator }, deploy: { replicas: 1 } }| Rule | Reason |
|---|---|
migrate completes before web/sync start |
a new schema must exist before new code reads it |
scheduler and replicator at 1 replica |
leader lock makes a second one a standby, not throughput |
stop_grace_period >= DRAIN_TIMEOUT
|
otherwise SIGKILL truncates the drain and the reconnect fanout |
Health probes from /readyz
|
never from a TCP check — a process can accept sockets while unable to serve |
x deploy --method compose applies this against the committed docker/docker-compose.prod.yml; it is a plain compose file you can read, diff, and run by hand.
The shipped file cannot scale
webpast 1. It declaresports: ['3000:3000']andreplicas: 3— two processes cannot bind one host port. Dropports:and put a reverse proxy in front, or setreplicas: 1. This is the rung-1 ceiling, and climbing off it is what the Helm chart below is for → Known gaps,docs/idea/17-scale-ladder.md.
Committed at docker/helm in the framework repo, one Deployment per role. Nothing generates it — there is no --helm flag on x build.
| Role | HPA metric | Typical range | Notes |
|---|---|---|---|
web |
requests/sec (or CPU as fallback) | 3–50 | behind Ingress + CDN; terminationGracePeriodSeconds >= drain |
sync |
active WS connections (custom metric) | 2–100 | no session affinity; connection count is the only honest signal |
worker |
queue depth per named queue (custom metric) | 2–200 | one Deployment per queue when isolation matters |
scheduler |
none — replicas: 1
|
1 |
PodDisruptionBudget maxUnavailable 1, leader lock covers overlap |
replicator |
none — replicas: 1
|
1 |
StatefulSet-shaped for stable identity; owns the slot |
migrate |
n/a | — | pre-install/pre-upgrade Job hook; blocks the release on failure |
CPU autoscaling is wrong for sync and worker: a node holding 80k idle sockets is near-zero CPU and near-capacity, and a worker blocked on a slow HTTP call is idle CPU with a growing backlog. The framework declares both series — connections and queue_depth, with rps derived from the monotonic http_requests_total — and SCALING_METRICS maps each role's signal to its series so the chart and the role table cannot drift. As of 2026-08 every role serves /metrics on METRICS_PORT (default 9090) and http/realtime/jobs call the recorders, so the signals exist. What the chart still lacks is a way to reach them: no role declares a metrics container port and the chart ships no scrape target, so an HPA needs both added before it reads anything → Observability.
The chart is committed at docker/helm in the framework repo and is not part of the scaffold, which is why x deploy --method helm throws X_NOT_IMPLEMENTED in a fresh app. Copy it in, or deploy with --method compose.
x build --target static # dist in .x/static — upload it with your CDN's own tool
| Property | Consequence |
|---|---|
| Static build does not include the app image | a copy change, a new blog post, a pricing tweak does not redeploy the API |
| Independent version, shared build ID namespace | assets stay resolvable across N deploys (PWA and offline) |
| ISR pages regenerate server-side and push to the CDN | no full rebuild for one changed record |
| Rollback is a pointer swap | seconds, no container churn |
| Cache purge | tag-driven, one hop from the write (Caching and invalidation) |
The only requirement: something that runs containers, plus Postgres. NATS and object storage are optional in small deployments — Postgres covers queue and pubsub; a local volume covers files.
| Target | How | Notes |
|---|---|---|
| Hetzner + Compose |
compose.prod.yml on one or two boxes |
cheapest credible production; one node runs all roles |
| Fly.io | one app per role, or process groups | drain semantics map cleanly to Fly's SIGTERM handling |
| Railway / Render | one service per role, same image | set ROLE per service |
| AWS ECS / Fargate | one task definition per role | ALB for web, NLB for sync
|
| Any Kubernetes | the generated Helm chart | EKS, GKE, AKS, k3s — no cloud-specific resources |
| Bare VM |
--target binary + systemd units per role |
no container runtime at all |
Not supported, by design: vendor edge runtimes, serverless-function-per-route, vendor KV / queue / cron primitives, proprietary image loaders. Each would need a second implementation of a framework primitive, and the second implementation is where behavior diverges.
x verify # the gate — green means shippable
x build --target docker
ROLE=migrate <image> # pre-deploy, must exit 0
<roll web + sync> # drain-aware; clients reconnect with backoff
x build --target static # independently, whenever copy changes
x doctor --json # env, versions, drift, ports
The framework depends on none of this; it is the operations manual for the app you deploy with it. Recommendations, not contracts — nothing in packages/ reads a word of it.
| Doc | Answers |
|---|---|
docs/ops/README.md |
the PaaS → Compose → Kubernetes ladder, and which rung you are on |
docs/ops/01-kubernetes.md |
the chart, one Deployment per role, probes, PDBs, HPAs |
docs/ops/02-secrets.md |
env or a mounted file, and nothing vendor-shaped |
docs/ops/03-observability.md |
what to scrape, what to alert on, what is not implemented yet → Observability |
docs/ops/04-datastores.md |
sizing Postgres, NATS and object storage for a given rung |
docs/ops/05-disaster-recovery.md |
backups, PITR, restore drills, replication-slot recovery |
docs/ops/06-runbooks.md |
symptom → page → action, per role |
Two design-only companions — specification, not shipped behaviour: docs/idea/16-app-targets.md (three targets, one backend, two view layers) and docs/idea/17-scale-ladder.md (why the app code is identical at rung 0 and rung 4).
Redeploy the previous image tag. Previous builds' assets stay served under the N-deploy retention window (default 10 deploys or 7d, whichever is longer), so a rollback does not 404 anyone mid-session. Version-skew handling: Upgrading. Failure symptoms: Troubleshooting.
Ultimate — v1.1.0 As of 2026-08. Stable API, semver from here. MIT licensed.
Repository · Issues · Changelog · llms.txt
Edits to these pages are synced from wiki/ in the repository — change the file there, not the wiki, or the next sync overwrites it.
Start
Tutorials
- 1 · First app
- 2 · First feature
- 3 · Auth and admin
- 4 · Jobs and realtime
- 5 · Deploy free
- 6 · Growing up
Primitives
- The eight primitives
- Actions
- Entities and migrations
- Policies and authz
- Queries and live queries
- Jobs and workflows
- Scheduled tasks
- Routes and render modes
Capabilities
Cross-cutting
Reference