docs(license): self-hosted server runbook + multi-tenancy plan
Adds SETUP-LICENSE-SERVER.md — end-to-end install runbook for the license server on the existing invixiom box (Ubuntu 24.04). Covers DNS, system packages, Postgres + API in Docker, dedicated system user, secrets layout under /srv/datatools-license/secrets (mode 400), nginx config in a separate sites-available/unalogix file, Let's Encrypt cert issuance, smoke tests, backups, monitoring, key rotation, and rollback. Multi-tenancy is explicit at every layer: separate DNS zone (unalogix.com vs invixiom.com), separate nginx file, separate TLS cert, dedicated backend ports (8090 for the API, 5433 for Postgres, both localhost-only), separate docker compose project and volume. No invixiom service is touched. LICENSE-SERVER.md updated: hosting choice moved from "Fly.io / Render" (rejected) to self-hosted (decided). Points at the new runbook for ops specifics. ADMIN.md pointer table updated. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
@@ -266,3 +266,4 @@ independent secure locations.
|
|||||||
| `~/.datatools/license.json` | Where activated licenses are stored on each machine |
|
| `~/.datatools/license.json` | Where activated licenses are stored on each machine |
|
||||||
| `~/.datatools-creator/issued.jsonl` | Creator-side issuance log (one JSON line per mint) |
|
| `~/.datatools-creator/issued.jsonl` | Creator-side issuance log (one JSON line per mint) |
|
||||||
| `docs/LICENSE-SERVER.md` | Design for the future online issuance + record-keeping system |
|
| `docs/LICENSE-SERVER.md` | Design for the future online issuance + record-keeping system |
|
||||||
|
| `docs/SETUP-LICENSE-SERVER.md` | Self-hosted server install runbook (DNS, Docker, nginx, TLS, backups) |
|
||||||
|
|||||||
@@ -223,9 +223,11 @@ across any of them — that's the whole point.
|
|||||||
|
|
||||||
## Open questions
|
## Open questions
|
||||||
|
|
||||||
- **Hosting choice.** A managed Postgres + small Python app is well
|
- **Hosting choice.** *Decided: self-hosted* on the existing
|
||||||
under $20/mo at the expected volume. Fly.io and Render are the
|
`46.225.166.142` box alongside the `*.invixiom.com` services.
|
||||||
obvious candidates; AWS is overkill at this scale.
|
Runbook in `SETUP-LICENSE-SERVER.md`. Operator owns uptime,
|
||||||
|
backups, TLS renewal, and key custody — see that doc's
|
||||||
|
"Operational concerns" section.
|
||||||
- **Per-seat or per-device limits?** v1 says no. Revisit if/when
|
- **Per-seat or per-device limits?** v1 says no. Revisit if/when
|
||||||
abuse is observable.
|
abuse is observable.
|
||||||
- **Email delivery.** Postmark or SES — both fine. Pick whichever the
|
- **Email delivery.** Postmark or SES — both fine. Pick whichever the
|
||||||
|
|||||||
553
docs/SETUP-LICENSE-SERVER.md
Normal file
553
docs/SETUP-LICENSE-SERVER.md
Normal file
@@ -0,0 +1,553 @@
|
|||||||
|
# SETUP — Self-hosted license server runbook
|
||||||
|
|
||||||
|
End-to-end build instructions for `licenses.datatools.unalogix.com` on
|
||||||
|
the existing invixiom box (Ubuntu 24.04, public IP `46.225.166.142`).
|
||||||
|
|
||||||
|
Audience: creator/operator. Read top to bottom on first install; use as
|
||||||
|
a reference thereafter.
|
||||||
|
|
||||||
|
Companions:
|
||||||
|
- `LICENSE-SERVER.md` — the architecture / design rationale
|
||||||
|
- `ADMIN.md` — day-2 ops (minting comps, looking at the issuance log)
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 0. Multi-tenancy: where this lands among existing services
|
||||||
|
|
||||||
|
This box already hosts the `*.invixiom.com` family (kasm, files, lifeos,
|
||||||
|
code, gitea) via one shared nginx + one shared Let's Encrypt cert.
|
||||||
|
DataTools is intentionally separated from that stack at every layer:
|
||||||
|
|
||||||
|
| Layer | Existing | New |
|
||||||
|
|---|---|---|
|
||||||
|
| **DNS zone** | `invixiom.com` | `unalogix.com` (different TLD) |
|
||||||
|
| **nginx file** | `/etc/nginx/sites-available/invixiom` | `/etc/nginx/sites-available/unalogix` |
|
||||||
|
| **nginx symlink** | `sites-enabled/invixiom` | `sites-enabled/unalogix` |
|
||||||
|
| **TLS cert** | `letsencrypt/live/kasm.invixiom.com[-0001]` | `letsencrypt/live/datatools.unalogix.com` |
|
||||||
|
| **Backend port** | 8000, 8002, 8003, 8080, 8081, 8443 | **8090** (mint API), **5433** (Postgres, localhost-only) |
|
||||||
|
| **Docker compose project** | per-service (kasm, lifeos, gitea) | `datatools-license` |
|
||||||
|
| **Docker volume** | per service | `datatools_pg_data` |
|
||||||
|
| **Filesystem root** | various | `/srv/datatools-license/` |
|
||||||
|
| **System user** | various | `datatools-api` (UID auto-assigned, no shell) |
|
||||||
|
|
||||||
|
Nothing in the invixiom stack is read, modified, or referenced by the
|
||||||
|
datatools stack. Restart, upgrade, or remove either without affecting
|
||||||
|
the other.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 1. Pre-flight checklist (off-box, before any commands run)
|
||||||
|
|
||||||
|
These have to be done by the operator outside this box. The build
|
||||||
|
won't proceed without them.
|
||||||
|
|
||||||
|
### 1a. DNS records
|
||||||
|
|
||||||
|
In your `unalogix.com` registrar / DNS panel, add:
|
||||||
|
|
||||||
|
```
|
||||||
|
A datatools.unalogix.com 46.225.166.142
|
||||||
|
A licenses.datatools.unalogix.com 46.225.166.142
|
||||||
|
```
|
||||||
|
|
||||||
|
Verify before continuing:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
dig +short datatools.unalogix.com
|
||||||
|
dig +short licenses.datatools.unalogix.com
|
||||||
|
# Both should print: 46.225.166.142
|
||||||
|
```
|
||||||
|
|
||||||
|
DNS propagation can take 1–60 minutes. Let's Encrypt won't issue
|
||||||
|
certs until DNS resolves correctly.
|
||||||
|
|
||||||
|
### 1b. Postmark account (transactional email)
|
||||||
|
|
||||||
|
1. Sign up at https://postmarkapp.com (free 100 emails/mo, $15/mo for
|
||||||
|
the volume range we'll be in).
|
||||||
|
2. Verify the `unalogix.com` domain (DNS TXT/CNAME records — Postmark
|
||||||
|
will tell you exactly what to add).
|
||||||
|
3. Create a Server, copy the **Server API Token**. Stash it; we'll put
|
||||||
|
it in the app's `.env`.
|
||||||
|
4. Configure the sender address: `licenses@datatools.unalogix.com`.
|
||||||
|
|
||||||
|
If you prefer SES, Mailgun, Resend, etc. — fine, just swap the
|
||||||
|
adapter (see §6). Postmark is the recommended default.
|
||||||
|
|
||||||
|
### 1c. Cloudflare in front (recommended)
|
||||||
|
|
||||||
|
Move `unalogix.com` DNS hosting to Cloudflare and enable proxy ("orange
|
||||||
|
cloud") on both subdomains. Gets you free DDoS protection, WAF, and rate
|
||||||
|
limiting. **Origin TLS still goes through Let's Encrypt on this box**;
|
||||||
|
Cloudflare adds a second TLS hop in front. Cert renewal still works
|
||||||
|
because we use HTTP-01 challenge on the origin, which Cloudflare
|
||||||
|
proxies transparently.
|
||||||
|
|
||||||
|
If you skip this, the public webhook endpoint is directly hammerable.
|
||||||
|
Not catastrophic at low scale, but the free protection is worth taking.
|
||||||
|
|
||||||
|
### 1d. Gumroad webhook secret
|
||||||
|
|
||||||
|
In Gumroad's seller dashboard → Settings → Advanced → "Ping URL":
|
||||||
|
|
||||||
|
```
|
||||||
|
URL: https://licenses.datatools.unalogix.com/webhooks/gumroad
|
||||||
|
Secret: <generate a random 32-char hex; save it for the .env>
|
||||||
|
```
|
||||||
|
|
||||||
|
Don't enter this until §10 ("PR 2 cutover") — the endpoint won't exist
|
||||||
|
yet during the Mint API build.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 2. One-time host setup
|
||||||
|
|
||||||
|
Run as `root` (or via `sudo`).
|
||||||
|
|
||||||
|
```bash
|
||||||
|
# Update apt cache and pull in the bits the rest of the doc needs.
|
||||||
|
apt-get update
|
||||||
|
apt-get install -y \
|
||||||
|
docker-compose-plugin \
|
||||||
|
certbot \
|
||||||
|
python3-certbot-nginx \
|
||||||
|
postgresql-client-16 # for psql to reach the containerized DB
|
||||||
|
|
||||||
|
# Sanity check: docker + compose v2 are already installed via Docker CE.
|
||||||
|
docker --version
|
||||||
|
docker compose version
|
||||||
|
|
||||||
|
# Create the system user the app process will run as (no shell, no home).
|
||||||
|
adduser --system --group --no-create-home --shell /usr/sbin/nologin datatools-api
|
||||||
|
|
||||||
|
# Filesystem layout under /srv (separate from /opt to make the
|
||||||
|
# multi-tenant boundary obvious on disk).
|
||||||
|
install -d -o datatools-api -g datatools-api -m 750 /srv/datatools-license
|
||||||
|
install -d -o datatools-api -g datatools-api -m 750 /srv/datatools-license/app
|
||||||
|
install -d -o datatools-api -g datatools-api -m 750 /srv/datatools-license/secrets
|
||||||
|
install -d -o datatools-api -g datatools-api -m 750 /srv/datatools-license/backups
|
||||||
|
```
|
||||||
|
|
||||||
|
The `secrets/` dir is mode 750 owned by `datatools-api`. The private
|
||||||
|
signing key and Postmark token live there as mode-400 files — never
|
||||||
|
in environment-variable-via-systemd-EnvironmentFile, never in the
|
||||||
|
docker-compose file, never anywhere `root` doesn't need to look.
|
||||||
|
|
||||||
|
### Firewall recommendation (separate decision)
|
||||||
|
|
||||||
|
The box currently runs without UFW. Enabling it now would affect all
|
||||||
|
existing services. Two options:
|
||||||
|
|
||||||
|
- **(A) Don't enable UFW.** Leave the cloud provider's network firewall
|
||||||
|
as the perimeter. This is the current state.
|
||||||
|
- **(B) Enable UFW with `allow 22, 80, 443` only.** Forces every Docker
|
||||||
|
service to bind to `127.0.0.1` (some currently bind `0.0.0.0`). Will
|
||||||
|
break any direct-port access until those binds are updated.
|
||||||
|
|
||||||
|
Default for this runbook: **(A)**. Revisit independently of the
|
||||||
|
DataTools rollout. The DataTools containers always bind to `127.0.0.1`
|
||||||
|
regardless.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 3. Database (Postgres in Docker)
|
||||||
|
|
||||||
|
Postgres lives inside the datatools compose project — separate from
|
||||||
|
every other service on the box, separate volume, separate port,
|
||||||
|
localhost-only binding.
|
||||||
|
|
||||||
|
`/srv/datatools-license/compose.yml`:
|
||||||
|
|
||||||
|
```yaml
|
||||||
|
services:
|
||||||
|
postgres:
|
||||||
|
image: postgres:16-alpine
|
||||||
|
container_name: datatools-postgres
|
||||||
|
restart: unless-stopped
|
||||||
|
environment:
|
||||||
|
POSTGRES_DB: datatools_licenses
|
||||||
|
POSTGRES_USER: datatools_api
|
||||||
|
POSTGRES_PASSWORD_FILE: /run/secrets/pg_password
|
||||||
|
secrets:
|
||||||
|
- pg_password
|
||||||
|
volumes:
|
||||||
|
- datatools_pg_data:/var/lib/postgresql/data
|
||||||
|
ports:
|
||||||
|
- "127.0.0.1:5433:5432" # localhost-only, non-default port
|
||||||
|
healthcheck:
|
||||||
|
test: ["CMD-SHELL", "pg_isready -U datatools_api -d datatools_licenses"]
|
||||||
|
interval: 10s
|
||||||
|
timeout: 3s
|
||||||
|
retries: 5
|
||||||
|
|
||||||
|
api:
|
||||||
|
image: datatools-license-api:latest # built locally; see §4
|
||||||
|
container_name: datatools-api
|
||||||
|
restart: unless-stopped
|
||||||
|
depends_on:
|
||||||
|
postgres:
|
||||||
|
condition: service_healthy
|
||||||
|
environment:
|
||||||
|
DATABASE_URL: postgresql://datatools_api@postgres:5432/datatools_licenses
|
||||||
|
DATATOOLS_LICENSE_PUBKEY: ${DATATOOLS_LICENSE_PUBKEY}
|
||||||
|
POSTMARK_TOKEN_FILE: /run/secrets/postmark_token
|
||||||
|
DATATOOLS_LICENSE_PRIVKEY_FILE: /run/secrets/license_privkey
|
||||||
|
GUMROAD_WEBHOOK_SECRET_FILE: /run/secrets/gumroad_secret
|
||||||
|
PG_PASSWORD_FILE: /run/secrets/pg_password
|
||||||
|
secrets:
|
||||||
|
- pg_password
|
||||||
|
- postmark_token
|
||||||
|
- license_privkey
|
||||||
|
- gumroad_secret
|
||||||
|
ports:
|
||||||
|
- "127.0.0.1:8090:8000" # localhost-only; nginx is the only path in
|
||||||
|
healthcheck:
|
||||||
|
test: ["CMD", "curl", "-f", "http://localhost:8000/health"]
|
||||||
|
interval: 30s
|
||||||
|
timeout: 3s
|
||||||
|
retries: 3
|
||||||
|
|
||||||
|
secrets:
|
||||||
|
pg_password: { file: ./secrets/pg_password }
|
||||||
|
postmark_token: { file: ./secrets/postmark_token }
|
||||||
|
license_privkey: { file: ./secrets/license_privkey }
|
||||||
|
gumroad_secret: { file: ./secrets/gumroad_secret }
|
||||||
|
|
||||||
|
volumes:
|
||||||
|
datatools_pg_data:
|
||||||
|
name: datatools_pg_data
|
||||||
|
```
|
||||||
|
|
||||||
|
Populate the secrets (each file should contain the value with no
|
||||||
|
trailing newline):
|
||||||
|
|
||||||
|
```bash
|
||||||
|
cd /srv/datatools-license
|
||||||
|
|
||||||
|
# Random 32-char hex DB password
|
||||||
|
openssl rand -hex 32 > secrets/pg_password
|
||||||
|
|
||||||
|
# Postmark server API token from §1b
|
||||||
|
echo -n "<paste-token>" > secrets/postmark_token
|
||||||
|
|
||||||
|
# Ed25519 private signing key (from password manager OR generate-keypair.py)
|
||||||
|
echo -n "<paste-hex-privkey>" > secrets/license_privkey
|
||||||
|
|
||||||
|
# Gumroad webhook secret from §1d (generate when needed; placeholder OK for PR 1)
|
||||||
|
openssl rand -hex 32 > secrets/gumroad_secret
|
||||||
|
|
||||||
|
# Lock everything down.
|
||||||
|
chmod 400 secrets/*
|
||||||
|
chown -R datatools-api:datatools-api secrets/
|
||||||
|
```
|
||||||
|
|
||||||
|
The corresponding **public** key for `DATATOOLS_LICENSE_PUBKEY` goes
|
||||||
|
in `/srv/datatools-license/.env` (it's not secret — it's already in
|
||||||
|
every shipped binary):
|
||||||
|
|
||||||
|
```bash
|
||||||
|
echo "DATATOOLS_LICENSE_PUBKEY=<hex-pubkey>" > /srv/datatools-license/.env
|
||||||
|
chmod 640 /srv/datatools-license/.env
|
||||||
|
chown datatools-api:datatools-api /srv/datatools-license/.env
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 4. App image build
|
||||||
|
|
||||||
|
The Mint API source lives in this repo under `server/` (new directory
|
||||||
|
introduced by PR 1). Build the Docker image:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
cd /srv/datatools-license/app
|
||||||
|
git clone https://git.invixiom.com/giteadmin/datatools-dev.git .
|
||||||
|
docker build -t datatools-license-api:latest -f server/Dockerfile server/
|
||||||
|
```
|
||||||
|
|
||||||
|
Schema bootstrap (one-time, after first `docker compose up`):
|
||||||
|
|
||||||
|
```bash
|
||||||
|
docker compose exec api alembic upgrade head
|
||||||
|
```
|
||||||
|
|
||||||
|
Smoke test:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
curl -s http://127.0.0.1:8090/health
|
||||||
|
# expects: {"status":"ok","db":"ok"}
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 5. nginx config
|
||||||
|
|
||||||
|
`/etc/nginx/sites-available/unalogix` — **new file**, do not merge
|
||||||
|
into `invixiom`:
|
||||||
|
|
||||||
|
```nginx
|
||||||
|
# Marketing / product site (datatools.unalogix.com) — static for now.
|
||||||
|
server {
|
||||||
|
listen 80;
|
||||||
|
server_name datatools.unalogix.com licenses.datatools.unalogix.com;
|
||||||
|
return 301 https://$host$request_uri;
|
||||||
|
}
|
||||||
|
|
||||||
|
server {
|
||||||
|
listen 443 ssl http2;
|
||||||
|
server_name datatools.unalogix.com;
|
||||||
|
|
||||||
|
ssl_certificate /etc/letsencrypt/live/datatools.unalogix.com/fullchain.pem;
|
||||||
|
ssl_certificate_key /etc/letsencrypt/live/datatools.unalogix.com/privkey.pem;
|
||||||
|
|
||||||
|
root /srv/datatools-license/site; # static landing page; create later
|
||||||
|
index index.html;
|
||||||
|
|
||||||
|
location / {
|
||||||
|
try_files $uri $uri/ =404;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
# License operations subdomain.
|
||||||
|
server {
|
||||||
|
listen 443 ssl http2;
|
||||||
|
server_name licenses.datatools.unalogix.com;
|
||||||
|
|
||||||
|
ssl_certificate /etc/letsencrypt/live/datatools.unalogix.com/fullchain.pem;
|
||||||
|
ssl_certificate_key /etc/letsencrypt/live/datatools.unalogix.com/privkey.pem;
|
||||||
|
|
||||||
|
# Block /internal/* from the public side as defense-in-depth.
|
||||||
|
# (The app also enforces this server-side; this is layered.)
|
||||||
|
location /internal/ {
|
||||||
|
return 404;
|
||||||
|
}
|
||||||
|
|
||||||
|
location / {
|
||||||
|
proxy_pass http://127.0.0.1:8090;
|
||||||
|
proxy_http_version 1.1;
|
||||||
|
proxy_set_header Host $host;
|
||||||
|
proxy_set_header X-Real-IP $remote_addr;
|
||||||
|
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
|
||||||
|
proxy_set_header X-Forwarded-Proto $scheme;
|
||||||
|
|
||||||
|
# Gumroad webhook payloads are tiny but tighten anyway.
|
||||||
|
client_max_body_size 1m;
|
||||||
|
|
||||||
|
# Basic rate limiting: 30 req/min/IP on /webhooks/* and /portal/*.
|
||||||
|
# Tune in nginx.conf with a `limit_req_zone` directive.
|
||||||
|
# limit_req zone=licenses burst=10 nodelay;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
Enable + reload:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
ln -s /etc/nginx/sites-available/unalogix /etc/nginx/sites-enabled/unalogix
|
||||||
|
nginx -t # validate
|
||||||
|
systemctl reload nginx
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 6. TLS cert
|
||||||
|
|
||||||
|
Use the standalone http-01 challenge (nginx-plugin works too; this is
|
||||||
|
slightly more explicit):
|
||||||
|
|
||||||
|
```bash
|
||||||
|
certbot certonly \
|
||||||
|
--webroot -w /var/www/html \
|
||||||
|
-d datatools.unalogix.com \
|
||||||
|
-d licenses.datatools.unalogix.com \
|
||||||
|
--agree-tos \
|
||||||
|
--email michael.dombaugh@gmail.com \
|
||||||
|
--non-interactive
|
||||||
|
```
|
||||||
|
|
||||||
|
Cert lands at `/etc/letsencrypt/live/datatools.unalogix.com/`.
|
||||||
|
Auto-renewal is already configured by the certbot package (systemd
|
||||||
|
timer `certbot.timer`). Confirm:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
systemctl list-timers certbot.timer
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 7. Bring it up
|
||||||
|
|
||||||
|
```bash
|
||||||
|
cd /srv/datatools-license
|
||||||
|
docker compose up -d
|
||||||
|
docker compose ps # both services should be 'running (healthy)'
|
||||||
|
docker compose logs -f api
|
||||||
|
```
|
||||||
|
|
||||||
|
Public smoke test:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
curl -s https://licenses.datatools.unalogix.com/health
|
||||||
|
# expects: {"status":"ok","db":"ok"}
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 8. Verification — end-to-end internal mint
|
||||||
|
|
||||||
|
From your laptop (NOT the server), open an SSH tunnel for the internal
|
||||||
|
endpoint:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
ssh -L 8090:127.0.0.1:8090 michael@46.225.166.142 -N
|
||||||
|
# Leave running; in another terminal:
|
||||||
|
|
||||||
|
curl -X POST http://127.0.0.1:8090/internal/mint \
|
||||||
|
-H "Authorization: Bearer $DATATOOLS_ADMIN_TOKEN" \
|
||||||
|
-H "Content-Type: application/json" \
|
||||||
|
-d '{
|
||||||
|
"name":"Test Buyer",
|
||||||
|
"email":"test@example.com",
|
||||||
|
"tier":"core",
|
||||||
|
"years":1,
|
||||||
|
"source":"manual",
|
||||||
|
"notes":"smoke test"
|
||||||
|
}'
|
||||||
|
```
|
||||||
|
|
||||||
|
Expected: 200 + a `DTLIC2:...` blob + a row inserted in the `licenses`
|
||||||
|
table. Confirm with:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
docker compose exec postgres \
|
||||||
|
psql -U datatools_api -d datatools_licenses \
|
||||||
|
-c "SELECT license_key, email, tier, source FROM licenses;"
|
||||||
|
```
|
||||||
|
|
||||||
|
Then **revoke the test row** before going further:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
docker compose exec postgres \
|
||||||
|
psql -U datatools_api -d datatools_licenses \
|
||||||
|
-c "DELETE FROM licenses WHERE email = 'test@example.com';"
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 9. Operational concerns
|
||||||
|
|
||||||
|
### Backups (Postgres → off-site)
|
||||||
|
|
||||||
|
`/etc/cron.daily/datatools-license-backup`:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
#!/bin/bash
|
||||||
|
set -euo pipefail
|
||||||
|
TS=$(date -u +%Y%m%dT%H%M%SZ)
|
||||||
|
OUT=/srv/datatools-license/backups/db-${TS}.sql.gz
|
||||||
|
docker compose -f /srv/datatools-license/compose.yml exec -T postgres \
|
||||||
|
pg_dump -U datatools_api datatools_licenses | gzip > "$OUT"
|
||||||
|
chmod 600 "$OUT"
|
||||||
|
# Off-site copy — pick one:
|
||||||
|
# rclone copy "$OUT" remote:datatools-license-backups/
|
||||||
|
# aws s3 cp "$OUT" s3://datatools-backups/db/ --sse AES256
|
||||||
|
find /srv/datatools-license/backups -name 'db-*.sql.gz' -mtime +30 -delete
|
||||||
|
```
|
||||||
|
|
||||||
|
Pick an off-site target. Without one, a disk failure loses every
|
||||||
|
customer record. Test the restore at least once on a staging copy.
|
||||||
|
|
||||||
|
### Monitoring
|
||||||
|
|
||||||
|
External uptime probe (free):
|
||||||
|
1. UptimeRobot account → add monitor for `https://licenses.datatools.unalogix.com/health`.
|
||||||
|
2. 5-minute interval, alert to email/SMS.
|
||||||
|
|
||||||
|
Container health is already handled by `restart: unless-stopped` +
|
||||||
|
healthcheck. To see recent failures:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
docker compose ps # last health-check status
|
||||||
|
docker compose logs api --tail 200
|
||||||
|
journalctl -u docker --since '1 hour ago' | grep datatools
|
||||||
|
```
|
||||||
|
|
||||||
|
### Log rotation
|
||||||
|
|
||||||
|
Docker handles container logs; cap their size in
|
||||||
|
`/etc/docker/daemon.json`:
|
||||||
|
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"log-driver": "json-file",
|
||||||
|
"log-opts": {
|
||||||
|
"max-size": "10m",
|
||||||
|
"max-file": "3"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
Then `systemctl restart docker` (this restarts all containers — schedule
|
||||||
|
during a quiet window).
|
||||||
|
|
||||||
|
### Key rotation (future)
|
||||||
|
|
||||||
|
If the private signing key is ever compromised:
|
||||||
|
|
||||||
|
1. Generate a new keypair (`scripts/generate_keypair.py`).
|
||||||
|
2. Build and ship a desktop release with the new pubkey embedded.
|
||||||
|
3. Update `/srv/datatools-license/secrets/license_privkey` and
|
||||||
|
`/srv/datatools-license/.env`'s pubkey.
|
||||||
|
4. `docker compose restart api`.
|
||||||
|
5. Re-issue every active license (script that queries the DB, calls
|
||||||
|
`/internal/mint`, emails buyers). Old blobs will fail verification
|
||||||
|
in the new desktop build.
|
||||||
|
|
||||||
|
Plan a 90-day overlap window where the desktop verifies against
|
||||||
|
*both* keys before retiring the old pubkey. (Verification logic
|
||||||
|
change to the desktop app — not in scope for PR 1.)
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 10. PR cutover sequence
|
||||||
|
|
||||||
|
This runbook covers the box-level scaffolding. Application code lands
|
||||||
|
in three independently shippable PRs:
|
||||||
|
|
||||||
|
| PR | Adds | Ship gate | Webhook live? |
|
||||||
|
|---|---|---|---|
|
||||||
|
| **1** | Source-agnostic Mint API + Postgres + `datatools-admin mint` CLI | Operator can mint a comp license through the server | No |
|
||||||
|
| **2** | Gumroad adapter + webhook receiver + email send | Real Gumroad sale auto-mints + emails buyer | **Yes** (enable in Gumroad dashboard at this PR's deploy) |
|
||||||
|
| **3** | Renewal / re-delivery portal | Buyer self-services renewals and lost-blob re-delivery | (unchanged) |
|
||||||
|
|
||||||
|
§1d (Gumroad webhook URL) is **filled in during PR 2's deploy**, not
|
||||||
|
before. Until then the endpoint returns 404.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 11. Rollback
|
||||||
|
|
||||||
|
Each component is independently reversible.
|
||||||
|
|
||||||
|
```bash
|
||||||
|
# Stop and remove containers (DB volume persists)
|
||||||
|
docker compose -f /srv/datatools-license/compose.yml down
|
||||||
|
|
||||||
|
# Full teardown including DB (DESTRUCTIVE — backup first)
|
||||||
|
docker compose -f /srv/datatools-license/compose.yml down -v
|
||||||
|
|
||||||
|
# Remove nginx site
|
||||||
|
rm /etc/nginx/sites-enabled/unalogix
|
||||||
|
nginx -t && systemctl reload nginx
|
||||||
|
|
||||||
|
# Revoke + delete TLS cert
|
||||||
|
certbot delete --cert-name datatools.unalogix.com
|
||||||
|
|
||||||
|
# Remove filesystem
|
||||||
|
rm -rf /srv/datatools-license # NOTE: includes secrets dir; backup first
|
||||||
|
|
||||||
|
# Remove system user
|
||||||
|
deluser datatools-api
|
||||||
|
delgroup datatools-api
|
||||||
|
```
|
||||||
|
|
||||||
|
DNS records can stay or be removed — they're not on this host.
|
||||||
Reference in New Issue
Block a user