Skip to main content

deployment overview

A Psychic application runs multiple process roles from a single built image. Each role has its own entrypoint:

RoleEntrypointPurpose
webnode ./dist/src/main.jsHTTP API server
websocketnode ./dist/src/ws.jsWebSocket server (if using @rvoh/psychic-websockets)
workernode ./dist/src/worker.jsBackground job processor (if using @rvoh/psychic-workers)
console / migratornode ./dist/src/conf/repl.js · node ./dist/src/conf/system/cli.js db:migrateDream console · database migrations and CLI tasks
warning

Do not rely on pnpm (or any package manager runner) in production containers. Use direct node ./dist/... commands in your deployment configuration (task definitions, Procfiles, Docker CMD, etc.).

Infrastructure requirements​

At minimum, you need:

  • a Postgres database
  • a server instance running Node.js 24 or later
  • a load balancer
  • a way to provision and rotate secrets securely (e.g. AWS Secrets Manager)

If using websockets or background workers:

  • a Redis 7.2.0+ instance

If using background workers:

  • worker instances running Node.js

Environment variables​

Always use AppEnv (defined in src/conf/AppEnv.ts) to access environment variables — never process.env directly. AppEnv throws MissingRequiredEnvironmentVariable, naming the variable, when a required one is read while unset — at boot for anything conf/app.ts, conf/dream.ts or an initializer reads — instead of handing undefined on. This also decouples the application from the container environment: secrets can be loaded from AWS Secrets Manager or AWS SSM Parameter Store at boot time and injected into AppEnv before anything else runs, so the container itself doesn't need every secret baked into its environment, and secrets can be rotated without redeploying.

For variables that are only present in some environments, use AppEnv.string(name, { optional: true }), which returns string | undefined instead of throwing.

warning

Every new environment variable name must be registered in AppEnv.ts's typed union before AppEnv.string(name) (or .boolean / .integer) will compile for it. Adding an env var is a two-part change: add the name to the appropriate union, then read it through AppEnv. The gotcha is where the omission surfaces — a Vitest run (pnpm uspec) doesn't type-check, so a spec exercising the new variable passes green, and the missing-name error only appears at the pnpm build:spec type-check gate. Add the union entry in the same change; pnpm build:spec is what catches the omission.

Build output​

If the project uses TypeScript path aliases (e.g. @app/*), the production build must run tsc-alias after tsc. Without this step, compiled JS will contain unresolved path alias imports and fail at runtime.

TLS​

TLS behind a reverse proxy​

The generated conf/app.ts serves TLS in production:

// conf/app.ts

if (AppEnv.isProduction) {
psy.set('ssl', {
key: AppEnv.string('SSL_KEY_PATH'),
cert: AppEnv.string('SSL_CERT_PATH'),
})
}

The web and websocket servers then listen with HTTPS, and boot throws unless both variables are set. That fits a reverse proxy (load balancer, ingress controller) that terminates TLS and re-encrypts traffic to the container. The key and certificate are a self-signed pair, separate from the certificate on the proxy; bake them into the image rather than generating them at runtime.

If the proxy forwards plain HTTP to the container instead, delete the psy.set('ssl', …) block. Psychic marks the cookies it sets Secure in production, so an app that sets cookies then also needs koaApp.proxy = true, or startSession and setCookie answer 500. Set koaApp.proxy only when nothing but the proxy can reach the container (see proxy configuration for secure cookies); otherwise keep the block and have the proxy re-encrypt.

Postgres TLS​

Configure TLS posture explicitly in conf/dream.ts via the ssl field on each credential. The scaffolded boilerplate uses verified TLS by default:

const dbSsl: { rejectUnauthorized: true } | false = AppEnv.boolean('DB_NO_SSL')
? false
: { rejectUnauthorized: true }

Provider reference:

ProviderRequired ssl value
Supabase, Neon, Render, Azure Database for PostgreSQL Flexible Server{ rejectUnauthorized: true } (default)
AWS RDS{ rejectUnauthorized: true, ca: readFileSync('rds-ca.pem') }
GCP Cloud SQL{ rejectUnauthorized: true, ca: readFileSync('server-ca.pem') }
Heroku Hobby, some local Docker images{ rejectUnauthorized: false }
Local dev with no TLSssl: false or DB_NO_SSL=1
warning

Setting app.set('db', ...) throws MissingDbSslDirective at setter time — in every environment, not just production — if a credential does not set ssl, so a deploy can't silently boot with an unspecified TLS posture.

Short-lived passwords​

A short-lived password, such as an AWS RDS IAM auth token, fetched once at boot stops working when it expires, and every new pool connection opened after that fails authentication. Set password on primary and any replica to a function returning the token or a promise of it, so each new connection gets a fresh token:

// conf/dream.ts

import { Signer } from '@aws-sdk/rds-signer'

const signer = new Signer({
hostname: AppEnv.string('DB_HOST'),
port: AppEnv.integer('DB_PORT'),
username: AppEnv.string('DB_USER'),
})

app.set('db', {
primary: {
// ...user, host, name, port, ssl
password: () => signer.getAuthToken(),
},
})

A replica on another host gets a Signer of its own.

See conf/dream.ts — database connection for the full credential shape.

Read replicas​

app.set('db', { primary, replica }) accepts an optional replica credential alongside primary, using the same shape (host, port, user, password, name, ssl) pointed at your read replica instance. The generated conf/dream.ts already passes one whenever REPLICA_DB_HOST is set: set it to your replica's host, and REPLICA_DB_PORT if the replica's port differs from DB_PORT.

Configuring replica only makes the connection available — it doesn't route any traffic there by itself. A model reads from the replica only when it's decorated @ReplicaSafe() or a call asks for .connection('replica'), and only for select queries outside a transaction (joins fall back to primary, and create/update/destroy always go to primary).

Omit replica (or pass undefined) for environments with no replica — every model, @ReplicaSafe() or not, then reads from primary.

Health checks​

The API server needs an explicit health check route. The generated conf/routes.ts carries one, commented out; uncomment it and adjust the path and body to what your infra expects:

// conf/routes.ts

r.get('health_check', ctx => {
ctx.status = 200
ctx.body = { ok: true }
})

The @rvoh/psychic-websockets server answers its own built-in health check, configured with wsApp.set('healthCheck', …). Its defaults differ from the API route:

APIWebSocket (default)
Path/health_check (you define it)/healthcheck
HTTP methodsGET and HEADGET only (one configurable method)

curl -I (which sends HEAD) works against the API health check but returns 404 from a default WebSocket health check; use curl -X GET there.

Running migrations in production​

The migrate task is the boot smoke test​

Running node ./dist/src/conf/system/cli.js db:migrate exercises the Psychic boot chain before applying any migrations:

  1. Node boot on the deployed image.
  2. loadEnv resolves the environment, including any external-secret injection wired through AppEnv.
  3. Every required variable that boot reads through AppEnv (conf/app.ts, conf/dream.ts, initializers) must be set, or boot throws naming it.
  4. Models, serializers, controllers, services and initializers load, so their import-time errors surface.
  5. A database connection is established using the configured credentials, including TLS negotiation.

Only then does Kysely start applying pending migrations. Any failure in steps 1–5 fails the migrate task before the schema changes — meaning a separate container "boot smoke" task is redundant. The migrate task itself is the smoke test, and it covers strictly more: missing env vars, registry-time import errors, malformed DB credentials, TLS handshake failures, and network reachability all surface here.

db:migrate boots with integrity checks bypassed, so route building, the OpenAPI document, and the check that every model's table is in the schema run only when the web, websocket and worker processes boot.

Combine db:migrate and db:seed in one invocation​

Migrate and seed share the same image, execution role, env, and DB credentials. On serverless container platforms where each one-off task pays a fixed scheduling and image-pull overhead, invoking them as two separate tasks pays the per-task lifecycle twice for no reason. Combine them in a single container invocation:

sh -c "node ./dist/src/conf/system/cli.js db:migrate && node ./dist/src/conf/system/cli.js db:seed"

If migrate fails, seed never runs and the task exits non-zero. If migrate succeeds and seed fails, the task exits non-zero with seed's error.

Dropping a column takes two deploys​

A rolling deploy runs the migration while containers built from the previous image are still serving traffic. If that migration drops a column, containers still running the old code can fail mid-request the moment the column disappears out from under them. Dropping a column is therefore a two-deploy process: first deploy declares the column ignored in the model (so the old code stops depending on it while the column still exists), and only the following deploy's migration actually drops it.

Debugging a deployed service​

When a deployed Psychic service is not responding correctly:

  1. Check the public endpoint — is it returning the expected status code?
  2. Check health check status — is the target healthy from the load balancer/ingress perspective?
  3. Check orchestrator events — container restarts, OOM kills, failed deployments.
  4. Check the running configuration — correct image, correct command, correct environment variables, correct log destination.
  5. Check application logs — look for startup errors, missing env vars, failed migrations.
  6. Verify HTTP method — if health checks pass but manual verification fails, confirm you're using GET (not HEAD) for WebSocket endpoints.
tip

See the AWS guide for a complete example of provisioning a Psychic application on AWS Fargate.