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:
@@ -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;
|
||||
|
||||
Reference in New Issue
Block a user