Deploy

Apply the migrations, start the application in production, and stop it without cutting requests.

A deploy runs two commands on the host: the first applies the migrations the database lacks, the second starts the application. Run them in this order, each time the code changes:

npx ultimabase migrate deploy
npx ultimabase start

start sets NODE_ENV to production unless it is set, then starts the application that ultimabase.config.ts exports; the starter's npm start runs it. In production, a start never changes the tables: it checks that the migrations deployed match the models, and refuses to start otherwise (see PostgreSQL for writing migrations).

The environment

VariableRead byValue
NODE_ENVevery packageproduction; ultimabase start sets it when it is unset
DATABASE_URLpostgres({ url }), hereThe connection string of the database.
PORThttpPlugin()The port to listen on; 3000 when it is unset.
MASTER_KEYmasterKey, here32 characters at least, from openssl rand -base64 32.

DATABASE_URL and MASTER_KEY are the names the application reads, as the example does:

src/app.ts
import { graphqlPlugin } from '@ultimabase/graphql'
import { httpPlugin } from '@ultimabase/http'
import { postgres } from '@ultimabase/postgres'
import { defineApp } from 'ultimabase'
import { Project, Task } from './models.ts'

export const app = defineApp({
	// Outside production, a start syncs the tables to the models; in
	// production it checks that the migrations deployed match them.
	database: postgres({ url: process.env['DATABASE_URL'] }),
	// Listens on PORT, or 3000.
	plugins: [httpPlugin(), graphqlPlugin()],
	models: [Project, Task],
	// Accepted in production, where it needs 32 characters at least.
	masterKey: { key: process.env['MASTER_KEY'], production: true },
})

The full example, with its tests: examples/postgres.

A master key works in production only when written { key, production: true }. Written as a plain string, a request that sends it is refused with The master key is refused in production. A key shorter than 32 characters refuses the start:

src/app.postgres.test.ts
expect(
	await ultimabase('start', { env: { MASTER_KEY: 'secret' } }),
).toMatchObject({
	code: 1,
	output:
		'The master key is 6 characters long; production needs 32 at least: `openssl rand -base64 32` gives one.\n',
})

Start

Once the migrations are deployed, the application starts, and serves until it gets SIGTERM:

src/app.postgres.test.ts
expect(await ultimabase('migrate deploy')).toMatchObject({
	code: 0,
	output: [
		'Applied 20261026200257_init.sql.',
		'Applied 20261029230454_add_priority.sql.',
		'Applied 20261029230505_first_tasks_urgent.sql.',
		'Applied 20261029230507_open_tasks_index.sql.',
		'',
	].join('\n'),
})
expect(await ultimabase('migrate status')).toMatchObject({
	code: 0,
	output: 'The database is up to date.\n',
})
src/app.postgres.test.ts
let body: unknown
const started = await ultimabase('start', {
	// `openssl rand -base64 32` gives such a key.
	env: { MASTER_KEY: randomBytes(32).toString('base64') },
	use: async (url) => {
		const response = await fetch(`${url}/graphql`, {
			method: 'POST',
			headers: { 'content-type': 'application/json' },
			body: JSON.stringify({
				query: `mutation { createProject(data: { name: "Launch",
				tasks: { create: [{ title: "Write the README" }] } }) {
				name tasks { edges { node { title done priority } } } } }`,
			}),
		})
		body = await response.json()
	},
})
expect(body).toEqual({
	data: {
		createProject: {
			name: 'Launch',
			tasks: {
				edges: [
					{ node: { title: 'Write the README', done: false, priority: 0 } },
				],
			},
		},
	},
})
// SIGTERM finishes the requests under way, then stops it: it ends
// with 143, 128 plus SIGTERM's number.
expect(started.code).toBe(143)
expect(started.output).toContain('Application stopped.')

It logs Listening on http://localhost:3000., then one JSON line per event when its output is not a terminal. Without a deploy, or with a database that does not answer, it prints why and exits 1:

src/app.postgres.test.ts
expect(await ultimabase('start')).toMatchObject({
	code: 1,
	output:
		'The database has no UltimaBase schema: run `npx ultimabase migrate deploy`, then start again.\n',
})
src/app.postgres.test.ts
expect(
	await ultimabase('start', { env: { DATABASE_URL: '' } }),
).toMatchObject({
	code: 1,
	output: 'postgres() was given no url: is the variable it reads set?\n',
})
expect(
	await ultimabase('start', {
		env: { DATABASE_URL: 'postgres://postgres@localhost:1/app' },
	}),
).toMatchObject({
	code: 1,
	output:
		'Cannot reach PostgreSQL at localhost:1 (ECONNREFUSED): is it running, and is its url right?\n',
})

npx ultimabase start prints a refusal as one line. A script that calls app.start() itself gets the same refusal as a thrown error, with its stack.

Check before a deploy

migrate status lists the migrations a deploy would apply, and exits 1 when there is one: a release step can run it to know whether a deploy has work to do.

src/app.postgres.test.ts
// Exits 1 while a deploy has something to do.
expect(await ultimabase('migrate status')).toMatchObject({
	code: 1,
	output: [
		'20261026200257_init.sql is pending.',
		'20261029230454_add_priority.sql is pending.',
		'20261029230505_first_tasks_urgent.sql is pending.',
		'20261029230507_open_tasks_index.sql is pending.',
		'',
	].join('\n'),
})

Each migration runs in a transaction, and waits 5 s at most for a table another session holds. A failed migration is rolled back, and the deploy stops on it: <file> was not applied: <reason>, then The database rolled it back: fix it, then deploy again. The migrations before it stay applied.

Roll back the code

A migration is never undone. To go back to an earlier release, start its code on the database as it is. The start logs a warning, such as The database is ahead of the models: 20261029230454_add_priority.sql was applied after them., and serves.

The earlier code must work on the newer tables: a migration that removes or renames a column breaks it. Write such a change in two releases: the first stops using the column, the second removes it.

Stop

On SIGTERM, the server stops accepting connections, and waits for the requests under way, 10 s at most. Then it closes those still running, warns Stopped 10 s after stop began: closed the requests still running., and stops the plugins and the database. It logs Application stopped. last.

Let the host wait more than 10 s between SIGTERM and SIGKILL: Docker waits 10 s by default, Kubernetes 30 s.

Behind a proxy

The application serves HTTP. A proxy or the host's load balancer serves HTTPS in front of it. Set proxies to the number of proxies in front of the server, so that rules and limits read the client's address in X-Forwarded-For (see HTTP):

src/app.ts
plugins: [
	httpPlugin({
		routes: [shop],
		// The site that calls the API from a browser, with its cookies;
		// local origins are allowed too, outside production.
		cors: ['https://shop.example.com'],
		// 64 KB per request body; 1 MB by default.
		bodyLimit: 64 * 1024,
		// One proxy in front: the client's address is read from
		// X-Forwarded-For.
		proxies: 1,
	}),
],

What production changes

In productionOutside production
A start checks the migrations, and never changes a table.A start syncs the tables to the models.
sync and reset are refused.They change the development database.
The master key works only as { key, production: true }, 32 characters at least; each use is logged.It works as a plain string, of any length.
auth's session cookie is Secure, named __Host-…: it needs HTTPS.It works on http://localhost.
Only the origins in cors may call from a browser.Local origins are allowed too.
GraphQL introspection, its "Did you mean" suggestions and GraphiQL are off.They are on.
No request log, no GraphiQL or REST address at start.Each request is logged once answered.

Reference

Commands

CommandEffect
ultimabase migrate deployApplies the pending migrations, in order; exits 1 on the first that fails.
ultimabase migrate statusLists the pending migrations; exits 1 if there is one.
ultimabase startStarts the application, with NODE_ENV set to production when unset; exits 1 when it refuses.

Each takes --config <path> for a config other than ultimabase.config.ts.

Messages

MessageWhen
The database has no UltimaBase schema: run `npx ultimabase migrate deploy`, then start again.A start before the first deploy.
The database's tables are not the models': run `npx ultimabase migrate deploy`, then start again.A start with migrations not deployed.
The database is ahead of the models: <file> was applied after them.A warning: code older than the migrations deployed.
The master key is 6 characters long; production needs 32 at least: …A start with a short master key.
The master key is refused in production.A request sends a master key written as a plain string.
PORT is abc, not a port between 0 and 65535.A start with a wrong PORT.
Port 3000 is in use: stop the process that holds it, or set PORT.Another process listens on the port.
Stopped 10 s after stop began: closed the requests still running.A warning: requests ran 10 s after SIGTERM.
Application stopped.The stop ended.

The messages of migrate deploy and of the database are on PostgreSQL.

Limits

  • A stop waits 10 s at most for the requests under way.
  • A migration waits 5 s at most for a table another session holds.
  • A migration is never undone: a rollback runs earlier code on the newer tables.

On this page