Reaching a VPS-hosted Postgres from developer machines
For teams where Postgres runs on a VPS (see Deploying to a VPS) but app services
still run locally on developer machines — bun --filter '@campus/api' dev against a remote
database instead of the full stack being deployed together.
Why WireGuard, not a public database port
Exposing Postgres directly on the VPS's public IP — even behind a firewall allowlist and TLS — is still a database port facing the entire internet, one misconfiguration away from a real problem. WireGuard instead means the only thing ever exposed publicly is the WireGuard UDP port itself, which has no database attack surface at all — a much smaller, better-audited thing to leave open than Postgres. Once connected, the tunnel behaves like a private network between your machine and the VPS; Postgres is bound only to that tunnel's address and is never listening on the public interface, regardless of firewall state.
This is a split tunnel — connecting only routes traffic for the tunnel subnet (10.8.0.0/24)
through the VPS, not all of your internet traffic. It's for reaching the database, not a general
VPN.
1. Server setup (once, on the VPS)
git clone <repo-url> && cd ConnectedCampus # if not already checked out there
./infra/wireguard/setup-server.sh
Works as root or as a non-root user with sudo — every privileged command inside the script is
already sudo-prefixed, which is a harmless no-op when you're already root. On a VPS where root is
the only account (common for a single-tenant box), just run it as root directly.
This installs wireguard-tools, generates the server keypair, writes /etc/wireguard/wg0.conf
(interface address 10.8.0.1/24, listening on UDP 51820), enables wg-quick@wg0 on boot, and
opens UDP 51820 through ufw if present. It detects and saves this VPS's public IP for use in
client configs — check /etc/wireguard/public_endpoint is correct if the VPS sits behind a load
balancer or NAT.
Then bind Postgres to the tunnel interface only, so it's never on the public interface at all. On
the production VPS deployment (docker-compose.prod.yml), the postgres service deliberately
has no ports: entry at all — it's reachable only from other containers on the backend network,
by design (see that file's own comment). To add tunnel access without weakening that default, use
the dedicated override, docker-compose.prod.wireguard.yml, instead of editing the base file:
# in .env on the VPS
POSTGRES_BIND_ADDR=10.8.0.1
docker compose -f docker-compose.prod.yml -f docker-compose.prod.nginx.yml \
-f docker-compose.prod.wireguard.yml up -d postgres
(Include whatever other override files the deployment already uses alongside the base file —
docker-compose.prod.nginx.yml above matches the shared-VPS-with-existing-nginx setup documented
in Deploying to a VPS; the important part is adding docker-compose.prod.wireguard.yml
to the same invocation.) POSTGRES_BIND_ADDR defaults to 127.0.0.1 (safe — publishes nothing new)
until set to the tunnel address.
If instead you're running the local dev docker-compose.yml (not the production one) as a
shared database for a team, that file already has ${POSTGRES_BIND_ADDR:-0.0.0.0}:55432:5432 built
directly into its postgres service — no override file needed there, just set the same .env
variable and docker compose up -d postgres.
2. Add each developer machine as a peer
Run this on the VPS once per device — it's the reusable step for onboarding new machines, not a one-off:
./infra/wireguard/add-peer.sh harsh-laptop
Prints a client .conf (also saved on the VPS at /etc/wireguard/clients/harsh-laptop.conf) —
this contains the device's private key, so treat it like any other credential: don't paste it
into a chat tool, ticket, or shared doc. Pull it directly to the device instead:
scp root@<vps-ip>:/etc/wireguard/clients/harsh-laptop.conf ~/.wireguard/harsh-laptop.conf
chmod 600 ~/.wireguard/harsh-laptop.conf
If a peer's config was ever pasted somewhere it shouldn't have been, treat that key as compromised
and reissue: remove its [Peer] block from wg0.conf and its file under /etc/wireguard/clients/
on the VPS, reload with wg syncconf wg0 <(wg-quick strip wg0), then re-run add-peer.sh with the
same name.
3. Client setup, per device
Linux / WSL2:
sudo apt-get install -y wireguard-tools
sudo wg-quick up ~/.wireguard/harsh-laptop.conf # wg-quick accepts a direct path; no need to
# copy into /etc/wireguard/wg0.conf first
Bring it down later with sudo wg-quick down ~/.wireguard/harsh-laptop.conf. Check it actually
connected with sudo wg show — a latest handshake line a few seconds old confirms the tunnel is
live, not just configured.
Windows / macOS: install the official WireGuard app,
then "Import tunnel(s) from file" and select the .conf. Toggle the tunnel on.
Verify the tunnel is actually up (not just configured) — run this from the client:
pg_isready -h 10.8.0.1 -p 55432
This should succeed only while connected. From a machine that is not connected, or against the VPS's public IP directly, the same command should time out — confirming the port genuinely isn't reachable outside the tunnel.
4. Point the app at the remote database
Once connected, update .env on that machine:
DATABASE_URL=postgres://campus:<password>@10.8.0.1:55432/campus_os
RUNTIME_DATABASE_URL=postgres://campus_app:<password>@10.8.0.1:55432/campus_os
Then the normal sequence: bun run db:migrate (idempotent — creates the schema, RLS policies, and
campus_app role the same way it does locally), and bun --filter '@campus/api' dev +
curl http://localhost:4000/health as the smoke test that the app actually works against the
remote database, not just that Postgres accepts a raw connection.
5. Migrating existing local data to the VPS
If a local Postgres already has real data (imported org/people/curriculum/attendance, etc.) and the goal is to move it — not start fresh — bring the data over after the schema exists on the VPS:
# 1. On the VPS, with the WireGuard tunnel connected from wherever you're running this:
# the schema/RLS/roles already exist from `bun run db:migrate` in step 1 above.
# 2. Locally — dump data only (schema already matches from the migrate step, so a full dump
# would just fight it):
pg_dump --data-only -Fc "$DATABASE_URL" -f local-data.dump
# 3. Restore into the VPS instance, over the tunnel:
pg_restore --data-only -d "postgres://campus:<password>@10.8.0.1:55432/campus_os" local-data.dump
campus is a superuser on both ends, so RLS doesn't block the restore. Spot-check a few row counts
against the local database afterward (select count(*) from persons, attendance_records,
legacy_records, ...) to confirm nothing was silently dropped.