# 91 — Quick knowledge base (info hub)

Short reference notes for James. Style: simple sentences (ASD-STE100-ish). Each section is self-contained. Sessions: add a section when James asks; keep each section short.

## Deployment setup (as of 2026-08-19, spec §9.48–§9.50)

**TLDR.** Code lives on the Mac. `git push vps main` sends it to the Germany server. The server bakes Docker images and runs them with Compose. Caddy gives each app a real HTTPS URL on `6326638.xyz`. The `6326638.xyz` URLs still work only on the tailnet, because their DNS records point at the server's Tailscale IP — not because a firewall stops them (2026-09-05: `tuaton.padthai.my` is served publicly from the same Caddy). Postgres runs natively on the server; only localhost, the tailnet, and Docker can reach it. Nightly backups. Secrets stay in one file per machine, never in git.

**Development.** All source files are on the MacBook. Claude Code runs on the MacBook. Development uses `next dev` (via `./launch.sh`). Dev mode recompiles only the changed file, in less than a second. There is no "baking" during development. Baking (image build) happens only for production.

**The server.** One VPS in Germany: Ubuntu 24, 12 GB, `james@92.118.206.89`. The server is on the Tailscale network as `100.71.64.33`. The firewall (ufw) allows only SSH from the public internet. All other access must come from the tailnet.

**Code transfer.** Deployment uses git, not rsync. The Mac pushes commits to a bare repo on the server (`git push vps main`). A checkout at `~/apps/ai-new-business/repo` pulls from it. Only committed files travel. Secrets never travel this way.

**Docker.** `deploy/deploy.sh` bakes one Docker image per app. The image contains Node, pnpm, the dependencies, and the compiled app. Containers serve from the baked image, not from the checkout. Docker Compose (`deploy/compose.yaml`) runs six containers: the four apps (each on a 127.0.0.1 port), Caddy, and an on-demand "ops" container for migrations and seeds. Two exceptions read live files: the hub serves the checkout directly, and ops mounts the secrets file.

**Ingress (Caddy).** The domain is `6326638.xyz` (Cloudflare DNS). DNS points to the TAILSCALE address of the server. So the URLs work only on James's devices. Caddy holds real Let's Encrypt certificates (DNS-01, via the Cloudflare token). Caddy routes each hostname to its app: `demo.` → 3100, `clinic.` → 3300, `barber.` → 3200, `cameo.` → 3201, apex → the hub (entry point + all docs). Port 443 is now open to the public internet (ufw `443 ALLOW Anywhere`, opened for `tuaton.padthai.my` on 2026-09-05), so making a `6326638.xyz` host public is only a DNS change to `92.118.206.89` — do task O23 first.

**Postgres.** Postgres 18 runs natively on Ubuntu (not in Docker). Database `ai_factory`, admin role `factory_admin` (not superuser), runtime role `ai_kernel_app` (restricted, RLS enforced). It listens to localhost, the tailnet, and Docker networks. The public internet cannot reach it. James's Mac can reach it at `100.71.64.33:5432`. A cron job dumps the database every night at 03:20 (14-day retention).

**Secrets.** The server secrets file is `~/apps/ai-new-business/.env` (mode 0600). The Mac has its own `.env` at the repo root. Neither file is ever committed. The Cloudflare token is only in the server file.

**Daily use.** Deploy: `git push vps main`, then run `deploy/deploy.sh` on the server (any Claude session does this). Logs: `docker compose logs -f clinic`. Restart one app: `docker compose restart clinic`. Data survives deploys. Local and production have separate databases.

## How to put a NEW project on the server (self-contained; written for any future session, any repo)

This section is deployment knowledge only. It makes no decisions about the project being deployed. Show it to any Claude Code session that must deploy something new to this server. The server-agnostic version of the whole pattern (for NEW servers, any project, plus the questions to ask James) is `92_DEPLOYMENT_PATTERN_GENERIC_GUIDE.md`.

**The server.** `ssh james@92.118.206.89` (key auth from James's Mac; user `james` has passwordless sudo). Tailscale name/IP: `100.71.64.33`. Ubuntu 24, 12 GB RAM, Docker + Compose installed. ufw is active: the public internet reaches SSH and 443 only (443 since 2026-09-05). The tailnet reaches everything. Do not open public ports without James's explicit word.

**Already running (do not collide).** Host ports in use on this box (check `docker ps` before picking one — it is now shared across projects): `3100` demo, `3200` barber, `3201` cameo, `3300` clinic, `3500` paiduay→container 3400 (spec §9.51), `3400` TuaTon (chatbot-engine-separate project), `4820`/`4821` Luna, `80`/`443` Caddy, `5432` host Postgres. Compose projects: `ai-factory` (the 4 demos + paiduay + Caddy + ops), `chatbot-engine` (Luna), `tuaton` (TuaTon).

**Code transfer (per project).** On the server: `git init --bare ~/repos/<project>.git`, then `git symbolic-ref HEAD refs/heads/main` in it. Clone a checkout: `git clone ~/repos/<project>.git ~/apps/<project>/repo`. On the Mac: `git remote add vps ssh://james@92.118.206.89/home/james/repos/<project>.git`, then `git push vps main`. Committed files only; never commit secrets.

**Secrets (per project).** One file: `~/apps/<project>/.env`, mode 0600, created over SSH stdin (never on a command line). Compose reads it with `env_file`. If a build step needs it, pass it as a BuildKit secret (see `deploy/Dockerfile` in ai-new-business), and put `.env` in the project's `.dockerignore`.

**Database (per project).** Use the HOST Postgres 18 (do not run Postgres in Docker). As `sudo -u postgres psql`: create a role (LOGIN, generated password, add CREATEROLE only if the project provisions sub-roles) and a database owned by it. From containers, the host is `172.17.0.1`. pg_hba already allows localhost + tailnet (100.64.0.0/10) + Docker (172.16.0.0/12) with scram; ufw already allows 5432 from Docker ranges. Add the new database to the backup script `~/backups/backup-ai-factory.sh` (it runs nightly at 03:20 via cron; `pg_dump` as the postgres superuser — FORCE RLS databases cannot be dumped by their owner role).

**Containers.** Copy the ai-new-business pattern: a `deploy/` folder in the project repo with a Dockerfile (bake node + deps + build into the image), a `compose.yaml` (one service per process, `restart: unless-stopped`, ports bound to `127.0.0.1` only), and a `deploy.sh` (copy secrets file → build → migrate → up). Compose builds that need the network use `network: host`. Note: `docker compose build` skips services behind `profiles:` — build with `--profile <name>` if used.

**Domain + HTTPS.** Which domain a project uses is James's decision per project — do not assume. One Caddy instance (in the ai-factory compose project) is the ingress for the whole server; a project's hostname is one block in `~/apps/ai-new-business/repo/deploy/Caddyfile` (commit in the ai-new-business repo): `<host> { import tls_cf  reverse_proxy 127.0.0.1:<port> }`, then `docker compose restart caddy`. Caddy fetches certificates by itself via DNS-01.
- *Using the existing domain* (`6326638.xyz`, Cloudflare): the wildcard + apex A records already point to the Tailscale IP, so any subdomain resolves with no DNS work, and the Cloudflare token in the ai-factory `.env` already covers certificates.
- *Using a new domain:* put the zone on Cloudflare; create A records (apex/wildcard) to the Tailscale IP `100.71.64.33` (DNS-only, not proxied); make a Zone-DNS API token for that zone and add it to the ai-factory `.env`; give the Caddyfile block its own `tls { dns cloudflare {env.<TOKEN_VAR>} }` instead of `import tls_cf`.
- Either way a new host is tailnet-only while its DNS points at `100.71.64.33`. Going public = repoint DNS to `92.118.206.89` (443 is already open) — James's call only.

**Verify.** `curl https://<name>.6326638.xyz` from a tailnet device. Also check the app's logs (`docker compose logs`) and that no public port opened (`sudo ufw status`).

## TuaTon (padthai.my) — deployed 2026-09-05

**What it is.** TuaTon (ตัวตน) is the Thai personality-assessment app. Repo on the Mac: `~/3_nodejs/tuaton-app`. Content/spec repo: `~/3_nodejs/cult-resources`.

**On the server.** Bare repo `~/repos/tuaton-app.git`. Checkout `~/apps/tuaton-app/repo`. Env file `~/apps/tuaton-app/.env` (mode 600). Compose project `tuaton`, one container `tuaton-web-1`, host port **3400** bound to `127.0.0.1` only.

**No database.** Phase 1 is DB-less. There is no role, no database, no migration step, and nothing to back up. Do not add it to `~/backups/backup-ai-factory.sh`.

**Hostnames.** `tuaton.padthai.my` is PUBLIC: the A record points at `92.118.206.89` and is Cloudflare-proxied (orange). James approved public exposure on 2026-09-05. `tuaton3.padthai.my` is tailnet-only: DNS-only (grey) A record to `100.71.64.33`. Both go to the same container. `tuaton2` and `tuaton4` are unused.

**Certificates.** Both blocks live in `deploy/Caddyfile` in THIS repo (one Caddy serves the whole box). The zone `padthai.my` uses its own token variable `CLOUDFLARE_API_TOKEN_PADTHAI`, kept in `~/apps/ai-new-business/.env`. It reaches Caddy through `deploy/.env`, which `deploy/deploy.sh` copies from that file — so after adding a new variable you must refresh `deploy/.env` before you recreate Caddy.

**Cloudflare SSL/TLS mode.** The `padthai.my` zone must stay on **Full (strict)**. Caddy already holds a publicly trusted certificate, so strict works. Flexible would make the proxied host redirect to itself forever.

**Deploy.**
```bash
cd ~/3_nodejs/tuaton-app && git push vps main
ssh james@92.118.206.89 'cd ~/apps/tuaton-app/repo && git pull && deploy/deploy.sh'
```
The script copies the env file in, builds the image on the server, starts the container, waits for `/api/health`, and then checks that `/` really contains `data-screen="HOMER"`. It exits 1 and prints the last 50 log lines if either check fails.

**Change the hostname or the port.** Edit `deploy/Caddyfile` here, commit, `git push vps main`, then on the server `cd ~/apps/ai-new-business/repo && git pull && cd deploy && docker compose up -d --force-recreate caddy`. Force-recreate, not restart.

**Note.** `NEXT_PUBLIC_SITE_URL=https://tuaton.padthai.my` is baked into the image at BUILD time. Changing the public URL needs a rebuild, not a restart.
