Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
4 changes: 2 additions & 2 deletions .env.example
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand All @@ -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
Expand Down
2 changes: 1 addition & 1 deletion .github/workflows/release.yml
Original file line number Diff line number Diff line change
Expand Up @@ -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
44 changes: 44 additions & 0 deletions deploy/production.env.example
Original file line number Diff line number Diff line change
@@ -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 <no-reply@CHANGE_ME.example.in>"

# ── 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
2 changes: 1 addition & 1 deletion docker-compose.yml
Original file line number Diff line number Diff line change
@@ -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
Expand Down
2 changes: 1 addition & 1 deletion docs/12-deployment.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
4 changes: 3 additions & 1 deletion docs/19-updates.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand All @@ -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:
Expand Down
168 changes: 168 additions & 0 deletions docs/20-vps.md
Original file line number Diff line number Diff line change
@@ -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)<br/>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` |
2 changes: 2 additions & 0 deletions docs/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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 |
Expand All @@ -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 |
Loading
Loading