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>
594 lines
18 KiB
Markdown
594 lines
18 KiB
Markdown
# 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.
|
||
|
||
> **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 — 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.
|
||
|
||
```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 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):
|
||
|
||
```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.
|