Three ways to run it. Pick one and follow it top to bottom — each is complete on its own.
| Use this when | You get | |
|---|---|---|
| 0. Paste a compose file | Your host has a "paste Docker Compose" box | http://YOUR_IP:8080, no shell needed |
| A. Locally | Trying it out, or developing against it | http://localhost:3000 |
| B. On a VPS, no domain | You have a server but no DNS yet | http://YOUR_IP:8080 |
| C. On a VPS with a domain | Anything other people will use | https://db.example.com |
Everything below has been run start to finish, on the versions in this repo.
You need Docker Engine with the Compose plugin, and nothing else. No Kubernetes, no Node, no Postgres on the host.
docker --version && docker compose versionIf Docker is missing on a Linux server:
curl -fsSL https://get.docker.com | sh
sudo usermod -aG docker $USER # then log out and back inFor hosts with a Docker Compose box — Hostinger, Coolify, Dokploy, Portainer — or anywhere you would rather not clone a repository. Nothing is built: every image is pulled from Docker Hub.
Take docker-compose.paste.yml from this repository and paste the
whole thing into your host's compose box.
Both are marked in the file, and nothing else needs touching.
JP_MASTER_KEY appears twice — once for the control plane, once for the router, and they must
match. Generate one:
openssl rand -base64 32It encrypts every project's database password, and there is no recovery. Save it in a password manager before you deploy.
JP_PUBLIC_HOST is your server's IP or hostname. It goes into the connection strings you hand to
applications, so left as localhost every string you copy works on the server and nowhere else.
Your host runs docker compose up. First start pulls about 900 MB of images, so give it a few
minutes.
There is no default account. Find the one-time setup token in the stack's logs:
docker compose logs control-plane | grep jp_setup
Most panels have a log viewer — search it for jp_setup. Then open http://YOUR_SERVER_IP:8080 and
create your account with that token.
Projects publish their Postgres on ports 55000–55999 on every interface. Your clients do not need those — they connect through the router on 5432 and 6543. Close the range in your provider's firewall. See SECURITY.md for why this matters.
- No HTTPS. The admin login crosses the network in the clear. Fine for a server only you use; for a team, do option C and put a certificate in front.
- It needs the Docker socket. justpostgres creates project containers through the Docker API. If your host is a constrained PaaS that blocks socket mounts, this will not start, and no configuration fixes it — you need a VPS where you control Docker.
For a laptop or a dev box. Takes about ten minutes, most of it building images.
git clone https://github.com/justpostgres/justpostgres.git
cd justpostgresecho "JP_MASTER_KEY=$(openssl rand -base64 32)" > .envThat is the only required setting. It encrypts every project's database password.
Keep this key. There is no recovery. If you lose it, your databases keep running and nothing — including justpostgres — can authenticate to them again. For a throwaway local instance you can afford to lose it; for anything you care about, put it in your password manager now.
images/postgres/build.shBuilds justpostgres/postgres:16, :17 and :18 — the official images plus pgBackRest, pgvector,
PostGIS and pg_cron. About 2.7 GB and a few minutes. Just one version is fine:
images/postgres/build.sh 17Do this before creating a project. The images are built locally and published nowhere, so a project on a major you have not built fails with a pull error.
docker compose up -d --build
docker compose psThree services, control-plane showing (healthy).
If port 3000 is already taken, put JP_HTTP_PORT=3300 in .env and run docker compose up -d
again.
docker compose logs control-plane | grep jp_setupOpen http://localhost:3000, paste that token with the email and password you want. There is no default account, and the token stops the first thing that reaches the page from taking ownership.
New project in the UI. A minute later you have connection strings on localhost:5432 (session)
and localhost:6543 (pooled).
Stopping and starting:
docker compose stop # pause; everything is still there
docker compose up -d # resume
docker compose down # remove containers, keep all data
docker compose down -v # delete everything, including your databasesReached at http://YOUR_SERVER_IP:8080. No HTTPS — the admin login and your database passwords
cross the network in the clear. Fine for a private network, a VPN, or a server only you use. If other
people will log in, do option C instead.
git clone https://github.com/justpostgres/justpostgres.git
cd justpostgrescurl -s ifconfig.meWrite it down — the next step needs it, and connection strings are built from it.
Replace 203.0.113.10 with the IP you just got:
cat > .env <<'EOF'
JP_MASTER_KEY=REPLACE_ME
JP_BIND_ADDR=0.0.0.0
JP_HTTP_PORT=8080
JP_PUBLIC_HOST=203.0.113.10
EOF
# generate the key and drop it in
sed -i "s|JP_MASTER_KEY=REPLACE_ME|JP_MASTER_KEY=$(openssl rand -base64 32)|" .env
chmod 600 .env
cat .envWhat each one does:
| Setting | Why it is there |
|---|---|
JP_MASTER_KEY |
Encrypts every project's password. No recovery if lost — copy it somewhere safe. |
JP_BIND_ADDR=0.0.0.0 |
Makes the UI reachable from outside the server. The default is loopback only. |
JP_HTTP_PORT=8080 |
The port you will open in a browser. Any free port works. |
JP_PUBLIC_HOST |
The IP goes here. Leave it out and every connection string says localhost, which works on the server and nowhere else. |
images/postgres/build.sh
docker compose up -d --build
docker compose psufw allow 22/tcp # do not lock yourself out
ufw allow 8080/tcp # the UI
ufw allow 5432/tcp # database connections (session)
ufw allow 6543/tcp # database connections (pooled)
ufw deny 55000:55999/tcp # see the warning below
ufw enableThe deny line matters. Every project also publishes its Postgres directly on a port in
55000–55999, on all interfaces. Without that rule, each database answers the whole internet on a high
port. Your clients do not need those ports — they connect through 5432 and 6543.
Some VPS providers (AWS, GCP, Oracle) have their own firewall in front of the machine. Open 8080, 5432 and 6543 there too, and leave 55000–55999 closed.
docker compose logs control-plane | grep jp_setupOpen http://YOUR_SERVER_IP:8080 and paste the token with your email and password.
Connection strings come back with your IP already in them:
postgresql://postgres.abc123:PASSWORD@203.0.113.10:5432/postgres # session
postgresql://postgres.abc123:PASSWORD@203.0.113.10:6543/postgres # pooled
The project is identified by the username suffix (postgres.abc123), which is how every project
answers on one port without per-project DNS.
Nothing is lost. Point DNS at the server, then follow option C
— keep the same .env and the same JP_MASTER_KEY, add JP_DOMAIN, change JP_PUBLIC_HOST to the
hostname, and remove JP_BIND_ADDR. Your projects and data carry over.
The full setup: a real certificate, renewed automatically, and the control plane not exposed directly.
Create an A record for the hostname you want, pointing at the server's IP:
db.example.com. A 203.0.113.10
Wait for it to resolve before continuing — the certificate is issued by proving control of this name, and that check fails if DNS has not propagated:
dig +short db.example.comThat must print your server's IP.
git clone https://github.com/justpostgres/justpostgres.git
cd justpostgresReplace db.example.com with your hostname in both places:
cat > .env <<'EOF'
JP_MASTER_KEY=REPLACE_ME
JP_DOMAIN=db.example.com
JP_PUBLIC_HOST=db.example.com
EOF
sed -i "s|JP_MASTER_KEY=REPLACE_ME|JP_MASTER_KEY=$(openssl rand -base64 32)|" .env
chmod 600 .env
cat .env| Setting | Why it is there |
|---|---|
JP_MASTER_KEY |
Encrypts every project's password. No recovery if lost. |
JP_DOMAIN |
The hostname Caddy gets a certificate for. This is the one the browser uses. |
JP_PUBLIC_HOST |
The hostname put into connection strings. Usually the same. |
There is no JP_BIND_ADDR here on purpose. The control plane stays on loopback and Caddy is the only
way in.
images/postgres/build.shdocker compose -f docker-compose.yml -f docker-compose.tls.yml up -d --build
docker compose -f docker-compose.yml -f docker-compose.tls.yml psTwo compose files: the base stack, plus an overlay that adds Caddy on ports 80 and 443 and removes the control plane's own published port.
That command is long, so put it in a shell alias or export the choice once per session:
export COMPOSE_FILE=docker-compose.yml:docker-compose.tls.yml
docker compose up -d --build # now picks up both files
docker compose logs -f caddyCaddy requests a certificate from Let's Encrypt on the first request and renews it without being asked. Nothing to configure and no certbot.
ufw allow 22/tcp
ufw allow 80/tcp # required: Let's Encrypt validates over port 80
ufw allow 443/tcp # the UI
ufw allow 5432/tcp # database connections (session)
ufw allow 6543/tcp # database connections (pooled)
ufw deny 55000:55999/tcp # projects' direct ports; clients do not need them
ufw enablePort 80 must stay open even though everything redirects to HTTPS. That is how the certificate is issued and renewed.
docker compose -f docker-compose.yml -f docker-compose.tls.yml logs control-plane | grep jp_setupOpen https://db.example.com and paste the token.
postgresql://postgres.abc123:PASSWORD@db.example.com:5432/postgres # session
postgresql://postgres.abc123:PASSWORD@db.example.com:6543/postgres # pooled
docker compose logs caddy says why. Nearly always one of:
- DNS is not pointing here yet.
dig +short db.example.commust return this server's IP. - Port 80 is closed, in
ufwor in the provider's firewall. - Something else already has port 80 or 443 — an existing nginx or Apache. Stop it, or use it as the proxy instead (see Using your own reverse proxy).
- Rate limited. Let's Encrypt limits repeated failures for the same name. Wait an hour.
To test the whole setup without a domain, set JP_DOMAIN=localhost. Caddy then issues its own
certificate instead of contacting Let's Encrypt.
Both apply to all three options.
Backups on the same disk as the database survive a dropped table and nothing else. Not the host dying, not the disk filling.
Go to Instance → Backups in object storage, pick Cloudflare R2, Amazon S3 or any S3-compatible provider, and follow the steps shown on that page. Test the connection before saving. New projects use it automatically; existing ones get a Move to object storage button on their Backups tab.
Scope the access token to one bucket. Those credentials reach every project container, so a key with account-wide access lets one project read every other project's backups.
It records which projects exist and holds their encrypted credentials. Lose it and the databases keep running with nothing able to reach them.
It backs itself up every six hours, and Instance → Metadata store backups lets you download one.
Copy those off the server. They do not contain JP_MASTER_KEY and are useless without it, so keep
the key somewhere else.
cd justpostgres
git pull
docker compose up -d --build # add -f docker-compose.tls.yml if you use option CSchema migrations run at startup. Your running projects are untouched — they are separate containers, and restarting the control plane does not interrupt a live database connection.
Upgrading Postgres itself is separate, done per project on its Version tab. Build the target image first:
images/postgres/build.sh 18It dumps the database out of the old version and loads it into the new one, so downtime is proportional to your data. The old data directory is kept afterwards, untouched, and the screen explains what you are agreeing to before you confirm.
If you already run nginx, Traefik or a load balancer, skip the TLS overlay, use the base
docker-compose.yml, and proxy to the control plane on 127.0.0.1:3000.
Add one setting so the audit log records real client addresses:
JP_TRUSTED_PROXIES=127.0.0.1,10.0.0.0/8,172.16.0.0/12,192.168.0.0/16Without it, X-Forwarded-For is ignored and every audit entry shows the proxy's address. Set it
only to where your proxy connects from — anything covered by this list can forge a client address,
and the login lockout is keyed on that address.
nginx:
server {
server_name db.example.com;
location / {
proxy_pass http://127.0.0.1:3000;
proxy_set_header Host $host;
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
proxy_set_header X-Forwarded-Proto $scheme;
}
}Separate from the control plane's certificate. With a wildcard certificate the router identifies
projects by TLS SNI, so clients connect to abc123.db.example.com instead of using the
postgres.abc123 username form.
JP_ROUTER_TLS_CERT=/etc/justpostgres/tls/fullchain.pem
JP_ROUTER_TLS_KEY=/etc/justpostgres/tls/privkey.pem
JP_ROUTER_DOMAIN_SUFFIX=db.example.comMount those files into the router service and add a *.db.example.com record. Without them the
username form is the only way in, which works everywhere and needs no extra DNS.
| RAM | 2 GB runs a handful of projects. 4 GB holds roughly 20–30 light ones, or 8–12 with the REST API on. |
| Disk | 20 GB minimum. Images are ~900 MB each, and projects keep data and backups locally until you point backups at a bucket. |
| A single project | 22–33 MB idle, 150–250 MB for a small app with real traffic. Capped at 512 MB by default. |
docker compose build stops with a message about JP_MASTER_KEY. Compose reads .env for every
command, not just up. Create it first.
Port is already allocated. Something else has that port. Change JP_HTTP_PORT in .env and run
docker compose up -d again.
A project fails with "pull access denied". You have not built the image for that Postgres major.
Run images/postgres/build.sh.
Connection strings say localhost instead of your IP or domain. JP_PUBLIC_HOST is not set. Add
it to .env and docker compose up -d. Existing projects pick it up straight away.
A new project refuses connections: "still being provisioned". The router refreshes its routing table every 5 seconds and reads the metadata store read-only, so it lags a few seconds behind a project turning green. Wait and retry.
Everything is refused with a 507. The disk is below the reserved headroom, so anything that allocates is blocked while deletes still work. Check Health, then free space or grow the disk.
Cannot reach the UI from outside. In option B, check JP_BIND_ADDR=0.0.0.0 is in .env (and that
you restarted), then the firewall, then the provider's own firewall.
You forgot the administrator password. There is no reset link — this instance has no email channel to send one through. Recover from the host instead:
docker compose exec control-plane node dist/index.js reset-admin
docker compose restart control-plane
docker compose logs control-plane | grep jp_setupThat removes the administrator account and nothing else, so the instance becomes unclaimed and mints a fresh setup token. Claim it again with the new token and whatever password you want. Your projects, their data, their credentials and their backups are untouched, and every database keeps running throughout — only the management UI is briefly unclaimed. The reset is written to the audit log, which outlives the account it describes.
To change a password you still know, use Instance → Administrator password. No downtime, and no need for any of the above.
Check the Health page first. It reports the database, Docker, the job worker, storage and disk separately, and stays up when any of them is down.
For what is protected and what is not, read SECURITY.md.