Skip to content
Docs

Tutorial: a Node.js service with Postgres and migrations

In this tutorial you build a small notes API in Node.js, give it a Postgres, connect a second service to it over the private network, and set up database migrations you can trust in production. The service code and the deploy steps below were run on the platform as written; outputs are trimmed. Along the way you get the reasoning behind each choice: how big the connection pool should be, what the health check should test, why migrations run before the server listens, and how to change a schema without downtime.

What you will build#

Three applications in one project. Only the API is reachable from the internet; everything else talks over the workspace's private network.

  • notes-db: Postgres from the template, with a persistent volume and a private TCP endpoint.
  • notes-api: the Node.js service. It owns the schema, runs the migrations and serves /notes and /healthz.
  • notes-summary: a private helper the API calls over HTTP to summarise a note. It shows how services find and call each other, and how to survive when one of them is down.

1. Create the database#

Start with the database, because the service needs its address. A project keeps the three services together on one canvas in the dashboard:

terminalbash
# A project groups the services on one canvas
inquir projects create notes

# Postgres 16 from the template: a persistent volume, a generated password and
# a private TCP endpoint. Nothing is exposed to the internet.
inquir apps create notes-db --template postgres --project notes
# Created application notes-db
#   template: postgres — first release 1ce3b082 is starting
# — serving (29s): serving production traffic —
#   connection string: inquir apps connection-url notes-db

# A minute later the private address is verified
inquir apps status notes-db
#   private:    notes-db-7ax8nt.apps.internal:5432  (postgres)
#   volume:     data → /var/lib/postgresql/data

The template runs Postgres 16 with 512 MB of memory and half a vCPU. The user is inquir, the database is named after the application (notes-db becomes notes_db), and the password is generated and stored encrypted. Data lives on the data volume. Postgres listens only on its private address, notes-db-<suffix>.apps.internal:5432. The suffix is random and never changes, even if you rename the application. The database has no public port, and traffic on the private network is not wrapped in TLS.

2. Write the service#

Create a folder notes-api with this layout: package.json, db.js, migrate.js, server.js, a migrations folder, a Dockerfile and a .dockerignore. The only dependency is pg, the standard Postgres driver: run npm install pg, which also writes the package-lock.json the Dockerfile needs. The service uses plain node:http so that nothing hides the database work. With Fastify or Express the routes look different, but db.js, migrate.js and the startup order stay exactly the same.

package.jsonjson
{
  "name": "notes-api",
  "version": "1.0.0",
  "private": true,
  "type": "module",
  "engines": {
    "node": ">=22"
  },
  "scripts": {
    "start": "node server.js",
    "migrate": "node migrate.js"
  },
  "dependencies": {
    "pg": "^8.23.1"
  }
}

db.js owns the connection pool. One pool per process, created once. Its settings are the ones that matter in production: a small max, a connect timeout so a request fails instead of waiting forever, a statement_timeout so one slow query cannot hold a connection, and an error listener. Without that listener, a connection that drops while idle (for example during the database's own deploy) crashes the whole process. waitForDatabase retries for up to a minute, because on a first deploy the database may still be starting.

db.jsjs
import pg from 'pg';

if (!process.env.DATABASE_URL) {
  // Fail fast with a hint instead of a confusing ECONNREFUSED on localhost.
  throw new Error('DATABASE_URL is not set. Run: inquir apps connect <database> <app>');
}

// One pool per process. Every replica opens up to `max` connections, so keep
// replicas x max well under the database's max_connections (100 by default).
export const pool = new pg.Pool({
  connectionString: process.env.DATABASE_URL,
  max: Number(process.env.PG_POOL_MAX ?? 5),
  idleTimeoutMillis: 30_000, // close connections nobody used for 30 s
  connectionTimeoutMillis: 5_000, // fail a request instead of queueing forever
  statement_timeout: 10_000, // no query may hold a connection longer than 10 s
  application_name: 'notes-api', // shows up in pg_stat_activity
});

// A connection that dies while idle in the pool (database restarted, network
// blip) emits 'error' on the pool. Without a listener Node crashes the process.
pool.on('error', (err) => console.error('postgres: idle client error:', err.message));

const sleep = (ms) => new Promise((resolve) => setTimeout(resolve, ms));

/**
 * The database may still be starting (first deploy) or restarting (its own
 * deploy). Retry with backoff for up to a minute before giving up.
 */
export async function waitForDatabase({ timeoutMs = 60_000 } = {}) {
  const deadline = Date.now() + timeoutMs;
  for (let attempt = 1; ; attempt++) {
    try {
      await pool.query('SELECT 1');
      return;
    } catch (err) {
      if (Date.now() >= deadline) throw err;
      const delay = Math.min(500 * 2 ** attempt, 5_000);
      console.log(`postgres: not ready (${err.code ?? err.message}), retrying in ${delay} ms`);
      await sleep(delay);
    }
  }
}

server.js starts in a fixed order: wait for the database, apply migrations, and only then listen. The platform starts sending traffic only after the health check passes, and the health check cannot pass before the server listens, so no request ever reaches an old schema. On SIGTERM it stops accepting connections, lets in-flight requests finish and closes the pool. The signal handlers are registered before the slow part of the startup, so a stop that arrives during migrations is handled too.

server.jsjs
import http from 'node:http';
import { pool, waitForDatabase } from './db.js';
import { migrate } from './migrate.js';

const port = Number(process.env.PORT ?? 3000);
let draining = false;

// --- the other service, reached over the private network -------------------
// SUMMARY_URL is http://<slug>.apps.internal of the summary app. A slow or
// missing dependency must not take this API down: short timeout, no throw.
async function summarize(text) {
  if (!process.env.SUMMARY_URL) return null;
  try {
    const res = await fetch(new URL('/summarize', process.env.SUMMARY_URL), {
      method: 'POST',
      headers: { 'content-type': 'application/json' },
      body: JSON.stringify({ text }),
      signal: AbortSignal.timeout(2_000),
    });
    if (!res.ok) throw new Error(`HTTP ${res.status}`);
    return (await res.json()).summary ?? null;
  } catch (err) {
    console.warn(`summary service unavailable: ${err.message}`);
    return null;
  }
}

// --- tiny HTTP helpers -------------------------------------------------------
function send(res, status, body) {
  res.writeHead(status, { 'content-type': 'application/json' });
  res.end(JSON.stringify(body));
}

async function readJson(req, limit = 64 * 1024) {
  let raw = '';
  req.setEncoding('utf8');
  for await (const chunk of req) {
    raw += chunk;
    if (raw.length > limit) throw Object.assign(new Error('body too large'), { status: 413 });
  }
  try {
    return raw ? JSON.parse(raw) : {};
  } catch {
    throw Object.assign(new Error('invalid JSON'), { status: 400 });
  }
}

// --- routes ------------------------------------------------------------------
async function handle(req, res) {
  const url = new URL(req.url, 'http://localhost');
  const noteId = url.pathname.match(/^\/notes\/(\d{1,18})$/)?.[1];

  if (url.pathname === '/healthz') {
    // Ready only when we can actually serve: not draining, database reachable.
    if (draining) return send(res, 503, { status: 'draining' });
    await pool.query('SELECT 1');
    return send(res, 200, { status: 'ok' });
  }

  if (url.pathname === '/notes' && req.method === 'GET') {
    const { rows } = await pool.query(
      'SELECT id, title, body, summary, created_at FROM notes ORDER BY id DESC LIMIT 50',
    );
    return send(res, 200, rows);
  }

  if (url.pathname === '/notes' && req.method === 'POST') {
    const { title, body = '' } = await readJson(req);
    if (typeof title !== 'string' || !title.trim()) return send(res, 400, { error: 'title is required' });
    if (typeof body !== 'string') return send(res, 400, { error: 'body must be a string' });
    const summary = await summarize(`${title}. ${body}`);
    // Always parameterised queries ($1, $2...), never string concatenation.
    const { rows } = await pool.query(
      'INSERT INTO notes (title, body, summary) VALUES ($1, $2, $3) RETURNING *',
      [title.trim(), body, summary],
    );
    return send(res, 201, rows[0]);
  }

  if (noteId && req.method === 'GET') {
    const { rows } = await pool.query('SELECT * FROM notes WHERE id = $1', [noteId]);
    return rows[0] ? send(res, 200, rows[0]) : send(res, 404, { error: 'not found' });
  }

  if (noteId && req.method === 'DELETE') {
    const { rowCount } = await pool.query('DELETE FROM notes WHERE id = $1', [noteId]);
    if (!rowCount) return send(res, 404, { error: 'not found' });
    return res.writeHead(204).end();
  }

  return send(res, 404, { error: 'not found' });
}

const server = http.createServer((req, res) => {
  handle(req, res).catch((err) => {
    if (!err.status) console.error(`${req.method} ${req.url} failed:`, err.message);
    if (!res.headersSent) send(res, err.status ?? 500, { error: err.status ? err.message : 'internal error' });
  });
});

// --- stop: finish in-flight requests, then close the pool ---------------------
// Registered before the slow startup below, so a stop during startup is clean too.
async function shutdown(signal) {
  if (draining) return;
  draining = true;
  console.log(`${signal} received, draining`);
  // Hard deadline below the platform's drain grace (--drain-grace, 10 s by
  // default), in case a client hangs on.
  setTimeout(() => process.exit(1), 8_000).unref();
  server.close(async () => {
    await pool.end();
    console.log('drained, bye');
    process.exit(0);
  });
}
process.on('SIGTERM', () => shutdown('SIGTERM'));
process.on('SIGINT', () => shutdown('SIGINT'));

// --- start: database first, then migrations, then traffic ---------------------
await waitForDatabase();
await migrate();
if (!draining) server.listen(port, '0.0.0.0', () => console.log(`notes-api listening on ${port}`));

Two details in the output: id comes back as a string, because pg returns bigint as a string to avoid losing precision; and every query is parameterised ($1, $2), never built by concatenating strings.

3. Migrations#

A migration is a numbered SQL file in migrations/. Files are applied in name order, each exactly once, and each applied name is recorded in a schema_migrations table. Start with two: the table, and a column added later.

migrations/001_create_notes.sqlbash
CREATE TABLE notes (
  id         bigint GENERATED ALWAYS AS IDENTITY PRIMARY KEY,
  title      text        NOT NULL,
  body       text        NOT NULL DEFAULT '',
  created_at timestamptz NOT NULL DEFAULT now()
);
migrations/002_add_note_summary.sqlbash
-- Expand step: a new nullable column. Old code ignores it, new code fills it.
-- Adding a nullable column without a default is instant even on a big table.
ALTER TABLE notes ADD COLUMN summary text;

The runner is about sixty lines you can read in full. It opens its own connection, separate from the pool, and takes a Postgres advisory lock, so when several replicas start at once one applies the migrations and the others wait, then find nothing left to do. It sets lock_timeout, so DDL that has to wait behind a busy table gives up after five seconds instead of queueing every query behind it. An ordinary file runs in a transaction: if it fails, none of it is applied and it is not recorded. A file whose first line is -- migrate:no-transaction holds a single statement and runs outside a transaction and without the lock timeout; that is what CREATE INDEX CONCURRENTLY needs, since it waits for running transactions by design.

migrate.jsjs
import { readdir, readFile } from 'node:fs/promises';
import { fileURLToPath } from 'node:url';
import pg from 'pg';

// Any constant unique to this application. Every replica that starts takes
// the same advisory lock, so only one of them applies migrations at a time;
// the others wait, then find nothing left to do.
const LOCK_KEY = 4_242_001;
const DIR = new URL('./migrations/', import.meta.url);

export async function migrate() {
  if (!process.env.DATABASE_URL) {
    throw new Error('DATABASE_URL is not set. Run: inquir apps connect <database> <app>');
  }
  // A dedicated connection, not the pool: no statement_timeout here, and the
  // advisory lock lives exactly as long as this session.
  const client = new pg.Client({
    connectionString: process.env.DATABASE_URL,
    application_name: 'notes-api:migrate',
  });
  await client.connect();
  try {
    await client.query('SELECT pg_advisory_lock($1)', [LOCK_KEY]);
    // DDL that has to wait for a busy table gives up after 5 s instead of
    // queueing every query behind it. Retry the deploy later rather than
    // freezing production.
    await client.query("SET lock_timeout = '5s'");
    await client.query(`
      CREATE TABLE IF NOT EXISTS schema_migrations (
        name       text PRIMARY KEY,
        applied_at timestamptz NOT NULL DEFAULT now()
      )`);

    const { rows } = await client.query('SELECT name FROM schema_migrations');
    const applied = new Set(rows.map((row) => row.name));
    const files = (await readdir(DIR)).filter((f) => f.endsWith('.sql')).sort();

    for (const file of files) {
      if (applied.has(file)) continue;
      const sql = await readFile(new URL(file, DIR), 'utf8');
      // CREATE INDEX CONCURRENTLY and friends refuse to run in a transaction.
      // Such a file holds exactly one statement and says so on its first line.
      const inTransaction = !/^\uFEFF?--\s*migrate:no-transaction\b/.test(sql);
      console.log(`migrate: applying ${file}`);
      try {
        if (inTransaction) {
          await client.query('BEGIN');
        } else {
          // A concurrent index build waits for running transactions by design.
          await client.query('SET lock_timeout = 0');
        }
        await client.query(sql);
        await client.query('INSERT INTO schema_migrations (name) VALUES ($1)', [file]);
        if (inTransaction) await client.query('COMMIT');
      } catch (err) {
        if (inTransaction) await client.query('ROLLBACK');
        throw new Error(`migration ${file} failed: ${err.message}`);
      } finally {
        if (!inTransaction) await client.query("SET lock_timeout = '5s'");
      }
    }
    console.log(`migrate: up to date (${files.length} migrations)`);
  } finally {
    await client.end(); // closing the session releases the advisory lock
  }
}

// `node migrate.js` runs the migrations on their own, e.g. from your laptop.
if (process.argv[1] === fileURLToPath(import.meta.url)) {
  migrate().catch((err) => {
    console.error(err.message);
    process.exit(1);
  });
}

Why run migrations at startup? Applications have no release (pre-deploy) command yet, so the container itself is the only place where code runs before traffic. If a migration fails, the process exits, the new release never passes its health check, and production keeps serving the previous release. You can also run npm run migrate by hand, against a local database or any database you can reach.

4. Dockerfile#

A plain production image: install exactly what the lockfile says, without dev dependencies, copy the code, and run as the unprivileged node user. node server.js is the main process, so SIGTERM reaches the handler directly; a shell wrapper such as sh -c would swallow it.

Dockerfilebash
FROM node:22-alpine
WORKDIR /app
ENV NODE_ENV=production
COPY package.json package-lock.json ./
RUN npm ci --omit=dev
COPY . .
# Run as the unprivileged user the node image ships with.
USER node
EXPOSE 3000
CMD ["node", "server.js"]

inquir apps deploy uploads the folder to the builders. It skips node_modules, .git, dist and folders whose names start with a dot, but it does not read .dockerignore. A .env file in the folder is uploaded, and only the .dockerignore keeps it out of the image. Keep real secrets out of the project folder altogether.

.dockerignorebash
node_modules
npm-debug.log
.env
.env.*
.git

5. Create, connect and deploy#

Create the application, connect the database to it, and deploy. connect writes DATABASE_URL into the service's variables; a variable reaches a container only when a new release starts, which is why the order is connect first, deploy second.

terminalbash
cd notes-api

# HTTP on port 3000, health-checked at /healthz
inquir apps create notes-api --port 3000 --health-path /healthz --project notes

# DATABASE_URL lands in notes-api's variables; the password never passes
# through your terminal
inquir apps connect notes-db notes-api
# DATABASE_URL set on notes-api
#   Applies on the next release — deploy it: inquir apps deploy notes-api

# Build the Dockerfile on the platform and start a release next to production
inquir apps deploy notes-api
#   Build succeeded in 13956ms (sha256:93fe5775df17)
# Release 4cf0e868 queued for notes-api (build 06e85ac3)
# — preview live (4s): serving its preview URL —
#   send production traffic to it: inquir apps promote 4cf0e868-ae7b-…

# Did the migrations run? Logs of this release; they follow until Ctrl-C
inquir apps logs 4cf0e868 --tail 20
# migrate: applying 001_create_notes.sql
# migrate: applying 002_add_note_summary.sql
# migrate: up to date (2 migrations)
# notes-api listening on 3000

# Production, then a public HTTPS address
inquir apps promote 4cf0e868
inquir apps update notes-api --ingress public
inquir apps status notes-api
#   production: https://notes-api-2fzzux.apps.inquir.org

deploy without --promote starts the new release next to production and waits for its health check. New applications are private, so this first release has no URL: read its logs to see the migrations run, then promote it and open public access. Once the application is public, every deploy prints a preview URL of its own that you can test before promoting.

Try it:

terminalbash
URL=https://notes-api-2fzzux.apps.inquir.org   # yours is in: inquir apps status notes-api

curl $URL/healthz
# {"status":"ok"}

curl -X POST $URL/notes -H 'content-type: application/json' \
  -d '{"title":"First note","body":"Hello from the tutorial"}'
# {"id":"1","title":"First note","body":"Hello from the tutorial","created_at":"2026-10-06T15:16:29.313Z","summary":null}

curl $URL/notes
# [{"id":"1","title":"First note",…}]

6. How the service talks to Postgres#

  • The connection string. connect writes postgresql://inquir:<password>@notes-db-<suffix>.apps.internal:5432/notes_db. It points at the private address and needs no sslmode. If the service already has a DATABASE_URL, connect refuses rather than overwrite it; pass --overwrite to replace it. inquir apps connection-url notes-db prints the URL if you need it elsewhere on the platform.
  • Pool size. Postgres allows 100 connections by default. Each replica opens up to max of them, and an application runs at most 8 replicas. During a deploy a full set of new replicas starts next to the old one, so the worst case is 16 × 5 = 80 connections, plus one for the migration runner and a few spare for you. On a 512 MB database a bigger pool does not make queries faster; it only lets more of them compete for the same CPU. Raise PG_POOL_MAX only when the metrics show requests waiting for a connection, and redo this sum when you do.
  • Timeouts. connectionTimeoutMillis fails a request after 5 s when the pool is exhausted or the database is unreachable, and statement_timeout stops a runaway query after 10 s. An error you can see beats a request that hangs until the client gives up.
  • Database restarts. A database with a volume deploys by stopping the old container before starting the new one, so expect a few seconds without it during its own deploys. The pool replaces broken connections by itself, and the error listener keeps the process alive while it does.
  • The health check tests the database. After every deploy the platform probes /healthz (any status below 500 counts as healthy, 3 s per probe, 120 s to pass) and routes traffic to the new release only after it passes. Because /healthz runs SELECT 1, a release that cannot reach the database is never promoted. Keep it that cheap: no table scans, no calls to other services. Know the trade-off: when the platform also uses the health check to replace unhealthy replicas, a database outage makes every replica fail at once, and restarting them does not bring the database back; and under heavy load a probe that waits for a free pool connection can exceed the 3 s probe timeout. If that matters more to you than a promotion gate, point the health check at a path that does not touch the database.
  • Graceful shutdown. When a release is replaced, it first stops receiving new traffic, and is later stopped with SIGTERM, followed by SIGKILL once the drain grace (--drain-grace, 10 s by default) runs out. The handler finishes in-flight requests, closes the pool so Postgres does not wait on dead sessions, and gives up after 8 s, below that grace. If you raise the grace, you can raise the deadline with it.

7. A second service on the private network#

Now the integration between services. notes-summary is a helper with no database and no public address. Create a sibling folder notes-summary with this server.js, a Dockerfile, and a package.json that only needs {"name": "notes-summary", "private": true, "type": "module"}:

notes-summary/server.jsjs
import http from 'node:http';

// A private helper service: no database, no public address. The notes API
// calls it at http://<slug>.apps.internal/summarize.
const port = Number(process.env.PORT ?? 3000);

function summarize(text) {
  const words = text.trim().split(/\s+/).filter(Boolean);
  const summary = words.slice(0, 12).join(' ') + (words.length > 12 ? '…' : '');
  return { words: words.length, summary };
}

const server = http.createServer(async (req, res) => {
  if (req.url === '/healthz') {
    res.writeHead(200).end('ok');
    return;
  }
  if (req.url === '/summarize' && req.method === 'POST') {
    let raw = '';
    req.setEncoding('utf8');
    for await (const chunk of req) {
      raw += chunk;
      if (raw.length > 64 * 1024) {
        res.writeHead(413).end('body too large');
        return;
      }
    }
    try {
      const { text = '' } = JSON.parse(raw || '{}');
      res.writeHead(200, { 'content-type': 'application/json' });
      res.end(JSON.stringify(summarize(String(text))));
    } catch {
      res.writeHead(400).end('invalid JSON');
    }
    return;
  }
  res.writeHead(404).end();
});

server.listen(port, '0.0.0.0', () => console.log(`notes-summary listening on ${port}`));
process.on('SIGTERM', () => server.close(() => process.exit(0)));
notes-summary/Dockerfilebash
FROM node:22-alpine
WORKDIR /app
COPY . .
USER node
EXPOSE 3000
CMD ["node", "server.js"]

Deploy it, find its private address, and hand the address to the API as a variable. redeploy starts a new release from the image production already runs, with the current variables, and promotes it once healthy: no rebuild needed.

terminalbash
cd ../notes-summary
inquir apps create notes-summary --port 3000 --health-path /healthz --project notes
inquir apps deploy notes-summary --promote

inquir apps status notes-summary
#   private:    http://notes-summary-v4sjsk.apps.internal

# Tell the API where it lives, then roll a release with the new variable.
# redeploy reuses the image production runs: no rebuild.
inquir apps update notes-api --set SUMMARY_URL=http://notes-summary-v4sjsk.apps.internal
inquir apps redeploy notes-api
# Release e79756d2 queued for notes-api (redeploy of 4cf0e868)
# Production now serves e79756d2

curl -X POST $URL/notes -H 'content-type: application/json' \
  -d '{"title":"Second note","body":"This one goes through the private summary service and back"}'
# {"id":"2",…,"summary":"Second note. This one goes through the private summary service and back"}
  • Addresses. Every application has a private address, http://<name>-<suffix>.apps.internal, shown by inquir apps status. For an HTTP service, port 80 on that address goes to the application's HTTP port, so the URL needs no port; TCP ports keep their own number, like Postgres on 5432. There is no short alias without the suffix, so copy the full address.
  • Private by default. Only the API needed --ingress public. Ingress decides who can reach an application from the internet; the database and the helper stay reachable only from inside the workspace.
  • Plan for the other side being down. The API calls the helper with a 2 s timeout, and if the call fails it saves the note without a summary. A slow helper must never take the API down with it.
  • Configuration, not code. The address lives in SUMMARY_URL, so the same image runs against a different helper without a rebuild. Set it with inquir apps update --set, then redeploy.

8. Migrations in production#

Everything here follows from one fact. A new release runs its migrations while the previous release is still serving, and a rollback runs the previous code on the new schema. So every version of the schema must work with two versions of the code: the one before the migration and the one after it.

  • Forward only. Never edit a migration that has run anywhere; fix it with a new file. Skip down migrations: a rollback is a forward fix you have not written yet, and a down migration that drops a column loses data.
  • Small and fast. One change per file. Migrations run inside the 120 s the release has to become healthy, so long backfills of large tables belong in a separate script that works in batches, not at startup.
  • Mind the locks. Most ALTER TABLE statements briefly lock the table; lock_timeout stops them from freezing production while they wait. Build indexes on live tables with CREATE INDEX CONCURRENTLY, alone in a file marked no-transaction:
migrations/003_index_notes_created_at.sqlbash
-- migrate:no-transaction
CREATE INDEX CONCURRENTLY notes_created_at_idx ON notes (created_at DESC);

If a concurrent build fails, Postgres leaves an invalid index behind and the file is not recorded, so the next deploy runs it again and stops at already exists. Because that file never ran successfully, you may change it: replace its statement with DROP INDEX CONCURRENTLY IF EXISTS notes_created_at_idx;, deploy, and then create the index again in a new file.

  • Nothing destructive in the same deploy. Dropping or renaming a column that the running code still uses breaks production the moment the new release starts.

Expand and contract#

Changes that are not purely additive are split across deploys. Renaming title to heading takes four, and at every step both the release in production and the one you might roll back to work with the schema:

expand / contractbash
-- Renaming notes.title to notes.heading without downtime: four deploys.

-- Deploy 1, expand. Code writes title AND heading, reads COALESCE(heading, title).
-- migrations/004_add_heading.sql
ALTER TABLE notes ADD COLUMN heading text;

-- Deploy 2, backfill. Code reads heading, still writes both columns.
-- migrations/005_backfill_heading.sql
UPDATE notes SET heading = title WHERE heading IS NULL;

-- Deploy 3. Code no longer touches title at all.
-- migrations/006_title_nullable.sql
ALTER TABLE notes ALTER COLUMN title DROP NOT NULL;

-- Deploy 4, contract. Nothing running reads or writes title any more.
-- migrations/007_drop_title.sql
ALTER TABLE notes DROP COLUMN title;

Each comment describes the code that ships with that migration. Wait between deploy 3 and deploy 4 until you are sure you will not roll back past deploy 3. Adding a column, a table or an index is a one-step expand with no contract.

Rolling back#

inquir apps rollback notes-api moves production back to the previous release without rebuilding anything. It restores code, not the schema, and that is safe precisely because of the rule above. The runner ignores migrations that are recorded in the database but missing from the older code, so the older release starts normally. If a migration itself was wrong, write a new one that fixes it and deploy that.

Backups before risky changes#

inquir apps backups notes-db create snapshots a database's volume and restore brings a snapshot back. Be aware of a current limitation: a database whose volume lives on a worker runner, which is where new template databases are placed on inquir.org today, answers that backups are not supported there yet. The console (inquir apps exec) and public TCP ports are not available for such databases either, so for now there is also no way to run pg_dump against it from outside.

Until managed backups reach these databases, let the expand and contract pattern protect you: no data disappears before the contract step, and the contract step can keep a copy of what it drops, inside the same database:

migrations/007_drop_title.sqlbash
-- The contract step, with a safety copy. Drop the copy in a later
-- migration, once you are sure you will not need it.
CREATE TABLE notes_title_backup AS SELECT id, title FROM notes;
ALTER TABLE notes DROP COLUMN title;

Secrets and configuration#

DATABASE_URL comes from connect, and other secrets go in the same way: inquir apps update notes-api --set API_KEY=…, followed by inquir apps redeploy notes-api. Values are stored encrypted and never appear in build logs. Never put secrets in the Dockerfile (ENV and ARG end up in the image) or in the project folder.

Local development#

Run the same Postgres major version locally with Docker Compose, point DATABASE_URL at it, and start the service. The same migrations run on every start, so your local schema always matches what production gets on the next deploy.

compose.yamlbash
# The same Postgres major version as the template
services:
  db:
    image: postgres:16-alpine
    environment:
      POSTGRES_USER: notes
      POSTGRES_PASSWORD: notes
      POSTGRES_DB: notes
    ports:
      - "5432:5432"
    volumes:
      - pgdata:/var/lib/postgresql/data
volumes:
  pgdata:
terminalbash
# In the notes-api folder, next to compose.yaml
docker compose up -d
export DATABASE_URL=postgresql://notes:notes@localhost:5432/notes
npm install
npm start          # waits for Postgres, migrates, listens on :3000
npm run migrate    # or only the migrations

docker compose down -v deletes the local data when you want to replay every migration from scratch, which is a good test before a deploy.

When release commands arrive#

A release (pre-deploy) command for applications is planned; once it exists, node migrate.js moves there and runs once per deploy before any replica starts, while every rule on this page stays the same.

9. Operate it#

The commands you will use day to day:

terminalbash
inquir apps status notes-api                   # releases, private and public addresses
inquir apps logs notes-api --tail 100          # live output; follows until Ctrl-C
inquir apps log-history notes-api --since 1h   # persisted logs, older releases too
inquir apps metrics notes-api                  # CPU, RAM, network per replica
inquir apps scale notes-api always --replicas 2
inquir apps rollback notes-api                 # previous release, no rebuild

Troubleshooting#

  • runtime.healthcheck.path: Invalid input: must start with "/" on Windows. Git Bash rewrites /healthz into a Windows path before the CLI sees it. Prefix the command with MSYS_NO_PATHCONV=1, or run it from PowerShell.
  • connect says the database has no verified private address yet. The platform verifies the private route once the database serves and re-checks about every 20 seconds. Wait a minute; when inquir apps status notes-db shows a private: address, run connect again.
  • The service exits with DATABASE_URL is not set. Variables reach only releases started after the change. Run connect, then deploy or redeploy.
  • The first deploy prints no reachable endpoint and no URL. The application is private. Check inquir apps logs, promote, then run inquir apps update notes-api --ingress public.
  • inquir apps logs never returns. It follows the stream until you press Ctrl-C. inquir apps log-history notes-api --since 1h prints stored logs and exits; --since accepts 1h, 6h, 1d, 7d or 30d.
  • The release never becomes healthy after a schema change. Look for migration … failed in the logs of that release, inquir apps logs <releaseId>, or inquir apps log-history notes-api --release <full-release-id> (plain inquir apps logs notes-api follows production). Production is still on the previous release. An ordinary file was rolled back and not recorded, so fix it and deploy again; for a failed no-transaction file, see the note on concurrent indexes in section 8. canceling statement due to lock timeout means a busy table: deploy again at a quieter moment.
  • inquir apps exec answers Console for applications running on a remote worker is not available yet. The console does not reach applications on worker runners yet, so one-off tasks cannot be run inside the container for now. This is one more reason the migrations run at startup. Note that exec takes the application name, not a release id.
  • sorry, too many clients already. Replicas times PG_POOL_MAX exceeds what Postgres allows. Lower the pool size or the number of replicas.

Clean up#

Delete the services that use the database first: a database refuses to be deleted while other applications are connected to it. delete stops every release at once; the application and its volume are purged for good a day later. Archive the empty project in the dashboard.

terminalbash
# The apps that use the database go first
inquir apps delete notes-api --yes
inquir apps delete notes-summary --yes
inquir apps delete notes-db --yes

Where to go next#

The Applications reference covers ports, volumes, scaling and the API behind every command, and Environment and secrets goes deeper into variables. For domains and previews in the dashboard, see Deploy an application; every flag is in the CLI reference.