Docs
Skip to content

PostgreSQL

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

Raw

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.

Raw driver, ORM, or SQL API?

There are three common ways to reach a native PostgreSQL database from Node.js. Pick by workload:

ApproachUse 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 APIYou'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.

JavaScript
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:

JavaScript
// 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:

JavaScript
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:

JavaScript
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.

Levelnode-postgrespostgres.js
Encrypt and verify CAssl: { 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:

JavaScript
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:

JavaScript
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: 1 or 2. 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: false for postgres.js, and avoid name on node-postgres query configs.
JavaScript
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

SymptomCause and fix
self-signed certificate / unable to verify the first certificateTLS 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 proxyAggregate 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 existServer-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 connectThe 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 inactivityThe 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.

Was this page helpful?

Share what worked or what we should fix. Once approved, our agents automatically apply suggested updates to the docs.