diff --git a/.env.example b/.env.example index 8b1add0..ac7988b 100644 --- a/.env.example +++ b/.env.example @@ -182,7 +182,7 @@ PASSWORD_RESET_URL=http://localhost:3000/reset-password # SHUTDOWN_TIMEOUT_MS=30000 # # Read only by docker-compose.yml (Docker Compose reads this file for them), not by the API: -# MGPL_API_VERSION=latest api image tag: pin a release in production, e.g. 1.3.0 (docs/18-updates.md) +# MGPL_API_VERSION=latest api image tag: pin a release in production, e.g. 1.3.0 (docs/19-updates.md) # MGPL_DASHBOARD_VERSION=latest dashboard image tag # MGPL_API_IMAGE=ghcr.io/mgpl-diagnostics/api # MGPL_DASHBOARD_IMAGE=ghcr.io/mgpl-diagnostics/dashboard @@ -207,7 +207,7 @@ PASSWORD_RESET_URL=http://localhost:3000/reset-password # DEBUG=1 `npm test`: print resolver errors -# ── Updates: notices for owners and admins (docs/18-updates.md) ────────────────────────────────────── +# ── Updates: notices for owners and admins (docs/19-updates.md) ────────────────────────────────────── # [optional] The api reads the newest GitHub releases of the api and the dashboard (one GET each, every # UPDATE_CHECK_INTERVAL_MS) and the dashboard shows "update available". false: no outbound call. # UPDATE_CHECK=true diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index 7e57fc6..bb55b59 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -75,5 +75,5 @@ jobs: echo "release $TAG exists" else gh release create "$TAG" --target "$GITHUB_SHA" --title "$TAG" --generate-notes \ - --notes "Image: \`ghcr.io/${{ github.repository }}:${{ steps.v.outputs.version }}\` (linux/amd64, linux/arm64). Update guide: docs/18-updates.md" + --notes "Image: \`ghcr.io/${{ github.repository }}:${{ steps.v.outputs.version }}\` (linux/amd64, linux/arm64). Update guide: docs/19-updates.md" fi diff --git a/deploy/production.env.example b/deploy/production.env.example new file mode 100644 index 0000000..833e5b6 --- /dev/null +++ b/deploy/production.env.example @@ -0,0 +1,44 @@ +# Production .env for docker-compose.yml on one server (docs/19-vps.md). Copy it next to docker-compose.yml: +# cp deploy/production.env.example .env && chmod 600 .env +# Replace every CHANGE_ME. Never commit the filled-in file. + +# ── Images (docs/18-updates.md): pin a release; change these two lines to update ───────────────────── +MGPL_API_VERSION=latest +MGPL_DASHBOARD_VERSION=latest + +# ── Ports and proxies ────────────────────────────────────────────────────────────────────────────────── +# nginx only on localhost: Caddy on the host terminates HTTPS and forwards here. (Docker's own port rules +# bypass ufw, so never publish 8080 on all interfaces.) +HTTP_PORT=127.0.0.1:8080 +# proxies in front of the api: Caddy + nginx. The login rate limits use the client IP this gives. +TRUST_PROXY=2 +API_REPLICAS=2 + +# ── App ─────────────────────────────────────────────────────────────────────────────────────────────── +NODE_ENV=production +# the address people open (no trailing slash) +CORS_ORIGINS=https://CHANGE_ME.example.in +PASSWORD_RESET_URL=https://CHANGE_ME.example.in/reset-password +# openssl rand -base64 48 (the same value on every api and the worker; this file gives it to all) +JWT_SECRET=CHANGE_ME +GRAPHIQL=false +INTROSPECTION=false +MGPL_SAMPLES=false +DEVICE_BINDING=enforce + +# ── Database (the compose file builds DATABASE_URL from this) ────────────────────────────────────── +# openssl rand -hex 24 (URL-safe characters only) +POSTGRES_PASSWORD=CHANGE_ME + +# ── Mail: password-reset links (any SMTP provider with TLS) ───────────────────────────────────────── +# URL-encode the user and password (@ → %40). Examples in .env.example. +SMTP_URL=smtps://CHANGE_ME%40example.in:APP_PASSWORD@smtp.example.in:465 +MAIL_FROM="Maa Gayatri Path Lab " + +# ── Signatures on reports and invoices (optional) ──────────────────────────────────────────────────── +# Mount the folder in docker-compose.yml (x-api volumes) and point these at the files inside it. +# MGPL_SIGNATURE_HEADER_FILE=/secure/signature-header.png +# MGPL_SIGNATURE_NONHEADER_FILE=/secure/signature-nonheader.png + +# ── Update notices (on by default; one GET to GitHub per 6 h) ─────────────────────────────────────── +# UPDATE_CHECK=true diff --git a/docker-compose.yml b/docker-compose.yml index 2ff3175..42ba065 100644 --- a/docker-compose.yml +++ b/docker-compose.yml @@ -1,6 +1,6 @@ # The whole system on one host: PostgreSQL ← PgBouncer ← N api replicas, the worker, Redis, the dashboard, # and nginx in front (/graphql and /health → api, everything else → dashboard). Guides: docs/17-scaling.md, -# docs/18-updates.md. +# docs/19-updates.md. # # cp .env.example .env # set JWT_SECRET, CORS_ORIGINS, POSTGRES_PASSWORD, … # docker compose up -d # pulls the released images; http://localhost:8080 diff --git a/docs/12-deployment.md b/docs/12-deployment.md index 59bfd0f..161002d 100644 --- a/docs/12-deployment.md +++ b/docs/12-deployment.md @@ -34,7 +34,7 @@ NODE_ENV=production node dist/worker.js # worker (with REDIS_URL) ``` Released images: `ghcr.io/mgpl-diagnostics/api` and `ghcr.io/mgpl-diagnostics/dashboard`, one tag per -release; owners and admins see new releases in the dashboard ([19-updates.md](19-updates.md)). +release; owners and admins see new releases in the dashboard ([19-updates.md](19-updates.md)). One VPS, step by step: [20-vps.md](20-vps.md). The image is built from the [`Dockerfile`](../Dockerfile) (Node 22 slim, `dist/`, production `node_modules/`; `node dist/main.js`, `node dist/worker.js` or `node dist/sync.js`; port 4000). It needs `NODE_AUTH_TOKEN` as a build secret for the kits. The PDF engine needs no system fonts (fonts are bundled), but diff --git a/docs/19-updates.md b/docs/19-updates.md index fb2a3d1..5065d69 100644 --- a/docs/19-updates.md +++ b/docs/19-updates.md @@ -26,6 +26,8 @@ Both are built for `linux/amd64` and `linux/arm64`. ## Hosting with Docker Compose +Step by step on a VPS, with HTTPS, a production `.env` and backups: [20-vps.md](20-vps.md). + [`docker-compose.yml`](../docker-compose.yml) runs everything on one host: PostgreSQL, PgBouncer, Redis, `db-sync`, the api replicas, the worker, the dashboard and nginx in front of them on `HTTP_PORT` (8080). nginx sends `/graphql` and `/health` to the api and everything else to the dashboard, so the browser sees one @@ -40,7 +42,7 @@ curl -fsSL -o .env https://raw.githubusercontent.com/mgpl-diagnostics/api/main/. docker compose up -d ``` -Seed the first organisation once ([03-getting-started.md](03-getting-started.md)). Then open `http://your-server:8080` (put TLS in front: a CDN or cloud load balancer, then `TRUST_PROXY=2`, or a +Seed the first organisation once (`docker compose run --rm db-sync node dist/seed.js`, see [20-vps.md](20-vps.md)). Then open `http://your-server:8080` (put TLS in front: a CDN or cloud load balancer, then `TRUST_PROXY=2`, or a `listen 443 ssl` server in `deploy/nginx.conf`). `CORS_ORIGINS` lists the address people open. To build the api from a checkout instead of pulling it, add the build file: diff --git a/docs/20-vps.md b/docs/20-vps.md new file mode 100644 index 0000000..06f193e --- /dev/null +++ b/docs/20-vps.md @@ -0,0 +1,168 @@ +# 20 · Hosting on a VPS + +The whole system (api, worker, dashboard, PostgreSQL, PgBouncer, Redis, nginx) on one Linux server with +Docker Compose, behind HTTPS on your own domain. It uses the released images +([19-updates.md](19-updates.md)), so the server needs no Node.js, no source code and no build. + +```mermaid +flowchart LR + B["Browser"] -- "HTTPS :443" --> C["Caddy (host)
Let's Encrypt"] + C -- "127.0.0.1:8080" --> N["nginx"] + N -- "/graphql, /health" --> A["api × 2"] + N -- "everything else" --> D["dashboard"] + A --> P["PgBouncer"] --> PG[("PostgreSQL")] + A --> R[("Redis")] + W["worker"] --> R + W --> P +``` + +## 1. Server + +- Ubuntu 24.04 LTS (or Debian 12), 2 vCPU, 4 GB RAM, 40 GB disk is enough for one lab. In India, pick a + Mumbai or Hyderabad region. +- A domain or subdomain, for example `lab.example.in`, with an **A record** pointing at the server's IP. + +As root, once: + +```bash +adduser mgpl && usermod -aG sudo mgpl # a normal user for everything below +# SSH keys only: copy your key to /home/mgpl/.ssh/authorized_keys, then in /etc/ssh/sshd_config set +# PasswordAuthentication no and PermitRootLogin no, and run: systemctl restart ssh +apt update && apt -y upgrade && apt -y install ufw unattended-upgrades +ufw allow OpenSSH && ufw allow 80 && ufw allow 443 && ufw enable +curl -fsSL https://get.docker.com | sh && usermod -aG docker mgpl +``` + +Docker's published ports bypass ufw, which is why the compose stack listens on `127.0.0.1` only (step 3). + +## 2. Files + +As `mgpl`: + +```bash +mkdir -p ~/mgpl/deploy && cd ~/mgpl +base=https://raw.githubusercontent.com/mgpl-diagnostics/api/main +curl -fsSLO $base/docker-compose.yml +curl -fsSL -o deploy/nginx.conf $base/deploy/nginx.conf +curl -fsSL -o .env $base/deploy/production.env.example && chmod 600 .env +``` + +The images are on GHCR. While the packages are private, sign in once with a GitHub token that has +`read:packages`: `echo TOKEN | docker login ghcr.io -u YOUR_GITHUB_LOGIN --password-stdin`. + +## 3. Production `.env` + +Edit `.env` and replace every `CHANGE_ME` ([`deploy/production.env.example`](../deploy/production.env.example)): + +| Variable | Value | +|---|---| +| `MGPL_API_VERSION`, `MGPL_DASHBOARD_VERSION` | A release, e.g. `1.0.0` (or `latest`) | +| `HTTP_PORT` | `127.0.0.1:8080`: nginx is reachable only through Caddy | +| `TRUST_PROXY` | `2` (Caddy, then nginx): login limits see the real client IP | +| `NODE_ENV` | `production` | +| `CORS_ORIGINS` | `https://lab.example.in` | +| `PASSWORD_RESET_URL` | `https://lab.example.in/reset-password` | +| `JWT_SECRET` | `openssl rand -base64 48` | +| `POSTGRES_PASSWORD` | `openssl rand -hex 24` | +| `SMTP_URL`, `MAIL_FROM` | Your mail provider (Zoho, Gmail app password, SES, Brevo); the domain's SPF/DKIM must cover `MAIL_FROM` | +| `GRAPHIQL`, `INTROSPECTION`, `MGPL_SAMPLES` | `false` | + +Every other variable is in [09-configuration.md](09-configuration.md). For real signatures, put the PNGs in +`/home/mgpl/mgpl/secure` (`chmod 700`), uncomment the `volumes` line under `x-api` in `docker-compose.yml` +(`["./secure:/secure:ro"]`) and set `MGPL_SIGNATURE_HEADER_FILE` / `MGPL_SIGNATURE_NONHEADER_FILE`. + +## 4. Start, and create the first lab + +```bash +docker compose up -d # pulls the images, creates the tables (db-sync), starts everything +docker compose ps # api "healthy", the others "running"; db-sync "exited (0)" + +# the first lab, branch and owner (once). The password is read from your shell, not typed on the command line. +read -rsp "Owner password: " SEED_OWNER_PASSWORD && export SEED_OWNER_PASSWORD && echo +docker compose run --rm \ + -e SEED_ORG_NAME="Maa Gayatri Path Lab" -e SEED_ORG_CODE=MGPL -e SEED_ORG_GSTIN=22AAAAA0000A1Z5 \ + -e SEED_BRANCH_CODE=ARG01 -e SEED_BRANCH_NAME=Arang -e SEED_BRANCH_STATE=22 \ + -e SEED_OWNER_NAME="Lab Owner" -e SEED_OWNER_EMAIL=owner@example.in -e SEED_OWNER_PASSWORD \ + db-sync node dist/seed.js +unset SEED_OWNER_PASSWORD +``` + +`SEED_ORG_GSTIN` is optional (needed for GST returns); `SEED_BRANCH_STATE` is the GST state code (22 = +Chhattisgarh). The tax rules still need the CA's confirmation before billing ([12-deployment.md](12-deployment.md)). + +## 5. HTTPS with Caddy + +Caddy on the host gets and renews the Let's Encrypt certificate by itself. + +```bash +sudo apt install -y debian-keyring debian-archive-keyring apt-transport-https curl +curl -1sLf 'https://dl.cloudsmith.io/public/caddy/stable/gpg.key' | sudo gpg --dearmor -o /usr/share/keyrings/caddy-stable-archive-keyring.gpg +curl -1sLf 'https://dl.cloudsmith.io/public/caddy/stable/debian.deb.txt' | sudo tee /etc/apt/sources.list.d/caddy-stable.list +sudo apt update && sudo apt install -y caddy +sudo tee /etc/caddy/Caddyfile >/dev/null <<'CADDY' +lab.example.in { + encode zstd gzip + reverse_proxy 127.0.0.1:8080 +} +CADDY +sudo systemctl reload caddy +``` + +Open `https://lab.example.in`, sign in as the owner and change the password. `https://lab.example.in/health` +answers `{"ok":true,…}`. + +## 6. Backups + +PostgreSQL holds everything (patients, reports, invoices, books). A daily dump, kept 14 days, plus a copy +off the server: + +```bash +mkdir -p ~/backups && crontab -e +# 02:30 IST every day (the server clock is usually UTC: 21:00 UTC) +0 21 * * * cd ~/mgpl && docker compose exec -T postgres pg_dump -U postgres -Fc mgpl > ~/backups/mgpl-$(date +\%F).dump && find ~/backups -name 'mgpl-*.dump' -mtime +14 -delete +``` + +Copy `~/backups` off the server every day (rclone to S3-compatible storage or Google Drive, or your VPS +provider's snapshots), encrypted: these files hold patient data. Test a restore now and then: + +```bash +docker compose exec -T postgres pg_restore -U postgres -d mgpl --clean --if-exists < ~/backups/mgpl-2026-11-10.dump +``` + +Keep a copy of `.env` (with `JWT_SECRET` and `POSTGRES_PASSWORD`) in a password manager, not with the dumps. + +## 7. Updates + +Owners and admins see "Update available" in the dashboard (Organisation → Updates), with the release notes. +Back up first, then: + +```bash +cd ~/mgpl +# .env: MGPL_API_VERSION=… and MGPL_DASHBOARD_VERSION=… (the versions the Updates page shows) +docker compose pull && docker compose up -d +docker compose ps +``` + +Roll back by setting the previous versions and running the same two commands. Details: +[19-updates.md](19-updates.md). + +## Everyday commands + +```bash +docker compose ps # what runs +docker compose logs -f --tail 100 api # api logs (worker, dashboard, nginx the same way) +docker compose restart worker # restart one service +docker compose up -d --scale api=3 # more api replicas +``` + +## Checklist + +| | | +|---|---| +| ☐ | DNS A record → server; ufw allows only 22, 80, 443; SSH keys only | +| ☐ | `.env` has no `CHANGE_ME`, `chmod 600`, a copy in a password manager | +| ☐ | `HTTP_PORT=127.0.0.1:8080`, `TRUST_PROXY=2`, Caddy serves HTTPS | +| ☐ | First lab seeded, owner password changed after the first sign-in | +| ☐ | A password-reset mail arrives (SMTP works) | +| ☐ | Daily dump runs, copies leave the server, one restore tested | +| ☐ | Versions pinned in `.env` | diff --git a/docs/README.md b/docs/README.md index 1d36341..681a214 100644 --- a/docs/README.md +++ b/docs/README.md @@ -21,6 +21,7 @@ flowchart TD L --> P["17 Scaling"] G --> Q["18 Partner-lab imports"] L --> R["19 Updates"] + R --> V["20 VPS"] ``` | # | Page | What it answers | @@ -44,5 +45,6 @@ flowchart TD | 17 | [Scaling](17-scaling.md) | N replicas behind a proxy, PgBouncer, readiness, graceful shutdown, docker-compose | | 18 | [Partner-lab imports](18-imports.md) | Reading partner lab PDFs, matching, applying with provenance, NABL and signature rules | | 19 | [Releases and updates](19-updates.md) | Docker images on GHCR, hosting with Compose, the update notice, releasing | +| 20 | [Hosting on a VPS](20-vps.md) | One server with Docker Compose: production .env, first lab, HTTPS, backups, updates | | — | [examples/](examples/) | Runnable `.graphql` operations | | — | [LOCAL-AGENT-BRIEF.md](LOCAL-AGENT-BRIEF.md) | What a local agent (or a person) must do next, outside this repo | diff --git a/llms-full.txt b/llms-full.txt index 440f35e..52804c8 100644 --- a/llms-full.txt +++ b/llms-full.txt @@ -1,6 +1,6 @@ # @mgpl-diagnostics/api — full documentation and source -> Generated by scripts/llms-full.mjs. Do not edit by hand. 100 files. +> Generated by scripts/llms-full.mjs. Do not edit by hand. 101 files. ## Contents @@ -25,6 +25,7 @@ - docs/17-scaling.md - docs/18-imports.md - docs/19-updates.md +- docs/20-vps.md - docs/LOCAL-AGENT-BRIEF.md - docs/README.md - kits.json @@ -1707,7 +1708,7 @@ NODE_ENV=production node dist/worker.js # worker (with REDIS_URL) ``` Released images: `ghcr.io/mgpl-diagnostics/api` and `ghcr.io/mgpl-diagnostics/dashboard`, one tag per -release; owners and admins see new releases in the dashboard ([19-updates.md](19-updates.md)). +release; owners and admins see new releases in the dashboard ([19-updates.md](19-updates.md)). One VPS, step by step: [20-vps.md](20-vps.md). The image is built from the [`Dockerfile`](../Dockerfile) (Node 22 slim, `dist/`, production `node_modules/`; `node dist/main.js`, `node dist/worker.js` or `node dist/sync.js`; port 4000). It needs `NODE_AUTH_TOKEN` as a build secret for the kits. The PDF engine needs no system fonts (fonts are bundled), but @@ -2376,6 +2377,8 @@ Both are built for `linux/amd64` and `linux/arm64`. ## Hosting with Docker Compose +Step by step on a VPS, with HTTPS, a production `.env` and backups: [20-vps.md](20-vps.md). + [`docker-compose.yml`](../docker-compose.yml) runs everything on one host: PostgreSQL, PgBouncer, Redis, `db-sync`, the api replicas, the worker, the dashboard and nginx in front of them on `HTTP_PORT` (8080). nginx sends `/graphql` and `/health` to the api and everything else to the dashboard, so the browser sees one @@ -2390,7 +2393,7 @@ curl -fsSL -o .env https://raw.githubusercontent.com/mgpl-diagnostics/api/main/. docker compose up -d ``` -Seed the first organisation once ([03-getting-started.md](03-getting-started.md)). Then open `http://your-server:8080` (put TLS in front: a CDN or cloud load balancer, then `TRUST_PROXY=2`, or a +Seed the first organisation once (`docker compose run --rm db-sync node dist/seed.js`, see [20-vps.md](20-vps.md)). Then open `http://your-server:8080` (put TLS in front: a CDN or cloud load balancer, then `TRUST_PROXY=2`, or a `listen 443 ssl` server in `deploy/nginx.conf`). `CORS_ORIGINS` lists the address people open. To build the api from a checkout instead of pulling it, add the build file: @@ -2449,6 +2452,180 @@ Updating stays two commands run by a person on the server, on purpose: an update socket (root on the host), and every update should follow a database backup. The dashboard shows what to run. +--- + +## File: docs/20-vps.md + +# 20 · Hosting on a VPS + +The whole system (api, worker, dashboard, PostgreSQL, PgBouncer, Redis, nginx) on one Linux server with +Docker Compose, behind HTTPS on your own domain. It uses the released images +([19-updates.md](19-updates.md)), so the server needs no Node.js, no source code and no build. + +```mermaid +flowchart LR + B["Browser"] -- "HTTPS :443" --> C["Caddy (host)
Let's Encrypt"] + C -- "127.0.0.1:8080" --> N["nginx"] + N -- "/graphql, /health" --> A["api × 2"] + N -- "everything else" --> D["dashboard"] + A --> P["PgBouncer"] --> PG[("PostgreSQL")] + A --> R[("Redis")] + W["worker"] --> R + W --> P +``` + +## 1. Server + +- Ubuntu 24.04 LTS (or Debian 12), 2 vCPU, 4 GB RAM, 40 GB disk is enough for one lab. In India, pick a + Mumbai or Hyderabad region. +- A domain or subdomain, for example `lab.example.in`, with an **A record** pointing at the server's IP. + +As root, once: + +```bash +adduser mgpl && usermod -aG sudo mgpl # a normal user for everything below +# SSH keys only: copy your key to /home/mgpl/.ssh/authorized_keys, then in /etc/ssh/sshd_config set +# PasswordAuthentication no and PermitRootLogin no, and run: systemctl restart ssh +apt update && apt -y upgrade && apt -y install ufw unattended-upgrades +ufw allow OpenSSH && ufw allow 80 && ufw allow 443 && ufw enable +curl -fsSL https://get.docker.com | sh && usermod -aG docker mgpl +``` + +Docker's published ports bypass ufw, which is why the compose stack listens on `127.0.0.1` only (step 3). + +## 2. Files + +As `mgpl`: + +```bash +mkdir -p ~/mgpl/deploy && cd ~/mgpl +base=https://raw.githubusercontent.com/mgpl-diagnostics/api/main +curl -fsSLO $base/docker-compose.yml +curl -fsSL -o deploy/nginx.conf $base/deploy/nginx.conf +curl -fsSL -o .env $base/deploy/production.env.example && chmod 600 .env +``` + +The images are on GHCR. While the packages are private, sign in once with a GitHub token that has +`read:packages`: `echo TOKEN | docker login ghcr.io -u YOUR_GITHUB_LOGIN --password-stdin`. + +## 3. Production `.env` + +Edit `.env` and replace every `CHANGE_ME` ([`deploy/production.env.example`](../deploy/production.env.example)): + +| Variable | Value | +|---|---| +| `MGPL_API_VERSION`, `MGPL_DASHBOARD_VERSION` | A release, e.g. `1.0.0` (or `latest`) | +| `HTTP_PORT` | `127.0.0.1:8080`: nginx is reachable only through Caddy | +| `TRUST_PROXY` | `2` (Caddy, then nginx): login limits see the real client IP | +| `NODE_ENV` | `production` | +| `CORS_ORIGINS` | `https://lab.example.in` | +| `PASSWORD_RESET_URL` | `https://lab.example.in/reset-password` | +| `JWT_SECRET` | `openssl rand -base64 48` | +| `POSTGRES_PASSWORD` | `openssl rand -hex 24` | +| `SMTP_URL`, `MAIL_FROM` | Your mail provider (Zoho, Gmail app password, SES, Brevo); the domain's SPF/DKIM must cover `MAIL_FROM` | +| `GRAPHIQL`, `INTROSPECTION`, `MGPL_SAMPLES` | `false` | + +Every other variable is in [09-configuration.md](09-configuration.md). For real signatures, put the PNGs in +`/home/mgpl/mgpl/secure` (`chmod 700`), uncomment the `volumes` line under `x-api` in `docker-compose.yml` +(`["./secure:/secure:ro"]`) and set `MGPL_SIGNATURE_HEADER_FILE` / `MGPL_SIGNATURE_NONHEADER_FILE`. + +## 4. Start, and create the first lab + +```bash +docker compose up -d # pulls the images, creates the tables (db-sync), starts everything +docker compose ps # api "healthy", the others "running"; db-sync "exited (0)" + +# the first lab, branch and owner (once). The password is read from your shell, not typed on the command line. +read -rsp "Owner password: " SEED_OWNER_PASSWORD && export SEED_OWNER_PASSWORD && echo +docker compose run --rm \ + -e SEED_ORG_NAME="Maa Gayatri Path Lab" -e SEED_ORG_CODE=MGPL -e SEED_ORG_GSTIN=22AAAAA0000A1Z5 \ + -e SEED_BRANCH_CODE=ARG01 -e SEED_BRANCH_NAME=Arang -e SEED_BRANCH_STATE=22 \ + -e SEED_OWNER_NAME="Lab Owner" -e SEED_OWNER_EMAIL=owner@example.in -e SEED_OWNER_PASSWORD \ + db-sync node dist/seed.js +unset SEED_OWNER_PASSWORD +``` + +`SEED_ORG_GSTIN` is optional (needed for GST returns); `SEED_BRANCH_STATE` is the GST state code (22 = +Chhattisgarh). The tax rules still need the CA's confirmation before billing ([12-deployment.md](12-deployment.md)). + +## 5. HTTPS with Caddy + +Caddy on the host gets and renews the Let's Encrypt certificate by itself. + +```bash +sudo apt install -y debian-keyring debian-archive-keyring apt-transport-https curl +curl -1sLf 'https://dl.cloudsmith.io/public/caddy/stable/gpg.key' | sudo gpg --dearmor -o /usr/share/keyrings/caddy-stable-archive-keyring.gpg +curl -1sLf 'https://dl.cloudsmith.io/public/caddy/stable/debian.deb.txt' | sudo tee /etc/apt/sources.list.d/caddy-stable.list +sudo apt update && sudo apt install -y caddy +sudo tee /etc/caddy/Caddyfile >/dev/null <<'CADDY' +lab.example.in { + encode zstd gzip + reverse_proxy 127.0.0.1:8080 +} +CADDY +sudo systemctl reload caddy +``` + +Open `https://lab.example.in`, sign in as the owner and change the password. `https://lab.example.in/health` +answers `{"ok":true,…}`. + +## 6. Backups + +PostgreSQL holds everything (patients, reports, invoices, books). A daily dump, kept 14 days, plus a copy +off the server: + +```bash +mkdir -p ~/backups && crontab -e +# 02:30 IST every day (the server clock is usually UTC: 21:00 UTC) +0 21 * * * cd ~/mgpl && docker compose exec -T postgres pg_dump -U postgres -Fc mgpl > ~/backups/mgpl-$(date +\%F).dump && find ~/backups -name 'mgpl-*.dump' -mtime +14 -delete +``` + +Copy `~/backups` off the server every day (rclone to S3-compatible storage or Google Drive, or your VPS +provider's snapshots), encrypted: these files hold patient data. Test a restore now and then: + +```bash +docker compose exec -T postgres pg_restore -U postgres -d mgpl --clean --if-exists < ~/backups/mgpl-2026-11-10.dump +``` + +Keep a copy of `.env` (with `JWT_SECRET` and `POSTGRES_PASSWORD`) in a password manager, not with the dumps. + +## 7. Updates + +Owners and admins see "Update available" in the dashboard (Organisation → Updates), with the release notes. +Back up first, then: + +```bash +cd ~/mgpl +# .env: MGPL_API_VERSION=… and MGPL_DASHBOARD_VERSION=… (the versions the Updates page shows) +docker compose pull && docker compose up -d +docker compose ps +``` + +Roll back by setting the previous versions and running the same two commands. Details: +[19-updates.md](19-updates.md). + +## Everyday commands + +```bash +docker compose ps # what runs +docker compose logs -f --tail 100 api # api logs (worker, dashboard, nginx the same way) +docker compose restart worker # restart one service +docker compose up -d --scale api=3 # more api replicas +``` + +## Checklist + +| | | +|---|---| +| ☐ | DNS A record → server; ufw allows only 22, 80, 443; SSH keys only | +| ☐ | `.env` has no `CHANGE_ME`, `chmod 600`, a copy in a password manager | +| ☐ | `HTTP_PORT=127.0.0.1:8080`, `TRUST_PROXY=2`, Caddy serves HTTPS | +| ☐ | First lab seeded, owner password changed after the first sign-in | +| ☐ | A password-reset mail arrives (SMTP works) | +| ☐ | Daily dump runs, copies leave the server, one restore tested | +| ☐ | Versions pinned in `.env` | + + --- ## File: docs/LOCAL-AGENT-BRIEF.md @@ -2688,6 +2865,7 @@ flowchart TD L --> P["17 Scaling"] G --> Q["18 Partner-lab imports"] L --> R["19 Updates"] + R --> V["20 VPS"] ``` | # | Page | What it answers | @@ -2711,6 +2889,7 @@ flowchart TD | 17 | [Scaling](17-scaling.md) | N replicas behind a proxy, PgBouncer, readiness, graceful shutdown, docker-compose | | 18 | [Partner-lab imports](18-imports.md) | Reading partner lab PDFs, matching, applying with provenance, NABL and signature rules | | 19 | [Releases and updates](19-updates.md) | Docker images on GHCR, hosting with Compose, the update notice, releasing | +| 20 | [Hosting on a VPS](20-vps.md) | One server with Docker Compose: production .env, first lab, HTTPS, backups, updates | | — | [examples/](examples/) | Runnable `.graphql` operations | | — | [LOCAL-AGENT-BRIEF.md](LOCAL-AGENT-BRIEF.md) | What a local agent (or a person) must do next, outside this repo | diff --git a/tsup.config.ts b/tsup.config.ts index 233bdb7..e4fee19 100644 --- a/tsup.config.ts +++ b/tsup.config.ts @@ -1,8 +1,9 @@ import { defineConfig } from "tsup"; -// The server bundle: our code only; the kits and drivers stay in node_modules. +// The server bundle: our code only; the kits and drivers stay in node_modules. dist/seed.js lets the +// Docker image create the first lab (`docker compose run --rm db-sync node dist/seed.js`). export default defineConfig({ - entry: { main: "src/main.ts", worker: "src/worker.ts", sync: "src/sync.ts", index: "src/index.ts" }, + entry: { main: "src/main.ts", worker: "src/worker.ts", sync: "src/sync.ts", seed: "scripts/seed.ts", index: "src/index.ts" }, format: ["esm"], dts: { entry: { index: "src/index.ts" } }, target: "node20",