Files
datatools-dev/docs/SETUP-LICENSE-SERVER.md
Michael 1cf69dd23b docs(license): runbook fixes from PR 1 self-host deploy
Two real-world footguns surfaced during the first live deploy:

1. docker-compose's uid/gid/mode long-form on file-based secrets is
   silently ignored — that's a swarm-mode-only feature. The
   container app user (UID 10001 from the Dockerfile) cannot read
   a mode-400 file whose host UID it doesn't match. Fix is to
   chown the secret files to 10001 directly; host-side access
   control stays gated by the parent dir's mode 750.

2. nginx 1.24 (Ubuntu 24.04 default) rejects the standalone
   "http2 on;" directive (that arrived in 1.25). Use the legacy
   "listen 443 ssl http2;" combined form. Noted prominently so the
   next deploy doesn't trip on it.

Also realigned §3's compose example to what actually got deployed
for PR 1 — only pg_password + admin_token secrets, postmark /
gumroad / license_privkey commented out as PR 2 / production-key
follow-ups.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-14 01:17:05 +00:00

584 lines
18 KiB
Markdown
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
# 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 160 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.
> **Gotcha — secret file ownership UID.** Docker compose's
> `uid:`/`gid:`/`mode:` long-form on `secrets:` is silently ignored
> for **file-based** secrets (it's a swarm-mode-only feature). The
> file inside the container appears with whatever ownership it has
> on the host, and the API runs as UID 10001 (the `app` user from
> the Dockerfile). So chown the actual files to **10001** (a numeric
> UID that doesn't exist on the host — that's fine, chown accepts
> it) and rely on the parent dir's mode 750 + ownership for host-side
> access control. See §3 below for the corrected `chown` step.
### 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:
build:
context: ./app
dockerfile: server/Dockerfile
image: datatools-license-api:latest
container_name: datatools-api
restart: unless-stopped
depends_on:
postgres:
condition: service_healthy
environment:
DATABASE_URL: postgresql+psycopg://datatools_api@postgres:5432/datatools_licenses
PG_PASSWORD_FILE: /run/secrets/pg_password
DATATOOLS_ADMIN_TOKEN_FILE: /run/secrets/admin_token
# PR 2 adds: POSTMARK_TOKEN_FILE, GUMROAD_WEBHOOK_SECRET_FILE.
# Production keypair (replaces in-tree dev key): set
# DATATOOLS_LICENSE_PRIVKEY_FILE: /run/secrets/license_privkey
# and DATATOOLS_LICENSE_PUBKEY: <hex> before shipping v1.0.
secrets:
- pg_password
- admin_token
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 }
admin_token: { file: ./secrets/admin_token }
# PR 2 adds: postmark_token, gumroad_secret. Production keypair
# rotation adds: license_privkey.
volumes:
datatools_pg_data:
name: datatools_pg_data
```
Populate the secrets (each file should contain the value with no
trailing newline). For PR 1, only `pg_password` and `admin_token`
are required; the rest land in PR 2 / production key rotation.
```bash
cd /srv/datatools-license
# Random 32-char hex DB password
openssl rand -hex 32 > secrets/pg_password
# Random admin Bearer token (CLI auth). Save this — you'll need it
# on your laptop to talk to /internal/* via the SSH tunnel.
openssl rand -hex 32 > secrets/admin_token
# --- PR 2 / production-key follow-ups (skip for PR 1 bring-up) ---
# echo -n "<postmark-server-token>" > secrets/postmark_token
# echo -n "<ed25519-private-hex>" > secrets/license_privkey
# openssl rand -hex 32 > secrets/gumroad_secret
# Lock everything down. The numeric 10001 matches the in-container
# `app` user (Dockerfile-defined), letting the API read the file
# while keeping host-side access gated by the parent dir's mode 750.
chmod 400 secrets/*
chown 10001:10001 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
> **Gotcha — nginx version syntax.** Ubuntu 24.04 ships nginx 1.24,
> which uses the legacy `listen 443 ssl http2;` form. The standalone
> `http2 on;` directive arrived in nginx 1.25 and will error on 1.24
> with `unknown directive "http2"`. The config below uses the 1.24
> form.
>
> **Bring-up sequence.** This config references a TLS cert at
> `/etc/letsencrypt/live/datatools.unalogix.com/`, which doesn't
> exist on a fresh install — nginx would refuse to start. The
> working sequence is: (a) install a temporary HTTP-only config
> that serves `.well-known/acme-challenge/` and returns 503 for
> everything else, (b) `nginx -s reload`, (c) run `certbot
> certonly --webroot`, (d) replace with the HTTPS config below,
> (e) `nginx -s reload` again. See §6.
`/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; # nginx 1.24 syntax (Ubuntu 24.04)
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; # nginx 1.24 syntax (Ubuntu 24.04)
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.