# 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: ``` 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 "" > secrets/postmark_token # Ed25519 private signing key (from password manager OR generate-keypair.py) echo -n "" > 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=" > /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.