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>
This commit is contained in:
2026-05-14 01:17:05 +00:00
parent 673b902377
commit 1cf69dd23b

View File

@@ -133,6 +133,16 @@ 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
@@ -181,24 +191,26 @@ services:
retries: 5
api:
image: datatools-license-api:latest # built locally; see §4
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://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
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
- postmark_token
- license_privkey
- gumroad_secret
- admin_token
ports:
- "127.0.0.1:8090:8000" # localhost-only; nginx is the only path in
healthcheck:
@@ -208,10 +220,10 @@ services:
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 }
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:
@@ -219,7 +231,8 @@ volumes:
```
Populate the secrets (each file should contain the value with no
trailing newline):
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
@@ -227,18 +240,20 @@ 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
# 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
# Ed25519 private signing key (from password manager OR generate-keypair.py)
echo -n "<paste-hex-privkey>" > secrets/license_privkey
# --- 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
# Gumroad webhook secret from §1d (generate when needed; placeholder OK for PR 1)
openssl rand -hex 32 > secrets/gumroad_secret
# Lock everything down.
# 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 -R datatools-api:datatools-api secrets/
chown 10001:10001 secrets/*
```
The corresponding **public** key for `DATATOOLS_LICENSE_PUBKEY` goes
@@ -281,6 +296,21 @@ curl -s http://127.0.0.1:8090/health
## 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`:
@@ -293,7 +323,7 @@ server {
}
server {
listen 443 ssl http2;
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;
@@ -309,7 +339,7 @@ server {
# License operations subdomain.
server {
listen 443 ssl http2;
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;