deployment overview
A Psychic application runs multiple process roles from a single built image. Each role has its own entrypoint:
| Role | Entrypoint | Purpose |
|---|---|---|
| web | node ./dist/src/main.js | HTTP API server |
| websocket | node ./dist/src/ws.js | WebSocket server (if using @rvoh/psychic-websockets) |
| worker | node ./dist/src/worker.js | Background job processor (if using @rvoh/psychic-workers) |
| console / migrator | node ./dist/src/conf/repl.js · node ./dist/src/conf/system/cli.js db:migrate | Dream console · database migrations and CLI tasks |
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.
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:
| Provider | Required 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 TLS | ssl: false or DB_NO_SSL=1 |
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:
| API | WebSocket (default) | |
|---|---|---|
| Path | /health_check (you define it) | /healthcheck |
| HTTP methods | GET and HEAD | GET 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:
- Node boot on the deployed image.
loadEnvresolves the environment, including any external-secret injection wired throughAppEnv.- Every required variable that boot reads through
AppEnv(conf/app.ts,conf/dream.ts, initializers) must be set, or boot throws naming it. - Models, serializers, controllers, services and initializers load, so their import-time errors surface.
- 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:
- Check the public endpoint — is it returning the expected status code?
- Check health check status — is the target healthy from the load balancer/ingress perspective?
- Check orchestrator events — container restarts, OOM kills, failed deployments.
- Check the running configuration — correct image, correct command, correct environment variables, correct log destination.
- Check application logs — look for startup errors, missing env vars, failed migrations.
- Verify HTTP method — if health checks pass but manual verification fails, confirm you're using
GET(notHEAD) for WebSocket endpoints.
See the AWS guide for a complete example of provisioning a Psychic application on AWS Fargate.