---
title: "k8s-postgres-fcm Mode"
description: "Recommended production backend — PostgreSQL 16 + PostGIS for all application data, Firebase Admin SDK only for FCM push and Apple Push. Single shared connection pool."
locale: "en"
---
# k8s-postgres-fcm Mode

> **Success**
> **k8s-postgres-fcm** is the recommended backend mode for production and local development. PostgreSQL handles all application data through one shared adapter pool; Firebase Admin SDK is initialized exclusively for FCM push and Apple Push. Use the **Founder** / **Developer** tabs in the docs sidebar to filter this page.

### For founders

## What k8s-postgres-fcm gives you

- **Full ownership of application data** — stored in PostgreSQL 16 on your infrastructure. No Firestore reads or writes.
- **Predictable connection usage** — every feature module shares one adapter pool instead of opening private connections. Stable Postgres load as your clone grows.
- **Zero Firebase vendor lock for data** — migration or provider change touches only the database connection string, not application code.
- **Optional push notifications** — FCM and Apple Push remain available without exposing your database to Firebase.
- **Auth.js v5 integration** — authentication tables live in PostgreSQL alongside business data.
- **PostGIS support** — spatial queries for delivery radius, entity locations, event venues, and map-heavy clones.
- **Native WebSocket transport** — tunnel uses native WSS via `TUNNEL_HUB_MODE=k8s-postgres` (or in-memory hub for local development).

## When to choose this mode

| Scenario | Recommended mode |
|----------|-----------------|
| Self-hosted production on k3s | **k8s-postgres-fcm** |
| Local development with PostgreSQL | **k8s-postgres-fcm** |
| Cloud PostgreSQL (no K8s) | supabase-fcm |
| Rapid prototyping / Vercel Edge | firebase-full |
| Push notifications needed | any mode — FCM is optional in all three |

## Cost profile

- **PostgreSQL** — runs on your own infrastructure (Kubernetes StatefulSet, bare metal, or Docker). No per-query cost.
- **Firebase FCM** — push notifications are free at any scale. Firebase project credentials are required only if you enable push.
- **No Firestore costs** — Firestore is never used for application data in this mode. `getAdminDb()` returns a mock that never connects.

### For developers

## Architecture

```
┌─────────────────────────────────────────────────────────────┐
│                     Next.js 16 App                           │
│                                                              │
│  ┌──────────────────────┐  ┌─────────────────────────────┐  │
│  │  db() / DatabaseService│  │  Firebase Admin (FCM only) │  │
│  │  PostgreSQLAdapter     │  │  getAdminDb() → mock        │  │
│  │  • single pg.Pool      │  │  getAdminAuth() → mock      │  │
│  │  • all CRUD / query    │  └─────────────────────────────┘  │
│  │  • getSharedPgPool()   │                                   │
│  │    (PostGIS escape)    │                                   │
│  └──────────┬─────────────┘                                   │
└─────────────┼─────────────────────────────────────────────────┘
              │
              ▼
     ┌─────────────────┐
     │  PostgreSQL 16   │
     │  + PostGIS       │
     │  (k8s or local)  │
     └─────────────────┘
```

**Key decision points in code:**

- `shouldUseFirebaseForDatabase()` returns `false` — `getAdminDb()` returns a mock `Firestore` from `build-mock.server.ts`.
- `shouldInitializeFirebaseFCM()` returns `true` — Firebase Admin SDK is initialized for push messaging only.
- `PostgreSQLAdapter` owns the **only** `new Pool()` in the codebase (`lib/database/adapters/PostgreSQLAdapter.ts`). Feature modules must not instantiate private pools.
- `getSharedPgPool()` (`lib/database/shared-pg-pool.ts`) is the sanctioned escape hatch for raw SQL (PostGIS). It calls `initializeDatabase()` then returns `getDatabaseService().getPostgreSQLPool()`.

## Single connection pool (SSOT)

| Surface | Path | Use |
|---------|------|-----|
| **CRUD / queries** | `db().readDoc`, `createDoc`, `updateDoc`, `queryDocs`, `findById`, `query`, … | All application collections |
| **Raw SQL (PostGIS)** | `getSharedPgPool()` | Spatial queries only — after `initializeDatabase()` |
| **Pool owner** | `PostgreSQLAdapter.getPgPool()` | Internal; exposed via `getSharedPgPool()` |

A 2026-07-07 audit eliminated three rogue `pg.Pool` instances in feature modules (`platform-settings-service.ts`, `native-token-oracle.ts`, `geolocation-service.ts`). Those modules now route through `db()` or `getSharedPgPool()`. The `validate-provider-ssot.sh` gate allows `new Pool(` only under `lib/database/`.

## Required environment variables

```bash
# Mode selection (REQUIRED — platform will not start without it)
DB_BACKEND_MODE=k8s-postgres-fcm

# PostgreSQL connection
DB_HOST=localhost               # K8s: postgres..svc.cluster.local
DB_PORT=5432
DB_NAME=ring_platform
DB_USER=ring_user
DB_PASSWORD=ring_password_2024

# Connection pool tuning (optional)
DB_POOL_SIZE=20
DB_TIMEOUT=30000
DB_RETRIES=3
DB_SSL=false
DB_ENABLE_POSTGIS=false
```

| Variable | Required | Default | Description |
|----------|----------|---------|-------------|
| `DB_BACKEND_MODE` | Yes | — | Must be `k8s-postgres-fcm` |
| `DB_HOST` | Yes | `localhost` | PostgreSQL hostname |
| `DB_PORT` | No | `5432` | PostgreSQL port |
| `DB_NAME` | Yes | `ring_platform` | Database name |
| `DB_USER` | Yes | `ring_user` | Database user |
| `DB_PASSWORD` | Yes | `ring_dev_password` | Database password |
| `DB_POOL_SIZE` | No | `20` | Connection pool max size |
| `DB_TIMEOUT` | No | `30000` | Connection timeout (ms) |
| `DB_RETRIES` | No | `3` | Connection retry count |
| `DB_SSL` | No | `false` | Enable TLS for PostgreSQL |
| `DB_ENABLE_POSTGIS` | No | `false` | Runtime flag; PostGIS extensions ship in `data/schema.sql` |

## JSONB hybrid schema

Most tables store business fields in a `data` JSONB column with top-level `id`, `created_at`, `updated_at`. Fully normalized tables (e.g. `vendor_applications`, `vendor_profiles`) list all columns in `PostgreSQLAdapter.fieldMappings` so the query builder uses direct column references.

**Exception — `platform_settings` hybrid table:**

```sql
CREATE TABLE IF NOT EXISTS platform_settings (
    id VARCHAR(64) PRIMARY KEY,
    data JSONB NOT NULL DEFAULT '{}',
    secrets JSONB NOT NULL DEFAULT '{}',
    updated_by VARCHAR(255),
    created_at TIMESTAMPTZ DEFAULT NOW(),
    updated_at TIMESTAMPTZ DEFAULT NOW()
);
```

`PostgreSQLAdapter` splits writes: public config into `data`, API keys into `secrets`, actor into `updated_by`. Consumers use `db().readDoc` / `createDoc` / `updateDoc`:

- `features/admin/platform-settings/platform-settings-service.ts` — AI, branding, matcher namespaces
- `features/wallet/services/native-token-oracle.ts` — `web3` namespace oracle rates

## PostGIS and geolocation

PostGIS extensions are created in `data/schema.sql` (`CREATE EXTENSION IF NOT EXISTS postgis`). Spatial queries use raw SQL because the doc-model cannot express `GEOGRAPHY` functions.

`lib/geolocation/geolocation-service.ts` calls `getSharedPgPool()` — **not** a private `Pool` or direct `DB_HOST` / `DB_PORT` env reads:

```typescript
import { getSharedPgPool } from '@/lib/database/shared-pg-pool'

const pool = await getSharedPgPool()
const result = await pool.query(sql, params)
```

Use cases: store delivery radius, entity locations, opportunity venues, map-heavy clones (pet-friendly places, real estate, events).

## DatabaseService contract

```typescript
import { initializeDatabase, getDatabaseService } from '@/lib/database/DatabaseService'

await initializeDatabase()
const db = getDatabaseService()

const user = await db.findById('users', userId)
await db.create('entities', { name: 'Acme', status: 'active' })

await db.transaction(async (txn) => {
  await txn.create('orders', { total: 100 })
})
```

Prefer `db()` shorthand from `@/lib/database` in feature modules. Email CRM routes through `features/email-crm/lib/jsonb-collection.ts`, which delegates to `db().*Doc`.

## Local setup

### Start PostgreSQL

{`docker run -d --name ring-postgres-dev \\
  -e POSTGRES_USER=ring_user \\
  -e POSTGRES_PASSWORD=ring_password_2024 \\
  -e POSTGRES_DB=ring_platform \\
  -p 5432:5432 \\
  postgres:16-alpine`}

### Configure `.env.local`

{`DB_BACKEND_MODE=k8s-postgres-fcm
DB_HOST=localhost
DB_PORT=5432
DB_NAME=ring_platform
DB_USER=ring_user
DB_PASSWORD=ring_password_2024`}

### Apply schema and start

{`psql -h localhost -U ring_user -d ring_platform -f data/schema.sql
npm run dev`}

Startup logs should show `Database: PostgreSQL`. For production-like realtime, set `TUNNEL_HUB_MODE=k8s-postgres`.

## K8s production setup

1. **Namespace** — `k8s/namespace.yaml` isolates each ring clone.
2. **PostgreSQL** — `k8s/postgres.yaml` deploys Postgres with `uuid-ossp` and `btree_gin`; service resolves as `postgres..svc.cluster.local`.
3. **Secrets / ConfigMap** — `DB_PASSWORD` in Secret; `DB_BACKEND_MODE`, `DB_HOST`, pool tuning in ConfigMap (`k8s/secrets.yaml`).
4. **App deployment** — `k8s/deployment.yaml` with health probes on `/api/health`, env from ConfigMap + Secret.
5. **Ingress** — `k8s/ingress.yaml` with TLS, WebSocket support, rate limiting.

```bash
kubectl apply -f k8s/namespace.yaml
kubectl apply -f k8s/postgres.yaml
kubectl apply -f k8s/secrets.yaml
kubectl apply -f k8s/deployment.yaml
kubectl apply -f k8s/service.yaml
kubectl apply -f k8s/ingress.yaml
kubectl apply -f k8s/pvc-local-file-storage.yaml
```

## FCM wiring notes

1. **Admin SDK** — initialized when `AUTH_FIREBASE_*` env vars are present.
2. **Mock Firestore / Auth** — `getAdminDb()` and `getAdminAuth()` return mocks; Auth.js v5 handles authentication.
3. **Client-side FCM** — `hooks/use-fcm.ts` + `public/firebase-messaging-sw.js` (RFC-only uses `push-sw.js`). Deep map: [Push notifications (FCM)](/docs/features/push-notifications-fcm.md).
4. **FCM tokens** — PostgreSQL `fcm_tokens` via `POST /api/notifications/fcm/register` (preferred from React). RFC rows: `push_subscriptions` (migration 046). Server PUSH fans out both; empty RFC on Chrome is a no-op.
5. **No Firebase env vars = no push** — PostgreSQL still works.

## Comparison: k8s-postgres-fcm vs supabase-fcm

| Aspect | k8s-postgres-fcm | supabase-fcm |
|--------|-----------------|--------------|
| **PostgreSQL host** | Your K8s or local | Supabase cloud |
| **SSL** | Optional (`DB_SSL=false` default) | Required (`DB_SSL=true`) |
| **Connection pool** | `DB_POOL_SIZE=20` default | `DB_POOL_SIZE=10` default |
| **Auth credentials** | `DB_USER` / `DB_PASSWORD` | `SUPABASE_URL` / `SUPABASE_SERVICE_KEY` |
| **Deploy target** | K8s, Docker, bare metal | Supabase platform |
| **Tunnel hub** | `k8s-postgres` or `memory` | `memory` |
| **Adapter** | Same `PostgreSQLAdapter` + shared pool | Same `PostgreSQLAdapter` + shared pool |

## Related documentation
