Skip to content

Commit 1483e37

Browse files
committed
chore: update docs
1 parent 4047eff commit 1483e37

File tree

1 file changed

+190
-21
lines changed

1 file changed

+190
-21
lines changed

docs/postgresql/SELF_HOSTING_POSTGRES_FLYIO.md

Lines changed: 190 additions & 21 deletions
Original file line numberDiff line numberDiff line change
@@ -5,7 +5,9 @@ This guide deploys a standalone PostgreSQL instance with CloudSync on Fly.io, pl
55
By the end you will have:
66

77
- A Fly.io VM running PostgreSQL with the CloudSync CRDT extension
8-
- A simple JWT auth server (Node.js) that issues tokens using a shared secret
8+
- A JWT auth server (Node.js) — choose between:
9+
- **HS256 (shared secret)** — simplest setup, signs tokens with a base64-encoded secret
10+
- **RS256 (JWKS)** — production-ready, signs with a private key and exposes a public JWKS endpoint
911
- A custom Postgres image published to Docker Hub
1012

1113
## Prerequisites
@@ -27,7 +29,7 @@ Since this is just PostgreSQL + a small auth server (not a full Supabase stack),
2729
|----------|---------|-------------|
2830
| RAM | 1 GB | 2 GB |
2931
| CPU | 1 core | 2 cores |
30-
| Disk | 10 GB SSD | 20 GB+ |
32+
| Disk | 4 GB SSD | 10 GB+ |
3133

3234
---
3335

@@ -89,7 +91,7 @@ fly apps create <your-app-name>
8991
### 4b. Create a persistent volume
9092

9193
```bash
92-
fly volumes create pg_data --app <your-app-name> --region <region> --size 10
94+
fly volumes create pg_data --app <your-app-name> --region <region> --size 4
9395
```
9496

9597
### 4c. Create a Fly Machine
@@ -256,13 +258,16 @@ const http = require("http");
256258
const jwt = require("jsonwebtoken");
257259
258260
const PORT = process.env.PORT || 3001;
259-
const JWT_SECRET = process.env.JWT_SECRET;
261+
const JWT_SECRET_RAW = process.env.JWT_SECRET;
260262
261-
if (!JWT_SECRET) {
263+
if (!JWT_SECRET_RAW) {
262264
console.error("JWT_SECRET environment variable is required");
263265
process.exit(1);
264266
}
265267
268+
// Decode base64 secret to raw bytes — must match how CloudSync verifies tokens
269+
const JWT_SECRET = Buffer.from(JWT_SECRET_RAW, "base64");
270+
266271
function parseBody(req) {
267272
return new Promise((resolve, reject) => {
268273
let data = "";
@@ -297,7 +302,7 @@ const server = http.createServer(async (req, res) => {
297302
const claims = body.claims || {};
298303
299304
const token = jwt.sign(
300-
{ sub, role, ...claims },
305+
{ sub, role, aud: "authenticated", ...claims },
301306
JWT_SECRET,
302307
{ expiresIn, algorithm: "HS256" }
303308
);
@@ -312,20 +317,149 @@ const server = http.createServer(async (req, res) => {
312317
});
313318
314319
server.listen(PORT, () => {
315-
console.log(`Auth server listening on port ${PORT}`);
320+
console.log("Auth server listening on port " + PORT);
316321
});
317322
AUTHEOF
318323
```
319324

320325
Install dependencies:
321326

322327
```bash
323-
cd /data/cloudsync-postgres/auth-server
324-
# Install node if not available
325-
apt-get install -y nodejs npm 2>/dev/null || true
326-
docker run --rm -v $(pwd):/app -w /app node:22-alpine npm install
328+
docker run --rm -v $(pwd)/auth-server:/app -w /app node:22-alpine npm install
327329
```
328330

331+
### 6e. (Optional) Create the JWKS auth server
332+
333+
If you need RS256/JWKS-based authentication instead of (or in addition to) the shared secret approach, create a second auth server that generates an RSA key pair on startup and exposes a JWKS endpoint.
334+
335+
```bash
336+
mkdir -p auth-server-jwks
337+
338+
cat > auth-server-jwks/package.json << 'EOF'
339+
{
340+
"name": "cloudsync-auth-jwks",
341+
"version": "1.0.0",
342+
"private": true,
343+
"dependencies": {
344+
"jsonwebtoken": "^9.0.0",
345+
"jose": "^5.0.0"
346+
}
347+
}
348+
EOF
349+
350+
cat > auth-server-jwks/server.js << 'EOF'
351+
const http = require("http");
352+
const jwt = require("jsonwebtoken");
353+
const crypto = require("crypto");
354+
const { exportJWK } = require("jose");
355+
356+
const PORT = process.env.PORT || 3002;
357+
const ISSUER = process.env.ISSUER || "cloudsync-auth-jwks";
358+
const KID = "cloudsync-key-1";
359+
360+
let privateKey, publicKey, jwksResponse;
361+
362+
async function init() {
363+
const pair = crypto.generateKeyPairSync("rsa", {
364+
modulusLength: 2048,
365+
publicKeyEncoding: { type: "spki", format: "pem" },
366+
privateKeyEncoding: { type: "pkcs8", format: "pem" },
367+
});
368+
privateKey = pair.privateKey;
369+
publicKey = pair.publicKey;
370+
371+
const publicKeyObject = crypto.createPublicKey(publicKey);
372+
const jwk = await exportJWK(publicKeyObject);
373+
jwk.kid = KID;
374+
jwk.alg = "RS256";
375+
jwk.use = "sig";
376+
jwksResponse = JSON.stringify({ keys: [jwk] });
377+
378+
console.log("RSA key pair generated (kid: " + KID + ")");
379+
}
380+
381+
function parseBody(req) {
382+
return new Promise((resolve, reject) => {
383+
let data = "";
384+
req.on("data", (chunk) => (data += chunk));
385+
req.on("end", () => {
386+
try { resolve(JSON.parse(data)); }
387+
catch { reject(new Error("Invalid JSON")); }
388+
});
389+
req.on("error", reject);
390+
});
391+
}
392+
393+
function respond(res, status, body) {
394+
res.writeHead(status, { "Content-Type": "application/json" });
395+
res.end(typeof body === "string" ? body : JSON.stringify(body));
396+
}
397+
398+
const server = http.createServer(async (req, res) => {
399+
if (req.method === "GET" && req.url === "/healthz") {
400+
return respond(res, 200, { status: "ok" });
401+
}
402+
403+
// JWKS endpoint — CloudSync server fetches this to verify tokens
404+
if (req.method === "GET" && req.url === "/.well-known/jwks.json") {
405+
res.writeHead(200, { "Content-Type": "application/json" });
406+
return res.end(jwksResponse);
407+
}
408+
409+
// POST /token { "sub": "user-id", "role": "authenticated", "expiresIn": "24h" }
410+
if (req.method === "POST" && req.url === "/token") {
411+
try {
412+
const body = await parseBody(req);
413+
const sub = body.sub || "anonymous";
414+
const role = body.role || "authenticated";
415+
const expiresIn = body.expiresIn || "24h";
416+
const claims = body.claims || {};
417+
418+
const token = jwt.sign(
419+
{ sub, role, aud: "authenticated", iss: ISSUER, ...claims },
420+
privateKey,
421+
{ expiresIn, algorithm: "RS256", keyid: KID }
422+
);
423+
424+
return respond(res, 200, { token, expiresIn });
425+
} catch (err) {
426+
return respond(res, 400, { error: err.message });
427+
}
428+
}
429+
430+
respond(res, 404, { error: "Not found" });
431+
});
432+
433+
init().then(() => {
434+
server.listen(PORT, () => {
435+
console.log("JWKS Auth server listening on port " + PORT);
436+
});
437+
});
438+
EOF
439+
440+
docker run --rm -v $(pwd)/auth-server-jwks:/app -w /app node:22-alpine npm install
441+
```
442+
443+
Add the JWKS auth service to `docker-compose.yml`:
444+
445+
```yaml
446+
auth-jwks:
447+
image: node:22-alpine
448+
container_name: cloudsync-auth-jwks
449+
environment:
450+
PORT: 3002
451+
ISSUER: cloudsync-auth-jwks
452+
ports:
453+
- "3002:3002"
454+
volumes:
455+
- ./auth-server-jwks:/app
456+
working_dir: /app
457+
command: ["node", "server.js"]
458+
restart: unless-stopped
459+
```
460+
461+
> **Note:** The JWKS server generates a new RSA key pair each time it starts. For production, persist the key pair to a volume so tokens remain valid across restarts.
462+
329463
---
330464
331465
## Step 7: Start the stack
@@ -339,27 +473,39 @@ Verify:
339473

340474
```bash
341475
docker compose ps
342-
# Both db and auth should be running
343476

344477
# Test Postgres
345478
docker compose exec db psql -U postgres -c "SELECT cloudsync_version();"
346479

347-
# Test auth server
480+
# Test HS256 auth server
348481
curl http://localhost:3001/healthz
482+
483+
# Test JWKS auth server (if enabled)
484+
curl http://localhost:3002/healthz
485+
curl http://localhost:3002/.well-known/jwks.json
349486
```
350487

351488
---
352489

353490
## Step 8: Generate a JWT token
354491

492+
**HS256 (shared secret):**
493+
355494
```bash
356-
# Generate a token for a user
357495
curl -X POST http://localhost:3001/token \
358496
-H "Content-Type: application/json" \
359497
-d '{"sub": "user-1", "role": "authenticated"}'
360498
```
361499

362-
Response:
500+
**RS256 (JWKS):**
501+
502+
```bash
503+
curl -X POST http://localhost:3002/token \
504+
-H "Content-Type: application/json" \
505+
-d '{"sub": "user-1", "role": "authenticated"}'
506+
```
507+
508+
Response (both):
363509

364510
```json
365511
{"token":"eyJhbG...","expiresIn":"24h"}
@@ -467,17 +613,35 @@ docker compose exec db psql -U postgres -c "SELECT * FROM todos;"
467613

468614
## Step 11: CloudSync server JWT configuration
469615

470-
The CloudSync server needs to validate tokens from your auth server. Configure these environment variables on the CloudSync server:
616+
The CloudSync server needs to validate tokens from your auth server. Configuration depends on which auth method you chose.
617+
618+
### Option A: HS256 (shared secret)
619+
620+
Configure these environment variables on the CloudSync server:
471621

472622
```env
473-
# Use the same JWT_SECRET as your auth server
623+
# Use the same JWT_SECRET as your auth server (base64-encoded)
474624
JWT_SECRET=<your-jwt-secret>
475625
476626
# For development/testing, set the development issuer override
477627
JWT_DEVELOPMENT_ISSUER_PROJECT_ID=cloudsync-postgres-flyio
478628
```
479629

480-
This allows the CloudSync server to verify HS256 tokens signed with your shared secret.
630+
Both the auth server and CloudSync must decode the base64 secret to the same raw bytes before signing/verifying.
631+
632+
### Option B: RS256 (JWKS)
633+
634+
Configure the CloudSync server to fetch the public key from your JWKS endpoint:
635+
636+
```env
637+
# JWKS endpoint URL — CloudSync fetches public keys from here to verify RS256 tokens
638+
JWKS_URL=http://cloudsync-postgres-test.internal:3002/.well-known/jwks.json
639+
640+
# Must match the ISSUER env var on the JWKS auth server
641+
JWT_ISSUER=cloudsync-auth-jwks
642+
```
643+
644+
No shared secret is needed — the CloudSync server fetches the public key from the JWKS endpoint and uses it to verify token signatures. This is how production auth systems (Auth0, Supabase, Firebase) work.
481645

482646
---
483647

@@ -486,13 +650,16 @@ This allows the CloudSync server to verify HS256 tokens signed with your shared
486650
| Service | URL |
487651
|---------|-----|
488652
| **PostgreSQL** | `postgres://postgres:<password>@<your-app-name>.internal:5432/postgres` |
489-
| **Auth Server** | `http://<your-app-name>.internal:3001` |
653+
| **Auth Server (HS256)** | `http://<your-app-name>.internal:3001` |
654+
| **Auth Server (JWKS)** | `http://<your-app-name>.internal:3002` |
655+
| **JWKS Endpoint** | `http://<your-app-name>.internal:3002/.well-known/jwks.json` |
490656

491657
From your local machine, use `fly proxy`:
492658

493659
```bash
494660
fly proxy 5432:5432 -a <your-app-name> # Postgres
495-
fly proxy 3001:3001 -a <your-app-name> # Auth server
661+
fly proxy 3001:3001 -a <your-app-name> # Auth server (HS256)
662+
fly proxy 3002:3002 -a <your-app-name> # Auth server (JWKS)
496663
```
497664

498665
---
@@ -585,7 +752,9 @@ docker compose logs -f auth # Auth server only
585752
| `fractional_indexing.h: No such file or directory` | Run `git submodule update --init --recursive` before building |
586753
| `cloudsync_version()` not found | Init scripts only run on first start. Run `CREATE EXTENSION IF NOT EXISTS cloudsync;` manually |
587754
| Auth server won't start | Check `docker compose logs auth`. Ensure `npm install` was run in `auth-server/` |
588-
| Token verification fails on CloudSync server | Ensure `JWT_SECRET` matches between auth server and CloudSync server |
755+
| Token verification fails (HS256) | Ensure `JWT_SECRET` matches and both sides base64-decode it before use |
756+
| Token verification fails (JWKS) | Ensure CloudSync can reach the JWKS endpoint and `JWT_ISSUER` matches the `ISSUER` env var |
757+
| JWKS keys lost after restart | The JWKS server generates new keys on each start. For production, persist keys to a volume |
589758
| Docker commands not found after VM restart | Run `/data/startup.sh` — Fly VM root filesystem resets on stop/start |
590759
| `fuse-overlayfs` not working | Install it: `apt-get install -y fuse-overlayfs` |
591760
| Can't connect to Postgres from outside Fly | Use `fly proxy 5432:5432 -a <your-app-name>` |

0 commit comments

Comments
 (0)