Best practices¶
Nothing on this page is a separate mechanism — every item links back to a setting or pattern
covered in full elsewhere. This is the checklist for pulling them together into a hardened,
production deployment, since defaults have to stay usable for a first just test-db-fresh run
and can't assume every one of these choices for you.
Turn off Postgres' default PUBLIC execute grant on functions¶
Postgres grants EXECUTE on every newly created function to PUBLIC by default — including
functions your own migrations create. rel calls a function under whatever role a request's
session switched to, and that role is typically a member of several application roles at once;
an ungranted function is reachable by any of them the instant it exists, whether or not you
meant to expose it as a route or through call/agg.
Turn the default off, then grant EXECUTE explicitly, the way you already grant any other
privilege:
-- affects functions created after this runs, in every schema `app_owner` creates one in
alter default privileges for role app_owner revoke execute on functions from public;
-- functions that already exist need revoking separately, per schema
revoke execute on all functions in schema hotel from public;
-- then grant explicitly, per function and role
grant execute on function hotel.rooms_available(int, date) to editor;
Do this before anything else on this page — it's the single Postgres-level setting most likely to leave a function more reachable than you intended, independent of anything rel's own blacklist or role separation below catches.
rel's own anonymous route authorization
already refuses to treat a route as anonymously reachable on the strength of a PUBLIC grant
alone — an unauthenticated request still needs an explicit EXECUTE grant to the anonymous
role. That only covers the literal "no login at all" case, though: an authenticated caller,
and any function reached through call/agg rather
than as a route, both fall back to Postgres's own live privilege check — which still credits
PUBLIC the same as it always has. Revoking the default above is what actually closes those,
and it's especially worth doing for a SECURITY DEFINER function: one of those left on the
PUBLIC default is callable by anyone with any authenticated role, running with the
privileges of whoever owns it, not the caller's own.
Keep pg.user's own privileges minimal¶
pg.user (see Configuration) is the one login rel connects with — for startup
introspection, reload.cmd, and serving requests alike. Grant it membership in
only the application roles a request should ever SET ROLE into ; it doesn't need any DDL
privilege of its own, even if reload.cmd runs a migration tool — that tool authenticates
however its own command line says to, entirely independent of pg.user:
create role rel_user login password '...';
grant "~anonymous" to rel_user;
grant "editor" to rel_user;
grant "admin" to rel_user;
Never make that role postgres, a superuser, or a member of pg_read_server_files,
pg_write_server_files, pg_execute_server_program, or pg_signal_backend. rel's own
relation/function blacklist below stops a lot, but it can
only stop what it already knows the name of — a newly CREATE EXTENSION'd function
(dblink, postgres_fdw, ...) defaults to PUBLIC EXECUTE the moment it exists, and isn't
covered until someone notices and blacklists it by name. Keeping the role's own privileges
minimal is the backstop for exactly that gap: as long as it never holds those privileges or
memberships, arbitrary file/network/process access stays unreachable even from a function the
blacklist hasn't caught up to yet.
Access control itself stays entirely Postgres-native this way: roles plus row-level security, not a separate authorization DSL rel introduces on top.
Restrict what a query can reach¶
pg_catalog and information_schema are unqueryable by default, and a handful of individually
dangerous functions (pg_sleep, pg_terminate_backend, the pg_advisory_*lock* family,
set_config) are blacklisted out of the box. Add your own sensitive tables and functions to
blacklist.relations.<schema>.<name> / blacklist.functions.<schema>.<name> explicitly — an
internal audit table, a dblink/postgres_fdw connection your app installs for its own
server-side use, anything a client should never be able to name directly even under a role
that can otherwise read a lot. See Configuration reference ## Restricting what a query can
reach.
Beyond the blacklist, every route/middleware function is reachable only at the path it's
explicitly declared at (see HTTP routes) — there's no
separate "discovered at all" gate to configure. Restrict which ones may mint or clear a
session with http.functions.allowed_auth — see Authentication.
Leave expensive operators off unless you actually need them¶
like/ilike, regex match (~/~*), and full-text search (@@) are disabled by default —
each can be made to run pathologically slowly against an unindexed or adversarially-chosen
input, and a client controls the pattern. Enable only the ones you use, on columns that are
actually indexed for that kind of match (trigram/GIN, a tsvector column) — see Operators
reference.
Lock down the browser surface¶
- CORS is closed by default (no
Access-Control-*headers at all). List exact origins inhttp.cors.allowed_originsrather than reaching for*—*also silently stops rel from ever sendingAccess-Control-Allow-Credentials, so the JWT cookie becomes useless to a cross-origin caller anyway; it's only right for a genuinely public, anonymous-role-only API. See CORS and CSP. - CSP ships a real
default-src 'self'policy even unconfigured. Reach for a route's ownHttpResponse.cspoverride or the per-requestNonce(see Rendering HTML with templates) for a one-off trusted inline script, rather than looseninghttp.csp.script_src/style_srcprocess-wide to accommodate it. - Cookies default to
secure,httponly,SameSite=Lax. Wideningjwt.same_sitetoNoneis only ever needed for a session cookie read across a genuine cross-site embed, and needssecurealongside it (browsers rejectSameSite=Nonewithout it) — see Configuration reference ## Sessions (JWT).
Size the resource limits to real traffic, not the defaults¶
http.max_body_size (10 MiB) and http.max_part_count (100) bound a single declared-route request;
pg.query.max_depth (6) bounds how deeply a query can nest joins; pg.pool_size (10) bounds
how many connections actually serve requests concurrently. All four default to something
reasonable for getting started, not to whatever your production traffic and payload sizes
actually need — revisit them once you know.
Keep dev off¶
dev (default false) adds the real Postgres error text and a stack trace to an otherwise
generic error response — useful while building against your own schema, a information leak
once real users can trigger errors. Confirm it's unset (or explicitly false) in production
rather than relying on the default alone if your deployment pipeline ever sets it per
environment. See Configuration ## Development mode.
Package your own app as an image built FROM rel¶
rel-server/rel ships as a generic, non-root, scratch-based binary — whatever reload.cmd
needs, well-known queries, static assets, and Jet templates are all your app's own versioned
files, not something the base image carries. Build them into your own image rather than
bind-mounting them from a host directory at deploy time, and keep /secrets as the one
directory that lives on a persistent volume. See Docker deployment ## /wellknown, /static,
/template, and reload.cmd: build them into your own
image.