Files
server-infra/IMPLEMENTATION.md
T
westfarn 658152088e
Sync runner checkout / sync (push) Successful in 6s
Treat omitted host_apps.enabled as true (#36)
Closes #35.

## Summary
- `#33` used Jinja `rejectattr('enabled', 'equalto', false)` to skip stopped apps. That filter **requires** the key, so every `host_apps` row that omits `enabled` (the documented default) crashed deploy on all webservers before git/compose.
- Filter with `item.enabled | default(true)` instead, in `app-deploy` and `web-static`. `print_forge` (`enabled: false`) still skipped. `url_shortening_service` and every other omitted-key row deploy again.

## Test plan
- [ ] `ansible-playbook playbooks/deploy-apps.yml --syntax-check`
- [ ] Redeploy `url_shortening_service` beta: `deploy.sh --app url_shortening_service --env beta --ref <sha>`
- [ ] Confirm `print_forge` is still not started (no compose up on 8007/8019)

Reviewed-on: #36
2026-09-16 08:21:29 -07:00

605 lines
33 KiB
Markdown
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
# Server Infrastructure — Implementation Guide
Ansible-based provisioning and deployment for homelab web servers.
## Architecture
```mermaid
flowchart TB
subgraph provision ["Provisioning (manual / rare)"]
Control1["ai-server-4080\n(control node)"]
Control1 -->|ansible-playbook site.yml| Adama
Control1 -->|ansible-playbook site.yml| Roslin
Control1 -->|ansible-playbook site.yml| Starbuck
Control1 -->|ansible-playbook site.yml| Apollo
end
subgraph cicd ["CI/CD (every merge to master)"]
Gitea["Gitea push"]
Gitea --> Tests["Act: unit tests"]
Tests --> Deploy["Act: deploy job"]
Deploy --> AnsibleDeploy["ansible-playbook deploy-apps.yml"]
AnsibleDeploy --> Adama2["adama"]
AnsibleDeploy --> Roslin2["roslin"]
AnsibleDeploy --> Starbuck2["starbuck"]
AnsibleDeploy --> Apollo2["apollo"]
end
```
| Pipeline | When | Playbook | Where it runs |
|----------|------|----------|---------------|
| **Provision** | New VM, OS change, firewall, Docker install | `site.yml` | ai-server-4080 — run manually |
| **Deploy** | Green unit tests on `master` | `deploy-apps.yml` | Gitea Act runner on ai-server-4080 |
| **Stop** | Take a compose app down | `stop-apps.yml` (`scripts/stop.sh`) | ai-server-4080 — run manually |
Both pipelines share the same inventory (`inventory/hosts.yml`).
## Servers
| Name | IP | Role |
|------|-----|------|
| adama | 10.0.0.77 | Ubuntu Server VM (Proxmox) — app host |
| roslin | 10.0.0.176 | Ubuntu Server VM (Proxmox) — app host |
| starbuck | 10.0.0.44 | Ubuntu Server VM (Proxmox) — app host |
| apollo | 10.0.0.7 | Ubuntu Server VM (Proxmox) — app host |
| ai-server-4080 | 10.0.0.128 | Control node + Gitea act runner + Ollama + SearxNG + observability; also runs app replicas |
Hostname on this machine: `ryan-development-1`
## Repo Layout
```
server-infra/
├── IMPLEMENTATION.md # This file
├── README.md # Quick start
├── ansible.cfg
├── requirements.yml # Ansible Galaxy collections
├── inventory/
│ ├── hosts.yml
│ ├── group_vars/
│ │ └── all.yml # vars + app_catalog
│ └── host_vars/
│ ├── adama.yml # host_apps (django + static; monica worker)
│ ├── roslin.yml # host_apps (mirrors adama, no worker)
│ ├── starbuck.yml # host_apps (mirrors roslin)
│ ├── apollo.yml # host_apps (mirrors roslin)
│ └── ai-server-4080.yml # control node / act runner / SearxNG / observability
├── playbooks/
│ ├── site.yml # Phase 1: provision
│ ├── deploy-apps.yml # Phase 2: CI deploy
│ └── stop-apps.yml # compose down one app+env
├── roles/
│ ├── common/ # Base packages
│ ├── ufw/ # Firewall
│ ├── docker/ # Docker CE + compose plugin
│ ├── nodejs/ # Node.js + npm + npx (NodeSource)
│ ├── gitea-key/ # per-server SSH key + Gitea access probe
│ ├── tianji/ # Monitoring reporter
│ ├── observability/ # Loki + Prometheus + Grafana (ai-server-4080)
│ ├── searxng/ # SearxNG JSON API for chat_backend (#10)
│ ├── alloy/ # log/metrics shipper
│ ├── app-deploy/ # django (docker) + node-static deploy
│ └── web-static/ # nginx container serving /var/www builds
└── scripts/
├── provision.sh # Wrapper with --limit support
├── deploy.sh # Wrapper for deploy playbook
└── stop.sh # Wrapper for stop playbook (compose down)
```
## Prerequisites (One-Time Bootstrap)
Ansible needs SSH + sudo on each target before playbooks work.
1. Create `westfarn` on each VM with sudo membership.
2. Copy your SSH public key from the control node (ai-server-4080):
```bash
ssh-copy-id westfarn@10.0.0.77
ssh-copy-id westfarn@10.0.0.176
ssh-copy-id westfarn@10.0.0.44
ssh-copy-id westfarn@10.0.0.7
```
3. Confirm passwordless SSH:
```bash
ssh westfarn@10.0.0.77
ssh westfarn@10.0.0.176
ssh westfarn@10.0.0.44
ssh westfarn@10.0.0.7
```
4. **First-time only** — grant passwordless sudo on each new host before the first
`provision.sh` run. Ubuntu 26.04 ships `sudo-rs` by default; Ansible's
`--ask-become-pass` does not recognize its password prompt, so bootstrap sudo
manually over SSH instead:
```bash
ssh -t westfarn@10.0.0.176 # repeat for each host IP
```
On the host:
```bash
echo 'westfarn ALL=(ALL) NOPASSWD:ALL' | sudo tee /etc/sudoers.d/westfarn
sudo chmod 440 /etc/sudoers.d/westfarn
exit
```
The `common` role writes the same file on later runs; this one-time step is only
needed before Ansible can escalate privileges the first time.
5. On ai-server-4080 (control node), install Ansible:
```bash
sudo apt update && sudo apt install -y ansible
# or: pip install ansible
```
6. Install Galaxy collections:
```bash
cd ~/Documents/repos/server-infra
ansible-galaxy collection install -r requirements.yml
```
7. Update `inventory/host_vars/ai-server-4080.yml` with this machine's LAN IP (`ansible_host`).
## Testing on a Single Server
Use `--limit` to target one host without touching the others. Helper scripts wrap this.
### Ping one host
```bash
./scripts/provision.sh adama --check # dry run
ansible adama -m ping
```
### Provision one host
New hosts need the one-time passwordless sudo bootstrap in
[Prerequisites](#prerequisites-one-time-bootstrap) before the first run.
```bash
# Dry run (no changes)
./scripts/provision.sh adama --check
# Apply for real
./scripts/provision.sh adama
# Same for other hosts
./scripts/provision.sh roslin
./scripts/provision.sh starbuck
./scripts/provision.sh apollo
./scripts/provision.sh ai-server-4080
```
### Provision all hosts
```bash
./scripts/provision.sh
```
### Deploy to one host (Phase 2)
```bash
./scripts/deploy.sh adama
./scripts/deploy.sh --check roslin
./scripts/deploy.sh starbuck
./scripts/deploy.sh apollo
```
### Stop a compose app
```bash
./scripts/stop.sh --app print_forge --env prod
./scripts/stop.sh --app print_forge --env beta
./scripts/stop.sh adama --app chat_backend --env beta --check
```
`stop.sh` is `docker compose down` for one `--app` + `--env` (no `-v`). Checkouts and
secrets stay. Next `deploy.sh` of that app+env starts it again unless `host_apps`
sets `enabled: false`. Node-static apps have no compose project; stop.sh skips them.
Under the hood, scripts pass `--limit <hostname>` to `ansible-playbook`.
## Phase 1: Provision (`site.yml`)
Applies roles in order to the `webservers` group:
| Role | Purpose |
|------|---------|
| `common` | apt update, git, python3, pip, curl, ca-certificates |
| `ufw` | Firewall: SSH from LAN only, HTTP/HTTPS public |
| `docker` | Docker CE, compose plugin, add `westfarn` to `docker` group |
| `nodejs` | Node.js + npm + npx (NodeSource) for `dta_webapp` builds |
| `gitea-key` | Per-server SSH key + Gitea access probe |
| `tianji` | Monitoring reporter |
### UFW rules
| Port | Source | Purpose |
|------|--------|---------|
| 22 | `10.0.0.0/24` | SSH (LAN only) |
| 80 | anywhere | HTTP |
| 443 | anywhere | HTTPS |
| default | deny incoming | Block everything else |
**Warning:** Test UFW on one host first (`./scripts/provision.sh adama`). Keep a Proxmox console session open in case SSH rules lock you out.
After Docker install, re-SSH so the `docker` group membership takes effect.
## Phase 2: CI Deploy (`deploy-apps.yml`)
### Apps
| App | Type | Hosts | Envs | Notes |
|-----|------|-------|------|-------|
| `company_site` | django (docker) | all webservers | prod | active/active behind NPM; beta port reserved |
| `dta_service` | django (docker) | all webservers | beta + prod | active/active behind NPM |
| `dta_webapp` | node/vite static | all webservers | beta + prod | active/active; built to `/var/www/<env>.realpath.app/html`, served by web-static nginx |
| `dta_blog` | node-static (Python SSG) | all webservers | beta + prod | active/active; built to `/var/www/<env>.blog.realpath.app/html`; **own hosts** (`blog.realpath.app` / `beta.blog.realpath.app`), not `realpath.app/blog`; Tianji ids baked at build |
| `scha` | django (docker) | all webservers | prod | active/active behind NPM; beta port reserved |
| `chat_web_app` | node-static (CRA) | all webservers | beta + prod | active/active; built to `/var/www/<env>.chat.aimloperations/html`, served by web-static nginx |
| `chat_backend` | django (docker) | all webservers | beta + prod | active/active behind NPM; Ollama `http://10.0.0.128:11434`; SearxNG `http://10.0.0.128:8088` (`SEARXNG_BASE_URL`) |
| `monica_site` | django (docker) | all webservers | beta + prod | active/active behind NPM; no bundled Postgres (like `scha`); dj-queue **worker singleton on adama** only (`compose --profile worker`); Ollama social drafting via `10.0.0.128:11434` |
| `url_shortening_service` | django (docker) | all webservers | beta + prod | active/active behind NPM; no bundled Postgres; **no worker**. Two public hosts, same container: short domain (`GET /`, `GET /<code>` 302) and API host (`/api/links/`, Bearer required). |
| `college_craft` | django (docker) | all webservers | beta + prod | active/active behind NPM; no bundled Postgres (like `scha` / `monica_site`); dj-queue **worker singleton on adama** only (`compose --profile worker`); Ollama social drafting via `10.0.0.128:11434`; Nominatim `http://10.0.0.128:8089`; prod `SITE_UNDER_CONSTRUCTION=true` until launch |
| `print_forge` | django (docker) | all webservers | beta + prod | **stopped** (`enabled: false`, [#33](https://git.aimloperations.com/ai_ml_operations/server-infra/issues/33)). Ports reserved. Re-enable + `deploy.sh` to bring back. Worker was adama-only. |
| `abc_be` | django (docker) | all webservers | **beta only** | AI Benefits Coach API; no bundled Postgres; Ollama `http://10.0.0.128:11434`; shares DB `abc_be_beta` with `abc_worker` |
| `abc_worker` | django-type compose (FastAPI) | all webservers | **beta only** | same Postgres as `abc_be`; `migrate_cmd: true`; WS on host port 8018 |
| `abc_fe` | node-static (Vite) | all webservers | **beta only** | built to `/var/www/<env>.abc.aimloperations/html`; no prod `host_apps` row |
| `livekit` | django-type compose (LiveKit SFU) | all webservers | **beta only** | Docker like `abc_worker`. Host network `:7880`. **Do not NPM-balance** until Redis — pick one upstream (adama). Router DNAT UDP 3478 + 5000060000 and TCP 7881 to that host. Secrets `~/Documents/secrets/livekit/livekit_beta.env` |
Django apps use a **shared external Postgres** (via `DATABASE_URL` in each host's
env file) so active/active replicas share one database. Beta and prod never share
a DB.
### Data model
- `app_catalog` (`group_vars/all.yml`) — how each app is built (repo, type, compose file, migrate cmd).
- `host_apps` (`host_vars/<host>.yml`) — which app+env+port runs on that host.
Optional `compose_profiles: [worker]` activates docker compose profiles on that
host only (used for `monica_site` / `college_craft` / `print_forge` dj-queue singleton on adama).
Optional `enabled: false` skips deploy (CI included) but keeps the row for ports
and `stop.sh`. Omit the key (or `true`) to deploy as before. Do not use Jinja
`rejectattr('enabled')` — missing key is an error, not "enabled".
- Django app = one compose project per env: project name `<app>_<env>`, host port from `host_apps`.
Ports match across app hosts so NPM can balance `adama:PORT` + `roslin:PORT` + `starbuck:PORT` + `apollo:PORT`.
### Ports
Reserved host ports for NPM upstreams. Ports must match across every host that
serves the same app+env. Rows marked *not deployed* keep the port free for a
future beta replica.
| App | beta | prod | Deployed on |
|-----|------|------|-------------|
| company_site | 8010 (*not deployed*) | 8000 | all webservers |
| dta_service | 8011 | 8001 | all webservers |
| scha | 8012 (*not deployed*) | 8002 | all webservers |
| chat_backend | 8013 | 8003 | all webservers |
| monica_site | 8014 | 8004 | all webservers |
| url_shortening_service | 8015 | 8005 | all webservers |
| college_craft | 8016 | 8006 | all webservers |
| print_forge | **8019** (*stopped*) | **8007** (*stopped*) | all webservers (`enabled: false`) |
| abc_be | **8017** | 8009 (*not deployed*) | all webservers |
| abc_worker | **8018** | 8008 (*not deployed*) | all webservers |
| dta_webapp (nginx) | 8081 | 8080 | all webservers |
| chat_web_app (nginx) | 8083 | 8082 | all webservers |
| abc_fe (nginx) | **8085** | 8084 (*not deployed*) | all webservers |
| dta_blog (nginx) | **8087** | **8086** | all webservers |
| livekit | **7880** | — | all webservers (signaling). UDP 3478 + 5000060000 + TCP 7881 via router DNAT to the NPM upstream host |
| SearxNG (LAN only) | — | **8088** | ai-server-4080 only (`searxng_stack`); not an NPM upstream |
Host-local services on ai-server-4080 (not balanced by NPM):
| Service | Port | Notes |
|---------|------|-------|
| Ollama | 11434 | Not Ansible-managed today; GPU host (`monica_site` / `college_craft` social drafting) |
| Nominatim | 8089 | Not Ansible-managed today; LAN address autocomplete for `college_craft` / `print_forge` |
| SearxNG | 8088 | `roles/searxng` (#10); JSON API for chat_backend grounded search |
| Loki | 3100 | `roles/observability` |
| Prometheus | 9090 | `roles/observability` |
| Grafana | 3000 | `roles/observability` |
**Port clash warning:** do **not** bind SearxNG to `8080` — that is `dta_webapp` prod.
chat_backend secrets must use `SEARXNG_BASE_URL=http://10.0.0.128:8088`.
### Flow
1. Gitea push to `master` → repo's `.gitea/workflows` runs tests.
2. On green, deploy job on the self-hosted runner calls:
```bash
~/Documents/repos/server-infra/scripts/deploy.sh \
--app company_site --env prod --ref "${{ gitea.sha }}"
```
3. `deploy-apps.yml` runs against `webservers`; each host deploys only the
matching **enabled** app+env from its `host_apps` (`enabled: false` is skipped).
To stop a compose app without deleting inventory:
```bash
./scripts/stop.sh --app print_forge --env prod
```
Set `enabled: false` on that `host_apps` row so the next CI deploy cannot start it
again. `stop.sh` still matches disabled rows.
### `app-deploy` role behavior
- **django**: push per-app secret from control node `{{ secrets_dir }}/<app>/<app>_<env>.env`
to host `{{ apps_env_dir }}` → git checkout at ref → copy `.env` into checkout →
`docker compose build` → `up -d` (with `COMPOSE_PROFILES` from optional
`host_apps.compose_profiles`) → migrate (run once, shared DB).
**`monica_site` / `college_craft` / `print_forge` worker:** adama `host_apps` sets
`compose_profiles: [worker]` so deploy starts dj-queue with web. Other hosts
omit profiles (web only).
- **node-static**: git checkout at ref → `npm ci` → `npm run build:<env>`
(writes to the app's `webroot_pattern`, e.g. `/var/www/{env}.realpath.app/html`,
`/var/www/{env}.blog.realpath.app/html`, or `/var/www/{env}.chat.aimloperations/html`).
Optional catalog `error_page_404` (used by `dta_blog`) serves that file instead
of the SPA `/index.html` fallback.
- **web-static** role: one nginx container per app host serving the static roots
on their ports (from `host_apps`); NPM balances across hosts. Before `compose up`,
removes any container currently publishing those host ports (`docker ps --filter
publish=<port>`) so leftovers cannot block the bind, then recreates web-static.
### Reverse proxy / load balancing (NPM at 10.0.0.230)
Ansible does **not** manage NPM. It only guarantees stable host ports. In NPM you
point each domain at the backend(s):
- Single host: standard Proxy Host → `adama:PORT`.
- Active/active: jc21 NPM's UI Proxy Host is single-target. To balance
app hosts you need the **Advanced** tab with a custom `upstream {}` block
(or a real LB). Confirm this before relying on active/active.
| App | Domains | Backends |
|-----|---------|----------|
| company_site | aimloperations.com (+ www) | `adama:8000` + `roslin:8000` + `starbuck:8000` + `apollo:8000` |
| dta_service | (see DTA NPM hosts) | `adama:8001` / `8011` + same on roslin / starbuck / apollo / ai-server-4080 |
| dta_webapp | (see DTA NPM hosts) | `adama:8080` / `8081` + same on roslin / starbuck / apollo |
| dta_blog | `blog.realpath.app` (prod); `beta.blog.realpath.app` (beta). **Do not** reverse-proxy onto `realpath.app/blog` | `adama:8086` / `8087` + same on roslin / starbuck / apollo / ai-server-4080 |
| scha | `schawheaton.aimloperations.com`, `schawheaton.com` (+ www) | `adama:8002` + `roslin:8002` + `starbuck:8002` + `apollo:8002` (+ `ai-server-4080:8002`) |
| chat_web_app | `chat.aimloperations.com` (+ www); `beta.chat.aimloperations.com` | `adama:8082` / `8083` + same on roslin / starbuck / apollo / ai-server-4080 |
| chat_backend | `chatbackend.aimloperations.com`; `beta.chatbackend.aimloperations.com` | `adama:8003` / `8013` + same on roslin / starbuck / apollo / ai-server-4080 |
| monica_site | `mkdrealtor.com` (+ www); `monica-preview.aimloperations.com` (beta) | `adama:8004` / `8014` + same on roslin / starbuck / apollo / ai-server-4080 |
| url_shortening_service (short) | `aiml.pw` and/or `cidinn.li` (pick when DNS is ready); `short-beta.aimloperations.com` (beta). Proxy `/` + `/[a-z0-9]{4,8}` only — 404 `/api/`, `/admin/`, `/debug/` | `adama:8005` / `8015` + same on roslin / starbuck / apollo / ai-server-4080 |
| url_shortening_service (API) | `shortener.aimloperations.com`; `shortener-beta.aimloperations.com` (beta). Proxy `/api/` only — 404 `/admin/` | same ports as short host (one container) |
| college_craft | `collegecraft.com` (+ www); `college-craft-preview.aimloperations.com` (beta) | `adama:8006` / `8016` + same on roslin / starbuck / apollo / ai-server-4080 |
| print_forge | `printforgeprints.com` (+ www); `print-forge-preview.aimloperations.com` (beta) — **app stopped** (`enabled: false`). Disable NPM hosts or expect 502. | `:8007` / `:8019` reserved |
| abc_fe | `beta.abc.aimloperations.com` (beta only) | `adama:8085` + same on roslin / starbuck / apollo / ai-server-4080 |
| abc_be | `beta.abc.be.aimloperations.com` (beta only) | `adama:8017` + same on roslin / starbuck / apollo / ai-server-4080 |
| abc_worker | `beta.abc.worker.aimloperations.com` (beta only; HTTP + WebSocket upgrade) | `adama:8018` + same on roslin / starbuck / apollo / ai-server-4080 |
| livekit | `LIVEKIT_DOMAIN` (e.g. `livekit.aimloperations.com`) — NPM Proxy Host, WebSocket, **single upstream** | `adama:7880` (do not balance until Redis) |
### Required changes IN each app repo (owned separately)
- [ ] `docker-compose.prod.yml`: drop the bundled `db` service; `web` reads
`DATABASE_URL` / `DB_HOST` pointing at the shared external Postgres.
- [ ] Each app has its own database + user on the shared Postgres.
- [ ] `.gitea/workflows/deploy.yml`: replace the local `scripts/deploy.sh` step
with a call to `server-infra/scripts/deploy.sh --app <name> --env <env> --ref <sha>`
(keep the test/docker jobs).
- [ ] `dta_webapp`: `npm run build:beta` / `build:prod` output to
`/var/www/beta.realpath.app/html` / `/var/www/prod.realpath.app/html`.
- [ ] `dta_blog`: `npm run build:beta` / `build:prod` output to
`/var/www/beta.blog.realpath.app/html` / `/var/www/prod.blog.realpath.app/html`
(`python3 build.py --env <env>` copies `dist/`). Default branch is `main`.
- [ ] `chat_web_app`: `npm run build:beta` / `build:prod` output to
`/var/www/beta.chat.aimloperations/html` / `/var/www/prod.chat.aimloperations/html`.
Companion `chat_web_app` frontend is already registered in this infrastructure repo.
### Shared Postgres (10.0.0.230, same box as NPM)
One shared instance; each app+env gets its own database (beta and prod MUST NOT
share a DB — active/active replicas of the same env share one DB, different envs
do not).
| app | env | database | DATABASE_URL |
|-----|-----|----------|--------------|
| company_site | prod | `company_site` | `postgres://westfarn:<pw>@10.0.0.230:5432/company_site` |
| company_site | beta | `company_site_beta` | `postgres://westfarn:<pw>@10.0.0.230:5432/company_site_beta` |
| dta_service | prod | `dta_service` | `postgres://westfarn:<pw>@10.0.0.230:5432/dta_service` |
| dta_service | beta | `dta_service_beta` | `postgres://westfarn:<pw>@10.0.0.230:5432/dta_service_beta` |
| scha | prod | `scha` | `postgres://westfarn:<pw>@10.0.0.230:5432/scha` |
| scha | beta | `scha_beta` | `postgres://westfarn:<pw>@10.0.0.230:5432/scha_beta` |
| chat_backend | prod | `chat_backend` | `postgres://westfarn:<pw>@10.0.0.230:5432/chat_backend` |
| chat_backend | beta | `chat_backend_beta` | `postgres://westfarn:<pw>@10.0.0.230:5432/chat_backend_beta` |
| monica_site | prod | `monica_site` | `postgres://westfarn:<pw>@10.0.0.230:5432/monica_site` |
| monica_site | beta | `monica_site_beta` | `postgres://westfarn:<pw>@10.0.0.230:5432/monica_site_beta` |
| url_shortening_service | prod | `url_shortener` | `postgres://westfarn:<pw>@10.0.0.230:5432/url_shortener` |
| url_shortening_service | beta | `url_shortener_beta` | `postgres://westfarn:<pw>@10.0.0.230:5432/url_shortener_beta` |
| college_craft | prod | `college_craft` | `postgres://westfarn:<pw>@10.0.0.230:5432/college_craft` |
| college_craft | beta | `college_craft_beta` | `postgres://westfarn:<pw>@10.0.0.230:5432/college_craft_beta` |
| print_forge | prod | `print_forge` | `postgres://westfarn:<pw>@10.0.0.230:5432/print_forge` |
| print_forge | beta | `print_forge_beta` | `postgres://westfarn:<pw>@10.0.0.230:5432/print_forge_beta` |
| abc_be | beta | `abc_be_beta` | `postgres://westfarn:<pw>@10.0.0.230:5432/abc_be_beta` |
| abc_worker | beta | *(same `abc_be_beta`)* | worker uses `DB_*` pointing at `abc_be_beta` — do **not** create a second DB |
Server prereqs on 10.0.0.230: create each DB + grant `westfarn`;
`listen_addresses` covers LAN; `pg_hba.conf` allows `10.0.0.0/24`; firewall opens
5432 to `10.0.0.0/24` only.
`url_shortening_service` extra env (control-node secrets, not in git):
`SHORT_PUBLIC_HOSTS` / `SHORT_API_HOSTS` / `SHORT_ADMIN_HOSTS` (admin = `localhost,127.0.0.1` only),
`SHORTENER_API_TOKENS` (`monica:<token>`), `SHORT_ALLOWED_HOSTS`, `CLICK_IP_PEPPER`
(distinct from `DJANGO_SECRET_KEY`). `DJANGO_DEBUG=false` for prod and beta.
Caller `monica_site` uses `SHORTENER_BASE_URL=https://<api-host>` and Bearer mint;
do not mint via the short hostname.
`college_craft` extra env (control-node secrets, not in git):
`~/Documents/secrets/college_craft/college_craft_prod.env` and
`college_craft_beta.env`. Template: app repo `.env.prod.example`.
`DJANGO_ENV` / `DJANGO_ALLOWED_HOSTS` / `DATABASE_URL` / `WEB_PORT` /
`PUBLIC_SITE_URL` differ per env (prod `collegecraft.com` / `:8006`;
beta `college-craft-preview.aimloperations.com` / `:8016`).
`SITE_UNDER_CONSTRUCTION=true` on prod (holding page until launch), `false` on beta.
`FEATURE_BLOG=true`; other `FEATURE_*` stay false until purchased. Prod also needs
`TIANJI_WEBSITE_ID` + reCAPTCHA keys. If email/SMS or direct mail is turned on later,
add a `college_craft:<token>` entry to `SHORTENER_API_TOKENS` on `url_shortening_service`.
App-repo companion (`college_craft`, not this repo): deploy workflows still call
`--app client_site` (template leftover). They must become `--app college_craft`
before CI deploy will hit this catalog entry. Default branch is `master` (not `main`).
`print_forge` extra env (control-node secrets, not in git) — **currently stopped**
([#33](https://git.aimloperations.com/ai_ml_operations/server-infra/issues/33)):
`host_apps` rows stay with `enabled: false`. Bring back with `enabled` removed/true
then `deploy.sh`. Secrets kept:
`~/Documents/secrets/print_forge/print_forge_prod.env` and
`print_forge_beta.env`. Template: app repo `.env.prod.example`.
`DJANGO_ENV` / `DJANGO_ALLOWED_HOSTS` / `DATABASE_URL` / `WEB_PORT` /
`PUBLIC_SITE_URL` differ per env (prod `printforgeprints.com` / `:8007`;
beta `print-forge-preview.aimloperations.com` / `:8019`).
`SITE_UNDER_CONSTRUCTION=true` on prod (holding page until launch), `false` on beta.
Purchased flags: `FEATURE_EMAIL_SMS`, `FEATURE_PAYMENTS`, `FEATURE_SHOP`,
`FEATURE_SHIPPING`. Other `FEATURE_*` stay false. Add a `print_forge:<token>`
entry to `SHORTENER_API_TOKENS` on `url_shortening_service` (beta token first).
`SHORTENER_BASE_URL` is prod shortener on prod, `https://shortener-beta.aimloperations.com`
on beta. Default branch is `master`.
Companion app ticket: [print_forge#1](https://git.aimloperations.com/ai_ml_operations/print_forge/issues/1)
([#27](https://git.aimloperations.com/ai_ml_operations/server-infra/issues/27)).
App Gitea workflow still calls `--app print_forge` on `master`; deploy **no-ops**
while `enabled: false`.
`abc_be` / `abc_worker` / `abc_fe` (GIS org, not this repo) — **beta only** ([#26](https://git.aimloperations.com/ai_ml_operations/server-infra/issues/26)):
Control-node secrets (never git, mode `600`):
```text
~/Documents/secrets/abc_be/abc_be_beta.env
~/Documents/secrets/abc_worker/abc_worker_beta.env
```
Templates: `GIS/abc_be` `.env.beta.example`, `GIS/abc_worker` `.env.beta.example`.
`abc_fe` has no secret file — public `VITE_*` URLs live in committed `.env.beta`.
Create Postgres DB `abc_be_beta` and grant `westfarn` before first deploy.
NPM: `beta.abc.aimloperations.com` → `:8085`, `beta.abc.be.aimloperations.com` → `:8017`,
`beta.abc.worker.aimloperations.com` → `:8018` (enable WebSocket). No prod ABC
`host_apps` rows; ports **8009** / 8008 / 8084 reserved (`print_forge` took prod **8007**).
Companion workflows: [abc_be#22](https://git.aimloperations.com/GIS/abc_be/issues/22),
[abc_worker#27](https://git.aimloperations.com/GIS/abc_worker/issues/27),
[abc_fe#27](https://git.aimloperations.com/GIS/abc_fe/issues/27).
`livekit` (GIS org, SFU on webservers — [abc_worker#14](https://git.aimloperations.com/GIS/abc_worker/issues/14)[#17](https://git.aimloperations.com/GIS/abc_worker/issues/17)):
Control-node secret (never git, mode `600`):
```text
~/Documents/secrets/livekit/livekit_beta.env
```
Template: `GIS/livekit` `.env.beta.example`. Same `LIVEKIT_API_KEY` /
`LIVEKIT_API_SECRET` pair as `abc_be` / `abc_worker`; worker `LIVEKIT_URL=wss://<LIVEKIT_DOMAIN>`.
Deploy: `./scripts/deploy.sh --app livekit --env beta` (all webservers, like
`abc_worker`). NPM: `LIVEKIT_DOMAIN` → **one** host `:7880` with WebSocket
(adama). Router DNAT UDP 3478 + 5000060000 and TCP 7881 to that same host.
Do not active/active-balance LiveKit until Redis is in the compose.
`dta_blog` ([#29](https://git.aimloperations.com/ai_ml_operations/server-infra/issues/29)) —
Python SSG, **no secrets file**. `npm run build:<env>` writes
`/var/www/<env>.blog.realpath.app/html`. Prod (`:8086`) omits `demo: true` seed
posts; beta (`:8087`) includes them. Header/footer links are baked at build
(`https://realpath.app` vs `https://beta.realpath.app`); Tianji website ids
are likewise baked (`cmtvvmf562afjzqumwt1yh2y8` / `cmtvvn6z62agdzqumtk8xijpy`).
Default branch is `main`.
NPM / DNS / Cloudflare (Ansible does not manage these): `blog.realpath.app` →
`:8086`, `beta.blog.realpath.app` → `:8087`, TLS like the other RealPath names.
Do **not** mount this on `realpath.app/blog`. Static ports stay LAN / NPM-only
(UFW does not world-open 8086/8087).
Companion app ticket: [dta_blog#1](https://git.aimloperations.com/Ditch_The_Agent/dta_blog/issues/1).
Gitea deploy can later call `./scripts/deploy.sh --app dta_blog --env beta|prod`.
### One-time host bootstrap (per target)
- [x] Gitea SSH key: the `gitea-key` role (in `site.yml`) generates a key per
server, configures SSH for port 30009, probes access, and — if the server
can't reach Gitea yet — prints the public key to add and stops. Add the key
(Gitea user SSH keys, or repo Deploy Keys) and re-run provisioning.
- [ ] Create control-node secrets `{{ secrets_dir }}/<app>/<app>_<env>.env`
(default `~/Documents/secrets/<app>/<app>_<env>.env`) with `DATABASE_URL`
(see table), `DJANGO_ENV`, `DJANGO_SECRET_KEY`, `WEB_PORT` (matching the port
table). Deploy pushes these to `/opt/apps/env/<app>_<env>.env` (mode 600) on
adama + roslin + starbuck + apollo. Never committed to git.
- [x] Node.js/npm/npx for the `dta_webapp` build — installed by the `nodejs`
role in `site.yml` (NodeSource, `node_major` default 20).
## Gitea Act Runner
**Recommended:** Single self-hosted runner on ai-server-4080.
- One orchestration point.
- App hosts (adama/roslin/starbuck/apollo) run the workloads; no runner needed on them for deploy fan-out.
- Runner needs: Ansible, this repo checked out, SSH key to all hosts, vault password (later).
### Runner requirements on ai-server-4080
| Requirement | Why |
|-------------|-----|
| Ansible | Run `deploy-apps.yml` |
| `server-infra` checkout | Playbooks + inventory |
| SSH key to app hosts | Deploy fan-out |
On every push or merged PR to `master`, `.gitea/workflows/sync-checkout.yml`
fast-forward pulls this repo at `~/Documents/repos/server-infra` on the Act
runner so playbooks and inventory stay current without a manual `git pull`.
## SSH Keys for CI Deploy
| Key | Used by | Purpose |
|-----|---------|---------|
| Personal key | You | Manual provisioning |
| Deploy key (runner) | Act → Ansible → hosts | Automated deploy |
Consider a dedicated `deploy` user with limited sudo (docker only) — future hardening step.
## Secrets (Phase 2)
Use Ansible Vault for production secrets. Do not commit plaintext.
```bash
ansible-vault create inventory/group_vars/webservers/vault.yml
ansible-playbook playbooks/site.yml --ask-vault-pass
```
Store vault password for CI in a file readable only by the Act runner (e.g. `~/.ansible-vault-pass`, mode 600).
## Implementation Order
| # | Task | Status |
|---|------|--------|
| 1 | Create `server-infra` repo | Done |
| 2 | Inventory with all 5 hosts | Done ([#20](https://git.aimloperations.com/ai_ml_operations/server-infra/issues/20)) |
| 3 | Bootstrap SSH to app hosts | Manual |
| 4 | `site.yml` → common, ufw, docker | Done |
| 5 | Verify `ansible webservers -m ping` | Manual |
| 6 | Test on single server: `./scripts/provision.sh adama` | Manual |
| 7 | Provision all: `./scripts/provision.sh` | Manual |
| 8 | Deploy SSH key for Act runner | Future |
| 8a | Auto-sync runner checkout on `master` (`.gitea/workflows/sync-checkout.yml`) | Done |
| 9 | Stub `deploy-apps.yml` + update `company_site` workflow | Future |
| 10 | Dockerize `company_site` | Future (separate ticket) |
| 10a | Register + deploy `scha` (all webservers, port 8002) | In progress ([scha#19](https://git.aimloperations.com/ai_ml_operations/scha/issues/19)) |
| 10b | Register + deploy `chat_web_app` (node-static, ports 8082/8083) | Done (prod); beta ([#7](https://git.aimloperations.com/ai_ml_operations/server-infra/issues/7), [chat_web_app#35](https://git.aimloperations.com/ai_ml_operations/chat_web_app/issues/35)) |
| 10c | Register + deploy `chat_backend` (django, ports 8003/8013) | Done (prod); beta ([#7](https://git.aimloperations.com/ai_ml_operations/server-infra/issues/7), [chat_backend#26](https://git.aimloperations.com/ai_ml_operations/chat_backend/issues/26)) |
| 10d | Register + deploy `monica_site` (django, ports 8004/8014) | Done ([#14](https://git.aimloperations.com/ai_ml_operations/server-infra/issues/14)) |
| 10e | Auto-start `monica_site` dj-queue worker on adama (`compose_profiles`) | Done ([#17](https://git.aimloperations.com/ai_ml_operations/server-infra/issues/17)) |
| 10f | Add starbuck + apollo as app hosts (same workloads as roslin) | Done ([#20](https://git.aimloperations.com/ai_ml_operations/server-infra/issues/20)) |
| 10g | Register + deploy `url_shortening_service` (django, ports 8005/8015) | Done ([#22](https://git.aimloperations.com/ai_ml_operations/server-infra/issues/22)) |
| 10h | Register + deploy `college_craft` (django, ports 8006/8016) | Done ([#24](https://git.aimloperations.com/ai_ml_operations/server-infra/issues/24)) |
| 10i | Register + deploy ABC beta (`abc_be` 8017, `abc_worker` 8018, `abc_fe` 8085) | Done ([#26](https://git.aimloperations.com/ai_ml_operations/server-infra/issues/26)) |
| 10j | Register + deploy `print_forge` (django, ports 8007/8019) | Done ([#27](https://git.aimloperations.com/ai_ml_operations/server-infra/issues/27)) |
| 10k | Register + deploy `dta_blog` (node-static, ports 8086/8087) | Done ([#29](https://git.aimloperations.com/ai_ml_operations/server-infra/issues/29)) |
| 10l | Register GIS LiveKit SFU (`abc_worker`-style docker on webservers, port 7880) | This PR |
| 11 | Gitea container registry (optional) | Future |
## Open Decisions
1. **Deploy user** — `westfarn` vs dedicated `deploy` for CI.
2. **NPM load balancing** — confirm jc21 NPM can express all app-host upstreams (Advanced tab), else active/active is just independent instances.
3. **Secrets** — Ansible Vault vs per-host env files (currently per-host `/opt/apps/env/*.env`).
## Adding a New VM
1. Add host to `inventory/hosts.yml` under `webservers`.
2. Bootstrap SSH: `ssh-copy-id westfarn@<new-ip>`.
3. Test: `./scripts/provision.sh <hostname> --check`.
4. Provision: `./scripts/provision.sh <hostname>`.
5. Deploys automatically include new host once in `webservers` group.