Files
datatools-dev/docs/SETUP-LICENSE-SERVER.md
Michael 86ad21db79 docs(license): PR 2 deploy + operator instructions
ADMIN.md gains a "Running a Gumroad webhook" section: how the URL
secret works, how to add a SKU to products.yaml, how to inspect
gumroad_events (recent activity + failures-only queries), how to
replay a failed delivery, and how to test without buyers via
Gumroad's "Send Test Ping" button.

The deployed-vs-queued matrix flips Gumroad + Postmark to
"code merged, deploy pending" so it's clear the bits exist on
main but the live box still runs PR 1.

SETUP-LICENSE-SERVER.md §3 commits the eventual compose.yml shape
with PR 2 environment + secrets lines included but commented out,
ready to uncomment at deploy time. The §3 chown step already covers
the new secret files because it uses `chmod 400 secrets/*` /
`chown 10001:10001 secrets/*`.

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

18 KiB
Raw Permalink Blame History

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:

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.

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).

# 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:

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 — uncomment when Postmark + Gumroad are provisioned.
      # POSTMARK_TOKEN_FILE: /run/secrets/postmark_token
      # GUMROAD_WEBHOOK_SECRET_FILE: /run/secrets/gumroad_secret
      # 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
      # PR 2:
      # - postmark_token
      # - 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 }
  admin_token: { file: ./secrets/admin_token }
  # PR 2:
  # postmark_token: { file: ./secrets/postmark_token }
  # gumroad_secret: { file: ./secrets/gumroad_secret }
  # Production keypair rotation adds:
  # license_privkey: { file: ./secrets/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.

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 secrets ---
# echo -n "<postmark-server-token>"  > secrets/postmark_token   # from postmarkapp.com
# openssl rand -hex 32               > secrets/gumroad_secret   # paste into Gumroad's Ping URL: ?secret=<this>
#
# --- production-key follow-up (defer until v1.0 cutover) ---
# echo -n "<ed25519-private-hex>"    > secrets/license_privkey

# 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):

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:

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):

docker compose exec api alembic upgrade head

Smoke test:

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/unalogixnew file, do not merge into invixiom:

# 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:

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):

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:

systemctl list-timers certbot.timer

7. Bring it up

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:

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:

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:

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:

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:

#!/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:

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:

{
  "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.

# 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.