What justpostgres protects, what it does not, and what you have to decide yourself.
This is written for the person running it, not for a compliance questionnaire. Where something is weak, it says so. Where a default is permissive, it says why and what to change.
justpostgres gives every project a real Postgres superuser. That is the point of the product —
it is what makes CREATE EXTENSION work — and it means anyone with a project's connection string can
run arbitrary code inside that project's container, via COPY ... FROM PROGRAM and several other
routes. Nothing here tries to prevent that. The container boundary is the security boundary, and
on a shared host, container escape is the only thing standing between one user's project and
another's. If you are giving projects to people you do not trust, read all of this.
If you are running it for yourself, the threat model is much smaller: mostly, do not put the control plane on the public internet, and firewall the project port range.
Verified against a running container, not just intended:
| Control | Setting | Why |
|---|---|---|
| No privilege escalation | no-new-privileges |
setuid binaries cannot gain more than the container has |
| Capabilities dropped | CapDrop: ALL, then only CHOWN, DAC_OVERRIDE, FOWNER, SETGID, SETUID |
the five the official Postgres image needs to drop from root to postgres |
| Not privileged | Privileged: false |
superuser in Postgres is not root on the host |
| Memory ceiling | --memory, default 512 MiB |
one project cannot starve the host |
| CPU ceiling | --cpus, default 1.0 |
same |
| Process ceiling | PidsLimit: 512 |
the one exhaustion memory and CPU limits miss — a fork bomb |
| Own network | one Docker network per project | no name resolution or direct routing between projects |
| No Docker socket | never mounted into a project | a project cannot ask Docker for anything |
| No host mounts | only its own two volumes | no path to the host filesystem |
The container's init process runs as root (the official image starts as root and drops to
postgres with gosu). With capabilities dropped and no-new-privileges set, that root is
substantially declawed — but there is no user-namespace remapping, so container root is host
UID 0 if the kernel boundary is ever crossed. If you host untrusted tenants, turn on
userns-remap in the Docker daemon. It is
the single highest-value thing you can add, and justpostgres does not require any change to work
under it.
Ordered by how much they should worry you.
This is the one to act on. Each project's Postgres is published on a host port in
JP_PORT_RANGE_START–JP_PORT_RANGE_END (default 55000–55999), bound to 0.0.0.0. On a VPS with no
firewall, every project's database answers the internet on a high port. SCRAM authentication is
still in the way, so this is exposure rather than compromise — but a database reachable from the
internet is a database being brute-forced, and it widens the blast radius of any future
pre-authentication vulnerability in Postgres.
It is also, on a Linux host, what lets one project reach another: containers can reach the host gateway, so project A can open a TCP connection to project B's published port even though the two are on separate Docker networks. Verified — the per-project network stops name resolution and direct container-to-container routing, and does not stop this.
What to do, in order of preference:
- Firewall the port range so only the router reaches it. On a single host, this costs one rule.
- If the control plane and router run on the host rather than in containers, set
JP_PROJECT_BIND_ADDR=127.0.0.1. That closes it completely. - Accept it, knowingly, on a host where you are the only user.
The default stays permissive because a containerised router reaches projects through the host gateway, and binding to loopback would cut it off — a change that would silently break every routed connection string. The control plane warns about this at every boot.
The real fix, not yet built: attach the control plane and the router to each project's network and stop publishing project ports altogether. The direct-port endpoint is already documented as a fallback from before the router existed (ARCHITECTURE §5), so removing it costs little. It touches the data path for every feature, so it is a change to make deliberately and verify, not to slip into a hardening pass.
For the same reason: containers can reach the host gateway. A project with arbitrary code execution — which is every project, by design — can reach the control plane's HTTP port.
It cannot do much with it. Every endpoint but health and login requires a session; login is rate
limited per address; and setup requires the one-time token. But it is reachable, and it is why the
compose file binds the control plane to 127.0.0.1 by default and expects a TLS proxy in front.
Do not change that binding to 0.0.0.0 on a host running projects for other people.
They are encrypted at rest with AES-256-GCM under JP_MASTER_KEY, which lives in the environment and
never in the database. That protects against someone reading the SQLite file — a stolen backup, a
snapshot, a misplaced volume. It does not protect against someone who has compromised the running
process, because it has the key.
A compromised control plane is total compromise of every database on the host. That is inherent to what it does, and the reason it talks to Docker through a socket proxy that allowlists only the endpoints it uses rather than holding the raw socket.
JP_MASTER_KEY has no recovery. Lose it and every stored credential is permanently unreadable —
the databases keep running, and nothing can authenticate to them. Store it where you store your other
secrets, and specifically not beside the control-plane backups it decrypts.
Backup credentials are stored encrypted under JP_MASTER_KEY, but they are handed to every project
container as environment variables, because archive_command runs as a subprocess of Postgres and
inherits them. Any project with superuser — which is every project — can read them.
That means a single key with account-wide access lets one project reach every other project's backups. Scope the token to one bucket, and give it object read, write and delete on that bucket only. Do not reuse a key that has other permissions attached.
Per-project prefixes stop one project's retention from expiring another's, which is a different problem and one justpostgres does solve.
Branching on btrfs or ZFS runs filesystem commands in a short-lived privileged container with only
the CoW root bind-mounted. This sounds worse than it is: the control plane already holds Docker API
access, which is root-equivalent, so it could start such a container regardless. Doing it narrowly
and briefly is better than running the control plane itself privileged. It is off unless you set
JP_COW_DRIVER.
There are no roles, no per-project permissions, and no second factor. The admins table holds a
TOTP column that nothing uses yet. Multi-user is deliberately out of 1.0
(ARCHITECTURE §12) — it implies invitations, permissions and an
organisation model, which is the hosted service arriving early.
What is there: scrypt password hashing, session tokens hashed at rest, per-address login lockout, and both failed logins and failed setup attempts written to the audit log.
There is no password reset, and recovery needs the host. A reset link requires an email channel
this product does not have, and a security question is worse than nothing — so a forgotten password
is recovered with reset-admin, run on the host, which removes the account and lets the instance be
claimed again through the ordinary first-run flow. That deliberately sets the bar at shell access:
anyone with it could edit the SQLite file by hand anyway, and anyone without it cannot get in by
forgetting a password. The reset is audited, and it never touches project credentials — those stay
encrypted under JP_MASTER_KEY, which the command neither knows nor needs.
There is no default account and no default password. The first boot mints a one-time setup token, prints it to the log, and keeps printing it on every boot until someone uses it:
docker compose logs control-plane | grep jp_setup/setup will not create the administrator without it. Without that token, "first run creates the
account" means the first HTTP request to reach a public IP owns every database on the host — the
window between docker compose up and you opening a browser is an unauthenticated land grab. The
token is compared in constant time and destroyed in the same transaction that creates the account.
Set JP_SETUP_TOKEN to choose the value yourself when a configuration manager provisions the host.
Off by default, per project. When enabled:
- No table is reachable until you expose it.
anonis granted nothing at all — a deliberate divergence from Supabase, whereanonstarts with schema-wide access and safety depends on remembering to enable row-level security. - Exposing a table grants access, enables RLS and creates a policy in one transaction, so "reachable but unprotected" is not a state you can reach by forgetting a step.
- Two keys are issued:
anonandservice. The service key bypasses RLS entirely. It belongs on a server, never in a browser, never in a mobile app. - Rotation is immediate and total: the old keys stop working the moment the container restarts. There is no grace period, because rotation exists for the case where a key has leaked.
- PostgREST is published on
127.0.0.1only; the control plane's proxy is the route in.
Identity comes from whatever provider signs a JWT with the project's secret. justpostgres issues keys
and installs auth.uid(), auth.role() and auth.claim(); your policies do the deciding.
The audit log records who did what, from which address: sign-ins and failed sign-ins, project lifecycle, credential and API-key reveals, schema and security changes, backups and restores, upgrades, and every row edit made through the UI. It is visible under Instance in the UI.
SQL text is deliberately not recorded. A WHERE clause routinely contains personal data, and
putting it in a table that outlives the project would make the audit log a liability. What is
recorded is that a query ran, how many statements, and which commands.
Passwords never appear in the log or the audit table, including in failed attempts.
This is a pre-1.0 project with no security team. If you find something, open an issue for anything already public, and for anything exploitable contact the maintainers privately first. Nothing here has had an external audit; this document is what a careful read of our own code found.
- Session cookie overwrite via the REST proxy (v0.1.1). The proxy that serves a project's API at
/rest/<ref>/forwarded upstreamSet-Cookieheaders unchanged. Because that path shares the admin origin and the session cookie isPath=/, a project — which can emit response headers through PostgREST'sresponse.headersGUC — could overwrite the admin's session cookie. The inboundCookieheader was already stripped; the outboundSet-Cookienow is too. Reported privately, following this document; fixed in v0.1.1. Thanks to the external researcher who disclosed it responsibly.