Node.js drivers_
Connect to an Appwrite native PostgreSQL database from Node.js with node-postgres or postgres.js. Configure pools, TLS verification, serverless connection management, and troubleshooting.
6 min read
A native PostgreSQL database is a standard PostgreSQL engine, so any Node.js driver that speaks the PostgreSQL wire protocol can connect over TLS without an Appwrite-specific adapter. The Connections page shows the minimal snippet to run your first query. This page covers production pool configuration, certificate verification, serverless connection management, and common connection errors.
You'll need a native PostgreSQL database in a ready state and its credentials. In the Console, open the database and click Credentials. Use the Details tab for individual values, or the DSN, .env, Prisma, Drizzle, or psql tab for a ready-made snippet. The primary user is admin, and the database name is generated for each database. Keep the password in an environment variable and never commit it.
Raw driver, ORM, or SQL API?
There are three common ways to reach a native PostgreSQL database from Node.js. Pick by workload:
| Approach | Use it when |
|---|---|
| Raw driver (this page) | You want a connection pool you control, hot-path queries, or a thin data layer with little overhead. |
| ORM (Prisma, Drizzle) | You want migrations, a typed schema, and query building. The ORM still uses a PostgreSQL driver underneath. |
| SQL API | You're on an edge runtime that cannot hold a TCP socket, or scripting a one-off query over HTTPS. |
The rest of this page is about raw drivers on a long-running or serverless Node.js server that can open TCP connections.
node-postgres (pg)
For a long-running server, create one Pool at startup and reuse it for every request. The pool opens connections lazily up to max and hands them back to your handlers.
import { Pool } from 'pg';import process from 'node:process';
const pool = new Pool({ host: 'db-<hash>.<region>.appwrite.center', port: 5432, user: 'admin', password: process.env.DB_PASSWORD, database: '<database>', ssl: { rejectUnauthorized: true }, max: 10, idleTimeoutMillis: 30000, connectionTimeoutMillis: 5000,});
const { rows } = await pool.query('SELECT id, email FROM users WHERE id = $1', [userId]);Pool sizing is a budget. Each connection in max, across every running instance, counts against your specification's connection cap. A single app server with max: 10 is fine; ten replicas with max: 50 each is 500 connections and can exhaust many specifications. Size max to the connection cap divided by replica count, then put the connection pooler in front if you need more client concurrency than that allows.
ssl: { rejectUnauthorized: true } validates the server certificate against Node's built-in CA store. The edge proxy presents a certificate signed by a well-known public CA, so this works without supplying your own bundle. See TLS and CA verification for custom CA bundles and mTLS.
Pooled port vs direct port
The pooler defaults to transaction mode, which does not hold a backend connection across statements, so server-side prepared statements are unavailable. In node-postgres, a query becomes a server-side prepared statement only when you pass a name field on the query config:
// Safe on the pooler (port 6432, transaction mode): no `name`, no server-side prepared statement.await pool.query('SELECT * FROM events WHERE user_id = $1', [userId]);
// Use the direct port or session-mode pooler for named prepared statements.await pool.query({ name: 'fetch-events', text: 'SELECT * FROM events WHERE user_id = $1', values: [userId] });Parameterized queries without a name are sent fresh each time and work on the pooled port. If you rely on named prepared statements, advisory locks, LISTEN/NOTIFY, temporary tables, or SET LOCAL, connect on the direct port (5432) or switch the pooler to session mode. See the pooler modes page for the trade-offs. Run migrations on the direct port.
postgres.js
postgres.js (the postgres package) takes its own option names. Create the client once at module scope:
import postgres from 'postgres';import process from 'node:process';
const sql = postgres({ host: 'db-<hash>.<region>.appwrite.center', port: 5432, username: 'admin', password: process.env.DB_PASSWORD, database: '<database>', ssl: 'verify-full', max: 10, idle_timeout: 30, connect_timeout: 10,});
const users = await sql`SELECT id, email FROM users WHERE id = ${userId}`;ssl: 'verify-full' enables TLS and verifies the server certificate against the system CA store. If your runtime image ships without one, pass an object with a public CA bundle instead: ssl: { rejectUnauthorized: true, ca: fs.readFileSync('./ca-bundle.crt') }.
When you point postgres.js at the pooler port (6432) in transaction mode, disable prepared statements. The library uses prepared statements by default, and documents prepare: false for PgBouncer transaction mode:
const sql = postgres({ host: 'db-<hash>.<region>.appwrite.center', port: 6432, username: 'admin', password: process.env.DB_PASSWORD, database: '<database>', ssl: 'verify-full', prepare: false, max: 5,});Leave prepare at its default when you connect on the direct port or use the pooler in session mode.
TLS and CA verification
Connections on Appwrite Cloud are encrypted with TLS, terminated at the edge and forwarded to your database over the internal network. The connection string from the credentials dialog carries the right SSL settings for your environment, and the examples above use strict certificate verification.
| Level | node-postgres | postgres.js |
|---|---|---|
| Encrypt and verify CA | ssl: { rejectUnauthorized: true } | ssl: 'verify-full' |
| Custom CA bundle (no system trust store) | ssl: { rejectUnauthorized: true, ca: fs.readFileSync('./ca-bundle.crt') } | ssl: { rejectUnauthorized: true, ca: fs.readFileSync('./ca-bundle.crt') } |
The certificate behind every database hostname is signed by a well-known public CA, so Appwrite does not require a custom CA download. The ca option is useful for runtimes without a system trust store, such as distroless or scratch images. Point it at a standard public CA bundle from your base image's ca-certificates package or Mozilla's bundle:
import fs from 'node:fs';
const ssl = { rejectUnauthorized: true, ca: fs.readFileSync('./ca-bundle.crt'),};When the CA comes from an environment variable, restore the newlines the variable strips:
const ssl = { rejectUnauthorized: true, ca: process.env.DB_SSL_CA?.replace(/\\n/g, '\n'),};Do not ship rejectUnauthorized: false to production. It disables certificate validation and exposes the connection to interception. To require mTLS, where the client also presents a certificate, see the Network security page. Then add key and cert alongside ca in the same ssl object.
Serverless connection management
On Lambda, Cloud Run, Vercel, and similar platforms, every cold start is a fresh instance with its own pool. A max: 10 pool times 200 concurrent instances is 2,000 backend connections, which is more than most specifications allow. Let Appwrite's pooler absorb the fan-out:
- Connect on the pooler port (
6432) in transaction mode. - Keep the per-instance pool tiny, usually
max: 1or2. One invocation rarely needs more than one connection at a time. - Create the client once per module scope so warm invocations reuse it.
- Disable per-connection prepared statements on the pooled port:
prepare: falsefor postgres.js, and avoidnameon node-postgres query configs.
import postgres from 'postgres';import process from 'node:process';
// Module scope: reused across warm invocations.const sql = postgres({ host: 'db-<hash>.<region>.appwrite.center', port: 6432, username: 'admin', password: process.env.DB_PASSWORD, database: '<database>', ssl: 'verify-full', prepare: false, max: 1,});
export async function handler(event) { const rows = await sql`SELECT id FROM users WHERE email = ${event.email}`; return rows[0] ?? null;}On edge runtimes (Cloudflare Workers, Vercel Edge, Deno Deploy), raw TCP sockets are usually unavailable. Use the SQL API for these workloads.
Troubleshooting
| Symptom | Cause and fix |
|---|---|
self-signed certificate / unable to verify the first certificate | TLS is reaching the wrong host, or the runtime has a stale or incomplete ca bundle. The proxy's certificate is signed by a public CA. Use driver certificate verification with the system trust store, and supply a current public CA bundle only when the runtime has no system trust store. |
sorry, too many clients already / connection attempts rejected at the proxy | Aggregate pool size across all instances exceeds the database specification's connection cap. Lower max, or move to the pooler port and shrink the per-instance pool to 1 or 2. |
prepared statement "S_1" does not exist / unnamed prepared statement does not exist | Server-side prepared statements on the pooler in transaction mode. Set prepare: false for postgres.js, drop the name field for node-postgres, or use the direct port or session mode. |
ETIMEDOUT / connection timed out on connect | The database may be cold-starting on smaller specifications or blocked by an IP allowlist. Confirm your egress IP is allowed and raise connectionTimeoutMillis or connect_timeout to absorb cold starts. |
| Connections drop after a period of inactivity | The proxy closes idle connections after networkIdleTimeoutSeconds. Keep idleTimeoutMillis and idle_timeout below that window so the driver recycles before the proxy does, or enable keep-alive. |
Related
Connections
Retrieve credentials, rotate the password, and create scoped connection users.
Connection pooler
Pool modes, ports, and read/write splitting for serverless workloads.
SQL API
Run SQL over HTTPS with no TCP connection for edge runtimes and scripts.
Network security
TLS, certificate verification, mTLS, and IP allowlists.
Was this page helpful?
Share what worked or what we should fix. Once approved, our agents automatically apply suggested updates to the docs.