cookiepal.oss
Backend

Configuration

Env vars, Postgres setup, and migrations.

The backend is configured entirely via environment variables. No CLI flags.

Required

Env varPurpose
SITESite origin this backend serves (e.g. https://example.com). Stamped onto every row.
DATABASE_URLPostgres connection string (postgres://user:pass@host:5432/db).

Optional

Env varDefaultPurpose
ORIGINSSITEComma-separated CORS allowlist. Exact-match — no wildcards.
PORT8080Listen port (1–65535).
LOG_LEVELinfodebug / info / warn / error.

Postgres

The backend works against Postgres 14 or later. It does not create the database for you — provision an empty database and a user with CREATE / INSERT / SELECT privileges, then point DATABASE_URL at it. Managed services (AWS RDS, Neon, Supabase, Fly Postgres, Railway) all work.

Migrations

On startup the backend runs every .sql file in packages/backend/src/db/migrations/ against DATABASE_URL, in sorted order, inside a Postgres advisory lock (key 42) so concurrent instances don't race. Already-applied migrations are skipped via a schema_migrations table.

Safe restart pattern: bring up a new container, wait for GET /health to return {"status":"ok"}, then switch traffic. The lock ensures only one instance runs migrations at a time even if you're rolling multiple replicas.

Schema

CREATE TABLE consents (
  id          UUID PRIMARY KEY DEFAULT gen_random_uuid(),
  site        TEXT NOT NULL,
  consent_id  TEXT NOT NULL,
  consent     JSONB NOT NULL,
  ip          TEXT,
  user_agent  TEXT,
  country     TEXT,
  region      TEXT,
  created_at  TIMESTAMPTZ NOT NULL DEFAULT NOW()
);

CREATE INDEX idx_consents_site_created_at ON consents (site, created_at DESC);
CREATE INDEX idx_consents_consent_id      ON consents (consent_id);

Per-row fields:

  • consent_id — client-generated UUID the SDK sends in the POST body.
  • consent — JSON-encoded consent map, e.g. {"necessary":true,"analytics":false}. From the client body.
  • ip — from X-Forwarded-For (first entry) or the raw socket address. Server-stamped.
  • user_agent — from the User-Agent header. Server-stamped.
  • country — from edge headers (cf-ipcountry, x-vercel-ip-country, x-amz-cf-ipcountry, cloudfront-viewer-country). Server-stamped. NULL if none present.
  • region — from cf-region or x-vercel-ip-country-region. Server-stamped. NULL otherwise.
  • created_at — server clock. Not trusted from the client.

Only consent_id and consent come from the request body. Everything else is stamped server-side — client-supplied values for those fields are ignored.

Latest state for a user

The table is append-only. To read the current consent for a given visitor:

SELECT consent, created_at
FROM consents
WHERE consent_id = $1
ORDER BY created_at DESC
LIMIT 1;

Backups

Managed Postgres: use the provider's point-in-time recovery.

Self-hosted: pg_dump against DATABASE_URL on a schedule:

pg_dump "$DATABASE_URL" > backup-$(date +%F).sql

Restore with psql "$DATABASE_URL" < backup-YYYY-MM-DD.sql.

On this page