-
Notifications
You must be signed in to change notification settings - Fork 0
Tutorial 05 Deploy Free
The framework ships no platform primitives — no buildpack, no app.json, no fly.toml, no adapter. It does not need to: every PaaS builds a Dockerfile, and every one of them wants the same three behaviours the scaffolded image already has.
v1.1.0 As of 2026-08. Every command and every output on this page was executed against a create-ultimate@1.1.0 app and its own docker/Dockerfile, with Docker 28 on Linux.
Series: 1 — first app · 2 — first feature · 3 — auth and admin · 4 — jobs and realtime · 5 · 6 — growing up
| The platform does | The image does |
|---|---|
injects PORT, routes traffic to it |
apps/web/server.ts binds exactly $PORT, refusing a non-port value with X_PORT_INVALID rather than defaulting past it |
requires a bind on 0.0.0.0
|
binds every interface — loopback is unreachable through a port mapping |
| polls a health path |
/readyz for may I have traffic, /healthz for am I alive
|
sends SIGTERM, then SIGKILL
|
drains in three phases: stop accepting, finish in-flight, close |
| wants one artifact per release | one image, ROLE selects the process |
Set DATABASE_URL. Point the platform at docker/Dockerfile. That is the integration.
bunx x build --target docker --tag myapp:dev✓ built docker
myapp:dev 194MB
x build runs the static gate steps first — typecheck, lint, boundaries, filesize, package-shape, errors — and exits non-zero without building if any fail. A build that would fail x verify produces no artifact.
The scaffolded image is oven/bun:1.3-alpine, runs as the non-root bun user, and its ENTRYPOINT is ["bun", "apps/web/server.ts"]. There is no build stage and no second gate inside it: re-running tsc and biome there would need the devDependencies the --production install deliberately omits.
docs/ops/README.mddescribes a distroless, single-binary, ~80MB image. That is not whatx newwrites at 1.1.0 — read the scaffoldeddocker/Dockerfileas the authority.
docker run -d --name myapp-web -e ROLE=web -e PORT=8080 -p 8085:8080 myapp:dev{"ts":"2026-08-11T17:12:04.499Z","level":"info","msg":"ultimate web listening on http://0.0.0.0:8080"}
{"ts":"2026-08-11T17:12:04.502Z","level":"info","msg":"ultimate started","role":"web","url":"http://0.0.0.0:8080","buildId":"ed71a3fe16aa534e"}
curl http://127.0.0.1:8085/readyz{"state":"ready","ready":true,"uptimeMs":11792,"inflight":0,"buildId":"","role":"web"}The image's own HEALTHCHECK reports healthy within the 30s start period. Prove that locally before you debug it on a platform.
| Key | Unset means | Set it to |
|---|---|---|
ROLE |
web |
web for the service, migrate for the release phase |
PORT |
3000 | whatever the platform injects — leave it to the platform |
DATABASE_URL |
embedded PGlite — never in production | the managed Postgres connection string |
A real DATABASE_URL env var wins over anything baked into the image; verified by pointing a container at an unreachable host and getting X_DB_UNAVAILABLE rather than a silent PGlite fallback.
.env.development is copied into the image — the .dockerignore excludes .env and .env.*.local, not .env.development. It ships with every value empty, so it is harmless as generated. Never put a real value in it.
Run the same image with ROLE=migrate before the new release serves traffic.
docker run --rm -e ROLE=migrate -e DATABASE_URL=postgres://… myapp:dev{"ts":"2026-08-11T17:12:23.681Z","level":"info","msg":"ultimate migrate applied","applied":3,"available":3,"appVersion":"dev"}It applies every pending migration in packages/db/migrations under a Postgres advisory lock, records each in the x_migrations ledger with its checksum, and exits 0. Concurrent migrators serialise (X_MIGRATE_CONCURRENT); a checksum that no longer matches an applied migration stops the release rather than corrupting it.
| Platform | Where the command goes |
|---|---|
| Heroku |
release: in Procfile, with ROLE=migrate on the release dyno |
| Render | a pre-deploy command running ROLE=migrate bun apps/web/server.ts
|
| Fly.io |
[deploy] release_command with ROLE=migrate
|
| Railway | a pre-deploy command running the same |
| Kubernetes | an initContainer or a Job on the same image |
| Compose | the migrate service; every other role waits on service_completed_successfully
|
No release phase on your free tier? Several platforms gate pre-deploy commands behind a paid plan. Run the one-off yourself against the same image and the same DATABASE_URL before promoting the release — that is what every row above ultimately is.
x db migrate is deliberately absent from that table: it is the developer's command, it needs the toolchain, and at 1.1.0 it is broken in a scaffolded app anyway. The release phase runs the shipped image and nothing else.
Free instances sleep. That is not a performance note; it changes which roles can exist.
| Role | On a single sleeping free instance |
|---|---|
web |
works. First request after a sleep pays a cold start, then serves normally |
worker |
does not run reliably. No inbound traffic means nothing wakes the instance, so queued jobs sit until the next visitor |
scheduler |
does not run reliably. A cron whose process is asleep at 03:00 does not fire; catchUp: 'skip' is the generated default, so the occurrence is dropped rather than replayed |
sync |
works while awake; every sleep disconnects every subscriber |
replicator |
needs a Postgres with wal_level=logical, which free managed tiers generally do not offer |
Two honest options on rung 0:
| Option | Trade |
|---|---|
ship ROLE=web only, do the work inline in the action |
no durability, no retries — acceptable for work that is cheap and idempotent |
ship ROLE=web and keep the jobs, accepting late execution |
correct results, unpredictable latency. The queue is Postgres, so nothing is lost — only delayed |
Do not paper over it with an external pinger: keeping a free instance awake around the clock is what the free tier is not. The moment jobs must run on time, that is the signal to climb — tutorial 6.
{"ts":"…","level":"info","msg":"draining","signal":"SIGTERM","deadlineMs":15000,"inflight":0}
{"ts":"…","level":"info","msg":"stopped","signal":"SIGTERM"}
/readyz flips to 503 before the socket closes, which is what makes a rolling restart drain instead of drop. With zero in-flight work the whole drain completes in a millisecond and the socket is simply gone — do not expect to observe the 503 on an idle process.
Full role-by-role handoff table: Deployment.
bunx x build --target static --out .x/static✓ built static
One HTML file per render: 'static' route — with the default scaffold that is .x/static/index.html. Every other render mode needs a running app and is reported as skipped, never emitted. Serve it from a CDN or an object store with no process behind it; set SITE_ORIGIN so canonical and og:url are built against the real host.
bunx x build --target binary --out .x/app✓ built binary
It compiles and then crashes at import — a 1.1.0 known gap:
ENOENT: no such file or directory, open '/$bunfs/package.json'
FRAMEWORK_VERSION reads package.json at module scope and a single-file executable has none. Use --target docker. The image is the self-contained artifact; the binary is a launcher for an app tree in any case, and must be started from the app root with the source beside it.
bunx x deploy --image myapp:dev --dry-run migrate docker compose -f …/docker/docker-compose.prod.yml run --rm migrate
web docker compose -f …/docker/docker-compose.prod.yml up -d web
sync docker compose -f …/docker/docker-compose.prod.yml up -d sync
worker docker compose -f …/docker/docker-compose.prod.yml up -d worker
scheduler docker compose -f …/docker/docker-compose.prod.yml up -d scheduler
✓ containers only: 1 image, roles migrate,web,sync,worker,scheduler
Migrate to completion, then the serving roles. --dry-run prints the plan and runs nothing.
Known gap. The shipped docker-compose.prod.yml gives web both ports: ['3000:3000'] and deploy: { replicas: 2 }; sync has the same shape. Two processes cannot bind one host port. Either drop the static publish and put a reverse proxy in front, or set replicas: 1. worker publishes no port and scales freely.
| Expected | Reality As of 2026-08
|
|---|---|
/metrics on ROLE=web
|
X_ROUTE_NOT_FOUND. A metrics module ships in core; confirm what your build exposes before wiring a scrape |
x logs tail |
planned — X_NOT_IMPLEMENTED, with x dev → the /_x timeline panel as its fix |
x status |
planned — x doctor --json is the shipped answer |
| OTLP export | tracing is a seam; spans exist, the default exporter is a no-op and no OTLP exporter ships |
| a Helm chart in your app |
x new writes none. Copy docker/helm from the framework repo, or stay on --method compose
|
Tutorial 6 — growing up: managed Postgres, a shared cache, one service per role, then Compose and Kubernetes — with the app code unchanged at every rung.
Related: Deployment · Configuration · CLI reference · Known gaps · 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