For AI agents: the complete documentation index is available at https://dotpress.dev/llms.txt, the full documentation bundle is available at https://dotpress.dev/llms-full.txt, and this page is available as Markdown at https://dotpress.dev/deployment/node.md.

Node.js and containers

A Dotpress app on Node.js is a long-running process: a container, a VM, or a platform that runs one (Cloud Run, Fly.io, Railway, Render, Kubernetes…). This page covers what to set up before going to production.

Run TypeScript

Node.js 22.18 or later runs TypeScript files directly: node src/server.ts, without a build step. The examples run this way in production too. Node.js only strips the types, so the code must use erasable syntax only, and relative imports end with .ts (see Installation).

You can also compile with tsc or a bundler, and run the JavaScript: Dotpress works the same.

Configuration

Dotpress never reads environment variables: the configuration is yours. Read it once, at startup, and check it with Zod: a missing or invalid variable stops the process with an explicit message, instead of failing later on a request.

import { z } from 'zod'

// Every setting of the app, from the environment, checked at startup: a
// missing or invalid variable stops the process with an explicit message,
// instead of failing later on a request.
const Env = z.object({
  NODE_ENV: z
    .enum(['development', 'test', 'production'])
    .default('development'),
  PORT: z.coerce.number().int().min(0).max(65_535).default(3000),
  /** SQLite database file, created when missing. */
  DATABASE_PATH: z.string().min(1).default('data/tasks.db'),
  /** Public URL of the API, written in the tokens (`iss`). */
  PUBLIC_URL: z.url().optional(),
  /** RSA key pair (PEM); generated in `data/` in development. */
  JWT_PRIVATE_KEY: z.string().min(1).optional(),
  JWT_PUBLIC_KEY: z.string().min(1).optional(),
  /**
   * Redis: the queue of the events (BullMQ) and a shared cache. Without
   * it, the listeners run in the API process, and the cache lives in
   * memory.
   */
  REDIS_URL: z.url().optional(),
  /** Name of the BullMQ queue, shared by the API and the worker. */
  EVENTS_QUEUE: z.string().min(1).default('tasks-api-events'),
  /** SMTP server of the emails. Without it, they are printed. */
  SMTP_URL: z.url().optional(),
  MAIL_FROM: z.string().min(1).default('Tasks <noreply@example.com>'),
})

export type Config = ReturnType<typeof loadConfig>

/** Reads the configuration, or throws an error listing the problems. */
export const loadConfig = (
  env: Record<string, string | undefined> = process.env
) => {
  const result = Env.safeParse(env)
  if (!result.success) {
    throw new Error(`Invalid configuration:\n${z.prettifyError(result.error)}`)
  }
  const config = result.data
  return {
    ...config,
    production: config.NODE_ENV === 'production',
    publicUrl: config.PUBLIC_URL ?? `http://localhost:${config.PORT}`,
  }
}

/**
 * Checks the variables a process needs in production: the API and the
 * worker do not need the same ones.
 */
export const requireInProduction = (
  config: Config,
  names: readonly (keyof Config & Uppercase<string>)[]
) => {
  const missing = names.filter((name) => !config[name])
  if (config.production && missing.length > 0) {
    throw new Error(`Required in production: ${missing.join(', ')}.`)
  }
}

Each process declares what it needs in production: the API needs the keys of the tokens, the worker does not.

const config = loadConfig()
requireInProduction(config, ['PUBLIC_URL', 'JWT_PRIVATE_KEY', 'JWT_PUBLIC_KEY'])

Keep the secrets (keys, passwords, tokens) in the secret manager of your platform, given to the process as environment variables. Never in the image, never in the repository.

Graceful shutdown

Platforms stop a container with SIGTERM, then kill it after a delay (10 seconds for Docker, 30 for Kubernetes). On SIGTERM, call app.close(): it stops accepting connections, waits for the pending requests, closes the streamed responses, then runs the onClose hooks of the plugins. Close your own connections afterwards.

// Entry point of the API: config → services → app → listen.
import { fastifyAdapter } from '@dotpress/adapter-fastify'
import { createConsoleLogger } from '@dotpress/core'
import { loadConfig, requireInProduction } from '../config.ts'
import { openDatabase } from '../db/database.ts'
import { loadKeys } from '../features/auth/keys.ts'
import { createNotifications } from '../features/notifications/listeners.ts'
import { createBus, createTransport } from '../services/bus.ts'
import { createCacheStore } from '../services/cache-store.ts'
import { createRepositories } from '../services/index.ts'
import { createAppMailer, createMailProvider } from '../services/mailer.ts'
import { createTasksApp } from './app.ts'

const config = loadConfig()
requireInProduction(config, [
  'PUBLIC_URL',
  'JWT_PRIVATE_KEY',
  'JWT_PUBLIC_KEY',
  // Without Redis, the API sends the emails itself.
  ...(config.REDIS_URL ? [] : (['SMTP_URL'] as const)),
])
const logger = createConsoleLogger()

// The services of this process, created once, closed on shutdown.
const repositories = createRepositories(openDatabase(config.DATABASE_PATH))
const cacheStore = await createCacheStore(config.REDIS_URL)
const mailProvider = createMailProvider(config)
const transport = createTransport(config)
if (transport.kind === 'memory') {
  logger.info('REDIS_URL is not set: the listeners run in this process')
} else if (mailProvider.name === 'console') {
  logger.info('SMTP_URL is not set: the worker prints the emails')
}

const app = await createTasksApp({
  adapter: fastifyAdapter(),
  repositories,
  keys: loadKeys(config),
  publicUrl: config.publicUrl,
  cacheStore: cacheStore.store,
  // The bus routes each event to its listeners, by name: with BullMQ, the
  // worker runs them; without, the in-memory queue of this process.
  events: createBus(
    transport,
    createNotifications({
      repositories,
      mailer: createAppMailer(mailProvider, config.MAIL_FROM),
    }),
    { logger }
  ),
  logger,
})
await app.listen(config.PORT)
logger.info(`Tasks API: ${config.publicUrl}`)
logger.info(`API reference: ${config.publicUrl}/docs`)

// Waits for pending requests, then closes the connections.
for (const signal of ['SIGINT', 'SIGTERM'] as const) {
  process.once(signal, () => {
    void app
      .close()
      .then(() => transport.close())
      .then(() => cacheStore.close())
      .then(() => {
        mailProvider.close?.()
        repositories.db.close()
      })
  })
}

Without it, a deployment cuts the requests in progress.

Health checks

Give the platform a route to check that the app is alive, and that it can reach its database:

const healthRouter = createRouter()

healthRouter.defineRoute({
  path: '/health',
  meta: { summary: 'Health check', tags: ['health'] },
  handler: ({ services }) => {
    services.db.prepare('SELECT 1').get()
    return { status: 'ok' }
  },
})

A failing check returns a 500, and the platform stops sending traffic to the instance. Keep it cheap: it runs every few seconds.

Behind a proxy

In production, a load balancer or a reverse proxy usually sits in front of the app, and terminates HTTPS:

  • Client IP addresses: configure the server to trust the proxy headers, or every client shares the address of the proxy, including in the login throttling. See Client IP address.
  • Sub-path: when the app is served under a prefix (/api), use basePath or publicBasePath. See Base path.
  • Request IDs: to keep the ID set by the proxy, use generateRequestId. See Request IDs.
  • Timeouts: the proxy closes slow requests (often after 60 seconds), and idle connections. Server-Sent Events send a heartbeat every 15 seconds to stay open.

Logs

Write the logs to the standard output, as JSON, and let the platform collect them. Pino does it, and works as is with Dotpress:

import pino from 'pino'

const app = await createApp({ adapter, logger: pino() })

Each line of a request carries its requestId, also sent to the client in X-Request-ID. See Logging.

Several instances

With several instances behind a load balancer, keep every state in shared services:

StateIn memory, per instanceShared
Sessions—the refresh tokens, in your database
Login throttlingthe default storea shared store (Redis, Postgres)
CachecreateMemoryStore()createRedisStore(): an invalidation must reach every instance
Event queuecreateMemoryTransport()BullMQ, SQS, pg-boss…
Revoked sessions—isSessionRevoked, on the database or Redis

Containers

A Dockerfile for an app that runs its TypeScript sources:

Dockerfile
FROM node:24-slim
WORKDIR /app
ENV NODE_ENV=production

# Dependencies first: this layer is rebuilt only when they change.
COPY package.json package-lock.json ./
RUN npm ci --omit=dev

COPY src ./src

USER node
EXPOSE 3000
CMD ["node", "src/server.ts"]
.dockerignore
node_modules
.env*
test
*.log
  • npm ci installs the exact versions of the lockfile; --omit=dev leaves the test tools out.
  • USER node: the process does not run as root.
  • CMD ["node", …] in the exec form: Node.js receives SIGTERM directly, and the app closes gracefully. Run node rather than npm start: one process less between the platform and your app.
  • Pick the current LTS release of Node.js for the image.

An event worker can use the same image, with another command: CMD ["node_modules/.bin/dotpress-events", "work", "src/worker.ts"].

Before going to production

  • The configuration is checked at startup, and the secrets come from a secret manager.
  • SIGTERM closes the app gracefully.
  • A health check route is configured on the platform.
  • The server trusts the proxy headers, and only those of your proxy.
  • Logs are JSON, on the standard output.
  • State is shared between the instances.
  • exposeErrors is disabled, and unexpected errors are reported with onError.
  • The security checklist of the authentication is done.