feat: initialize serverAppConfigs with podman stacks for Dankserver and Dankclaw

- Dankserver stack: RomM, Copyparty, Calibre-Web, node-exporter
- Dankclaw stack: Gluetun VPN (SearXNG tunnel), SearXNG+Valkey, ComfyUI
  (CPU backend), OpenWebUI, Hermes, Gitea+Postgres (migrated), Caddy
  reverse proxy, Grafana+Prometheus+Loki+Alloy observability
- Caddyfiles with .local hostname convention and basicauth homepage
- bind-mount storage policy, no named volumes
- deploy.sh mirroring vllmScripts git workflow (push -> pull -> restart -> health)
- docs/ Obsidian vault: per-service docs from frank-default-small research
  subagents (promtail EOL -> Alloy; gitea SECRET_KEY preservation; ComfyUI --cpu)
- bats test suite at 6 seams — 22 tests green
This commit is contained in:
Frank
2026-08-13 20:19:41 -07:00
commit e3eb5334cb
57 changed files with 3033 additions and 0 deletions
+14
View File
@@ -0,0 +1,14 @@
# Local environment files — never commit real values
.env
*.env
!.env.example
# pi subagent artifacts
.pi-subagents/
# Agent trash
.AGENTTRASH/
# Editor/OS noise
*.swp
.DS_Store
+68
View File
@@ -0,0 +1,68 @@
# AGENTS.md — serverAppConfigs Agent Rules
## Overview
This repository tracks the Podman container stacks for two homelab servers. Dankserver runs the media/library stack (RomM, Copyparty, Calibre-Web). Dankclaw runs the AI/search/development stack (SearXNG via Gluetun, ComfyUI, OpenWebUI, Hermes, Gitea, Grafana, Prometheus) plus the observability stack. Caddy reverse-proxies everything on both hosts.
## Git Workflow
All config edits flow through git. Do not edit files on the remote machine directly.
### Protocol
1. **Source the config from the target machine** — fetch the live compose/Caddyfile from the server where it runs
2. **Place it in this repo** — commit the fetched version (redact secrets into `.env`)
3. **Edit here** — make all changes locally in the working tree
4. **Run the tests** — `./tests/run.sh` must pass
5. **Commit and push** — push to origin (Gitea on dankclaw)
6. **Pull on the target** — `git pull` on the remote machine
7. **Restart the stack** — use `scripts/deploy.sh` to pull + restart + healthcheck
## Deployment
Use `scripts/deploy.sh` to deploy committed changes to a target.
```
./scripts/deploy.sh dankclaw # Dankclaw stack
./scripts/deploy.sh dankserver # Dankserver stack
./scripts/deploy.sh dankclaw --dry-run # preview without changes
```
## Testing
Tests are mandatory before commit. `bats` must be installed on the development machine.
```
./tests/run.sh
```
Test seams: compose validity, env consistency (no leaked secrets), Caddyfile validity, docs completeness, script syntax, deployment flow. See [docs/Architecture/Testing Policy.md](docs/Architecture/Testing Policy.md).
## Secret Rules
- Never commit real secrets. The live Gitea database password is test-enforced as a leak check.
- `.env` files are git-ignored; commit only `.env.example` with placeholders.
- Do not document passwords in this repo, including sudo passwords. SSH keys are the auth mechanism.
## Runtime & Troubleshooting
- Model load on the LLM servers takes 3–8 minutes; `503 Loading model` is normal during load. Do not kill the process.
- `sudo` on both boxes needs a tty. Do not use `echo password | sudo -S` in committed scripts; run sudo interactively.
- Never run `pkill -f` with a pattern that appears in your own command line. Use `pkill -x <exact-name>`.
- ComfyUI on Dankclaw runs CPU-only (`--cpu`); ROCm wheels do not support gfx1151. Do not use CUDA images.
- SearXNG outbound traffic must stay on the Gluetun tunnel; do not move its ports off the gluetun service.
## Why
Direct edits on the remote drift from version control. Future agents will not know what changed. Git is the single source of truth.
## Summary
| Context | Action |
|---------|--------|
| Editing a compose/Caddyfile | **Work through git** — fetch, commit, edit, test, push, pull, restart. Never edit on the remote directly. |
| Config exists only on remote | **Pull it into the repo first** — commit the fetched version (redacted) before making changes. |
| Unstaged local changes | **Commit or discard** — check `git status` before starting work. Ask the user if unsure. |
| Deploying a committed change | **Run `scripts/deploy.sh <target>`** — it pulls on the target, restarts the stack, and checks health. |
| Tests exist | **Run them** — verify tests pass after every change. Do not commit failing tests. |
| Real secrets | **Never commit** — use host `.env` files; `.env.example` holds placeholders only. |
+50
View File
@@ -0,0 +1,50 @@
# Server App Configs
Podman deployment configuration for two homelab servers: Dankserver and Dankclaw.
## Targets
| Host | User | IP | Stack |
|------|------|----|-------|
| dankclaw | dank | 192.168.0.143 | AI, search, development, observability |
| dankserver | dank | 192.168.0.235 | media and library |
## Active Services
| Service | Host | Hostname | Port |
|---------|------|----------|------|
| RomM | dankserver | romm.dankserver.local | 8085 |
| Copyparty | dankserver | files.dankserver.local | 3923 |
| Calibre-Web | dankserver | books.dankserver.local | 8083 |
| SearXNG | dankclaw | search.dankclaw.local | 8081 |
| ComfyUI | dankclaw | comfyui.dankclaw.local | 8188 |
| OpenWebUI | dankclaw | chat.dankclaw.local | 3000 |
| Hermes | dankclaw | hermes.dankclaw.local | 8090 |
| Gitea | dankclaw | gitea.dankclaw.local | 3001 |
| Grafana | dankclaw | grafana.dankclaw.local | 3002 |
| Prometheus | dankclaw | prometheus.dankclaw.local | 9090 |
## Structure
- `dankserver/` — compose.yaml + Caddyfile + .env.example for the Dankserver stack
- `dankclaw/` — compose.yaml + Caddyfile + .env.example for the Dankclaw stack
- `shared/` — homepage, prometheus config, deploy helpers
- `scripts/` — deploy.sh and stack control scripts
- `docs/` — project knowledge base (Obsidian vault, wikilinks)
- `tests/` — bats test suite (see [[Testing Policy]])
## Deployment
See [docs/Architecture/Deployment Flow.md](docs/Architecture/Deployment Flow.md). Run `./scripts/deploy.sh <dankclaw|dankserver>` after pushing committed changes.
## Knowledge Base
The `docs/` directory is an Obsidian vault. Start at [docs/index.md](docs/index.md).
## Testing
Run `./tests/run.sh` before every push. See [docs/Architecture/Testing Policy.md](docs/Architecture/Testing Policy.md).
## Also See
- [vllmScripts](https://gitea.dankclaw.local/.../vllmScripts) — the deployment model this repo follows
+22
View File
@@ -0,0 +1,22 @@
# Dankclaw stack secrets and configuration.
# Copy to .env and fill real values. Never commit real secrets.
# SearXNG
SEARXNG_SECRET=changeme
SEARXNG_BASE_URL=http://search.dankclaw.local/
# Gluetun VPN (see https://github.com/qdm12/gluetun-wiki)
VPN_SERVICE_PROVIDER=mullvad
VPN_TYPE=wireguard
WIREGUARD_PRIVATE_KEY=changeme
# Gitea database
GITEA_DB_PASSWORD=changeme
# Grafana admin
GRAFANA_ADMIN_USER=admin
GRAFANA_ADMIN_PASSWORD=changeme
# OpenWebUI -> llama.cpp on the host (frank-default-*, port 7770)
OPENAI_API_BASE_URL=http://host.containers.internal:7770/v1
OPENAI_API_KEY=not-needed
+76
View File
@@ -0,0 +1,76 @@
# Dankclaw Caddyfile
# Reverse proxy for the Dankclaw stack. Caddy runs as a container on the
# podman network; upstreams are container names. Homepage is password
# protected. Replace the bcrypt hashes below (caddy hash-password) at deploy.
{
admin off
email admin@dankclaw.local
}
# --- Homepage (password protected) ---
dankclaw.local {
basicauth {
dank $2a$14$REPLACE_WITH_HASH
}
root * /srv/homepage
file_server
}
# --- SearXNG (behind the Gluetun tunnel) ---
search.dankclaw.local {
basicauth {
dank $2a$14$REPLACE_WITH_HASH
}
reverse_proxy gluetun:8080
}
# --- ComfyUI ---
comfyui.dankclaw.local {
reverse_proxy comfyui:8188
}
# --- OpenWebUI ---
chat.dankclaw.local {
basicauth {
dank $2a$14$REPLACE_WITH_HASH
}
reverse_proxy openwebui:8080
}
# --- Hermes ---
hermes.dankclaw.local {
basicauth {
dank $2a$14$REPLACE_WITH_HASH
}
reverse_proxy hermes:8090
}
# --- Gitea (migrated) ---
gitea.dankclaw.local {
reverse_proxy gitea:3000
}
# --- Grafana ---
grafana.dankclaw.local {
basicauth {
dank $2a$14$REPLACE_WITH_HASH
}
reverse_proxy grafana:3000
}
# --- Prometheus ---
prometheus.dankclaw.local {
basicauth {
dank $2a$14$REPLACE_WITH_HASH
}
reverse_proxy prometheus:9090
}
# --- Loki ---
loki.dankclaw.local {
basicauth {
dank $2a$14$REPLACE_WITH_HASH
}
reverse_proxy loki:3100
}
+275
View File
@@ -0,0 +1,275 @@
# Dankclaw stack — AI, search, development & observability services.
# Deploy: cd /home/dank/serverAppConfigs && podman-compose -f dankclaw/compose.yaml up -d
# See ../docs/Dankclaw/ and ../docs/Infrastructure/ for service documentation.
services:
# --- VPN tunnel: SearXNG outbound traffic only ---------------------------
gluetun:
image: qmcgaw/gluetun:latest
container_name: gluetun
restart: always
cap_add:
- NET_ADMIN
devices:
- /dev/net/tun:/dev/net/tun
environment:
VPN_SERVICE_PROVIDER: "${VPN_SERVICE_PROVIDER}"
VPN_TYPE: "${VPN_TYPE}"
WIREGUARD_PRIVATE_KEY: "${WIREGUARD_PRIVATE_KEY}"
ports:
# SearXNG's published port lives on gluetun (network_mode: service:gluetun)
- "8081:8080"
healthcheck:
test: ["CMD", "curl", "-sf", "http://localhost:8000/health"]
interval: 60s
timeout: 10s
retries: 3
searxng:
image: ghcr.io/searxng/searxng:latest
container_name: searxng
restart: unless-stopped
network_mode: "service:gluetun"
depends_on:
- gluetun
- valkey
environment:
SEARXNG_SECRET: "${SEARXNG_SECRET}"
SEARXNG_BASE_URL: "${SEARXNG_BASE_URL}"
volumes:
- /opt/searxng/config:/etc/searxng
healthcheck:
test: ["CMD", "python3", "-c", "import urllib.request; urllib.request.urlopen('http://localhost:8080/healthz')"]
interval: 30s
timeout: 10s
retries: 3
valkey:
image: valkey/valkey:9-alpine
container_name: searxng-valkey
restart: always
volumes:
- /opt/searxng/valkey:/data
healthcheck:
test: ["CMD", "valkey-cli", "ping"]
interval: 30s
timeout: 10s
retries: 3
# --- AI image generation (CPU backend; ROCm does not support gfx1151) ----
comfyui:
image: ghcr.io/comfyanonymous/comfyui:latest
container_name: comfyui
restart: unless-stopped
command: ["python", "main.py", "--cpu", "--listen", "0.0.0.0", "--port", "8188"]
ports:
- "8188:8188"
volumes:
- /opt/comfyui:/workspace
healthcheck:
test: ["CMD", "python3", "-c", "import urllib.request; urllib.request.urlopen('http://localhost:8188/')"]
interval: 30s
timeout: 10s
retries: 3
# --- Chat UI + agent -----------------------------------------------------
openwebui:
image: ghcr.io/open-webui/open-webui:main
container_name: openwebui
restart: unless-stopped
environment:
OPENAI_API_BASE_URL: "${OPENAI_API_BASE_URL}"
OPENAI_API_KEY: "${OPENAI_API_KEY}"
ports:
- "3000:8080"
volumes:
- /opt/openwebui/data:/app/backend/data
healthcheck:
test: ["CMD", "curl", "-f", "http://localhost:8080/health"]
interval: 30s
timeout: 10s
retries: 3
hermes:
image: docker.io/nousresearch/hermes-agent:latest
container_name: hermes
restart: unless-stopped
environment:
HERMES_DATA_DIR: /data
ports:
- "8090:8090"
volumes:
- /opt/hermes/data:/data
healthcheck:
test: ["CMD", "python3", "-c", "import urllib.request; urllib.request.urlopen('http://localhost:8090/')"]
interval: 30s
timeout: 10s
retries: 3
# --- Git hosting (migrated from /opt/gitea) ------------------------------
postgres:
image: docker.io/library/postgres:16-alpine
container_name: gitea-postgres
restart: unless-stopped
environment:
POSTGRES_USER: gitea
POSTGRES_PASSWORD: "${GITEA_DB_PASSWORD}"
POSTGRES_DB: gitea
volumes:
- /opt/gitea/data/gitea-postgres:/var/lib/postgresql/data
healthcheck:
test: ["CMD-SHELL", "pg_isready -U gitea"]
interval: 30s
timeout: 10s
retries: 3
gitea:
image: docker.io/gitea/gitea:latest
container_name: gitea-server
restart: unless-stopped
environment:
DATABASE_TYPE: postgres
DATABASE_HOST: postgres
DATABASE_NAME: gitea
DATABASE_USER: gitea
DATABASE_PASSWD: "${GITEA_DB_PASSWORD}"
DATABASE_PORT: "5432"
USER_UID: "1000"
USER_GID: "1000"
ports:
- "3001:3000"
- "2222:22"
volumes:
- /opt/gitea/data/gitea-gitea:/data
- /etc/timezone:/etc/timezone:ro
- /etc/localtime:/etc/localtime:ro
depends_on:
- postgres
healthcheck:
test: ["CMD", "wget", "--spider", "-q", "http://localhost:3000/"]
interval: 30s
timeout: 10s
retries: 3
# --- Observability -------------------------------------------------------
prometheus:
image: docker.io/prom/prometheus:latest
container_name: prometheus
restart: unless-stopped
command:
- --config.file=/etc/prometheus/prometheus.yml
- --storage.tsdb.path=/prometheus
- --storage.tsdb.retention.time=30d
- --web.enable-lifecycle
ports:
- "9090:9090"
volumes:
- /opt/prometheus/config:/etc/prometheus
- /opt/prometheus/data:/prometheus
healthcheck:
test: ["CMD", "wget", "--spider", "-q", "http://localhost:9090/-/healthy"]
interval: 30s
timeout: 10s
retries: 3
grafana:
image: docker.io/grafana/grafana:latest
container_name: grafana
restart: unless-stopped
environment:
GF_SERVER_ROOT_URL: "http://grafana.dankclaw.local"
GF_SECURITY_ADMIN_USER: "${GRAFANA_ADMIN_USER}"
GF_SECURITY_ADMIN_PASSWORD: "${GRAFANA_ADMIN_PASSWORD}"
ports:
- "3002:3000"
volumes:
- /opt/grafana/data:/var/lib/grafana
- /opt/grafana/provisioning:/etc/grafana/provisioning:ro
depends_on:
- prometheus
healthcheck:
test: ["CMD", "wget", "--spider", "-q", "http://localhost:3000/api/health"]
interval: 30s
timeout: 10s
retries: 3
loki:
image: docker.io/grafana/loki:latest
container_name: loki
restart: unless-stopped
command: ["-config.file=/etc/loki/loki-config.yaml"]
ports:
- "3100:3100"
volumes:
- /opt/loki/config:/etc/loki
- /opt/loki/data:/loki
healthcheck:
test: ["CMD", "wget", "--spider", "-q", "http://localhost:3100/ready"]
interval: 30s
timeout: 10s
retries: 3
alloy:
image: docker.io/grafana/alloy:latest
container_name: alloy
restart: unless-stopped
volumes:
- /opt/alloy/config:/etc/alloy
- /run/systemd/journal/socket:/run/systemd/journal/socket:ro
- /var/log:/var/log:ro
- /var/lib/containers:/var/lib/containers:ro
depends_on:
- loki
healthcheck:
test: ["CMD", "wget", "--spider", "-q", "http://localhost:12345/-/healthy"]
interval: 30s
timeout: 10s
retries: 3
node-exporter:
image: docker.io/prom/node-exporter:latest
container_name: node-exporter
restart: unless-stopped
network_mode: host
pid: host
volumes:
- /proc:/host/proc:ro
- /sys:/host/sys:ro
- /:/rootfs:ro
command:
- --path.procfs=/host/proc
- --path.sysfs=/host/sys
- --path.rootfs=/rootfs
healthcheck:
test: ["CMD", "wget", "--spider", "-q", "http://localhost:9100/metrics"]
interval: 30s
timeout: 10s
retries: 3
# --- Reverse proxy + homepage -------------------------------------------
caddy:
image: docker.io/library/caddy:latest
container_name: caddy
restart: unless-stopped
ports:
- "80:80"
- "443:443"
volumes:
- ./Caddyfile:/etc/caddy/Caddyfile:ro
- /opt/caddy/data:/data
- /opt/caddy/config:/config
- ../shared/homepage:/srv/homepage:ro
depends_on:
- searxng
- comfyui
- openwebui
- hermes
- gitea
- grafana
- prometheus
- loki
healthcheck:
test: ["CMD", "wget", "--spider", "-q", "http://localhost:80/"]
interval: 30s
timeout: 10s
retries: 3
+7
View File
@@ -0,0 +1,7 @@
# Dankserver stack secrets and configuration.
# Copy to .env and fill real values. Never commit real secrets.
# RomM admin credentials (first boot)
ROMM_USERNAME=changeme
ROMM_PASSWORD=changeme
ROMM_SECRET_KEY=changeme
+56
View File
@@ -0,0 +1,56 @@
# Dankserver Caddyfile
# Migrated from the live /etc/caddy/Caddyfile on dankserver, extended with the
# new stack services. Native caddy (systemd) on this host; upstreams are host
# ports because the services publish to the host.
{
admin off
email admin@dankserver.local
}
# --- Homepage (password protected) ---
dankserver.local {
basicauth {
dank $2a$14$REPLACE_WITH_HASH
}
root * /srv/homepage
file_server
}
# --- Plex (existing, kept) ---
plex.franksdank.casa {
reverse_proxy localhost:32400
}
plex.franksdank.cloud {
reverse_proxy localhost:32400
}
plex.dankserver.local {
reverse_proxy localhost:32400
}
# --- Calibre-Web (existing, kept) ---
books.franksdank.cloud {
reverse_proxy localhost:8083
}
books.dankserver.local {
reverse_proxy localhost:8083
}
# --- RomM ---
romm.dankserver.local {
basicauth {
dank $2a$14$REPLACE_WITH_HASH
}
reverse_proxy localhost:8085
}
# --- Copyparty ---
files.dankserver.local {
basicauth {
dank $2a$14$REPLACE_WITH_HASH
}
reverse_proxy localhost:3923
}
+76
View File
@@ -0,0 +1,76 @@
# Dankserver stack — media & library services.
# Deploy: cd /home/dank/serverAppConfigs && podman-compose -f dankserver/compose.yaml up -d
# See ../docs/Dankserver/ for service documentation.
services:
romm:
image: ghcr.io/rommapp/romm:latest
container_name: romm
restart: unless-stopped
environment:
ROMM_USERNAME: "${ROMM_USERNAME}"
ROMM_PASSWORD: "${ROMM_PASSWORD}"
ROMM_SECRET_KEY: "${ROMM_SECRET_KEY}"
ports:
- "8085:8080"
volumes:
- /opt/romm/library:/romm/library
- /opt/romm/resources:/romm/resources
healthcheck:
test: ["CMD", "curl", "-f", "http://localhost:8080/"]
interval: 30s
timeout: 10s
retries: 3
copyparty:
image: ghcr.io/9001/copyparty:latest
container_name: copyparty
restart: unless-stopped
ports:
- "3923:3923"
volumes:
- /opt/copyparty/data:/data
healthcheck:
test: ["CMD", "python3", "-c", "import urllib.request; urllib.request.urlopen('http://localhost:3923/')"]
interval: 30s
timeout: 10s
retries: 3
calibre-web:
image: lscr.io/linuxserver/calibre-web:latest
container_name: calibre-web
restart: unless-stopped
environment:
PUID: "1000"
PGID: "1000"
TZ: Etc/UTC
ports:
- "8083:8083"
volumes:
- /opt/calibre-web/config:/config
- /opt/calibre-web/books:/books
healthcheck:
test: ["CMD", "curl", "-f", "http://localhost:8083/"]
interval: 30s
timeout: 10s
retries: 3
node-exporter:
image: prom/node-exporter:latest
container_name: node-exporter
restart: unless-stopped
network_mode: host
pid: host
volumes:
- /proc:/host/proc:ro
- /sys:/host/sys:ro
- /:/rootfs:ro
command:
- --path.procfs=/host/proc
- --path.sysfs=/host/sys
- --path.rootfs=/rootfs
healthcheck:
test: ["CMD", "wget", "--spider", "-q", "http://localhost:9100/metrics"]
interval: 30s
timeout: 10s
retries: 3
+8
View File
@@ -0,0 +1,8 @@
{
"alwaysUpdateLinks": true,
"newFileLocation": "folder",
"newFileFolderPath": "Dankclaw",
"attachmentFolderPath": "_attachments",
"useMarkdownLinks": false,
"showUnsupportedFiles": true
}
+57
View File
@@ -0,0 +1,57 @@
# Deployment Flow
Deployment mirrors the `vllmScripts` repository: git is the single source of truth, and a `deploy.sh` script moves committed changes to the target hosts over SSH.
## Workflow
```mermaid
graph LR
A[Edit in this repo] --> B[Commit]
B --> C[Push to origin]
C --> D[deploy.sh: pull on target]
D --> E[restart stack]
E --> F[health check]
```
1. Edit files locally in this repository.
2. Commit and push.
3. Run `./scripts/deploy.sh <dankclaw|dankserver>`.
4. The script pulls the repo on the target and restarts the podman stack.
5. It verifies service health after restart.
## Deploy Script
```
./scripts/deploy.sh dankclaw # pull + restart on Dankclaw
./scripts/deploy.sh dankserver # pull + restart on Dankserver
./scripts/deploy.sh dankclaw --dry-run
```
SSH uses the existing key pair (`dank@dankclaw`, `dank@dankserver`). No passwords are stored or documented in this repo. If sudo is needed, use the interactive sudo on the target — never commit a password.
## Targets
| Target | Host | Stack |
|--------|------|-------|
| dankclaw | dank@dankclaw (192.168.0.143) | `dankclaw/` |
| dankserver | dank@dankserver (192.168.0.235) | `dankserver/` |
## Health Verification
The deploy script polls the health endpoint of each service after restart. Service healthchecks are defined in each compose file. A service that fails its healthcheck is reported, not hidden.
## Tests
Run the test suite before pushing:
```
./tests/run.sh
```
The suite validates compose YAML, env consistency, Caddyfiles, docs completeness, and script syntax. See [[Testing Policy]].
## See Also
- [[Storage Policy]]
- [[Testing Policy]]
- [vllmScripts deployment docs](../../../vllmScripts/docs/deployment.md) — the model this follows
+54
View File
@@ -0,0 +1,54 @@
# Network Architecture
Both hosts run Caddy as the single entry point. Every service is reachable by hostname; the router firewall governs external access. On Dankclaw, SearXNG's outbound traffic exits through the Gluetun VPN container.
## Topology
```mermaid
graph TB
subgraph WAN[Router 192.168.0.1 firewall]
EXT[Internet]
end
EXT -->|port forward| DS[Caddy Dankserver]
EXT -->|port forward| DC[Caddy Dankclaw]
subgraph Dankserver[192.168.0.235]
DS --> R[RomM :8085]
DS --> P[Copyparty :3923]
DS --> W[Calibre-Web :8083]
end
subgraph Dankclaw[192.168.0.143]
DC --> O[OpenWebUI :3000]
DC --> G[Gitea :3001]
DC --> F[ComfyUI :8188]
DC --> GR[Grafana :3002]
DC --> PR[Prometheus :9090]
DC --> SE[SearXNG :8081]
SE -->|network_mode service| GL[Gluetun VPN]
GL -->|tunnel| EXT
end
```
## Hostnames
| Host | Convention |
|------|------------|
| Dankserver | `*.dankserver.local` → Caddy → service |
| Dankclaw | `*.dankclaw.local` → Caddy → service |
Users never type ports; Caddy maps names to upstreams.
## Gluetun Path (SearXNG only)
SearXNG attaches to the Gluetun network namespace (`network_mode: service:gluetun`). Only SearXNG shares the tunnel. Everything else on Dankclaw uses the normal LAN route. This matches KB Pattern 2 in [[VPN Routing for SearXNG]].
## Access Policy
- All services assume external access is possible; the **router firewall** governs what the WAN can reach.
- Caddy adds HTTP basic auth on the homepage and on sensitive services.
- LAN subnet: `192.168.0.0/24`.
## See Also
- [[Dankserver Stack]], [[Dankclaw Stack]]
- [[Caddy]], [[Gluetun]]
- Knowledge base: [[VPN Routing for SearXNG]], [[SearXNG Tuning]]
+40
View File
@@ -0,0 +1,40 @@
# Storage Policy
All persistent data uses **bind mounts**: a host directory mapped directly into the container. No named volumes. This matches the preference of this repo and the existing Gitea stack on Dankclaw.
## Why Bind Mounts
- The exact host location of every byte is visible and controllable.
- Backups are a simple `tar` of known host directories.
- The data survives container recreation and `podman-compose down`.
- Ownership is explicit: everything runs as UID 1000 (`dank`) so host tools can read it.
## Conventions
| Stack | Host root |
|-------|-----------|
| Dankserver | `/opt/<service>/...` |
| Dankclaw | `/opt/<service>/...` (Gitea keeps `/opt/gitea/data/...`) |
## Ownership
Containers run as `PUID=1000` / `PGID=1000`. Host directories must be owned by `dank`:
```bash
sudo chown -R 1000:1000 /opt/<service>
```
## Secrets
Secrets never live in bind-mounted configs that get committed. They are:
1. Set in `.env` files on the host (git-ignored, `.env.example` committed as template), or
2. Injected as environment variables from the deploy script.
The live Gitea database password exists only in `.env` on Dankclaw. A test enforces that it never appears in this repo.
## See Also
- [[Deployment Flow]]
- [[Gitea]] — the existing bind-mount stack being migrated
- [[Backblaze Integration]] — future backup target for these mounts
+23
View File
@@ -0,0 +1,23 @@
# Testing Policy
This repository tests its configuration at defined seams. The tests run with `bats`; run them with `./tests/run.sh` before every push.
## Seams Under Test
| Seam | File | What it asserts |
|------|------|-----------------|
| Compose validity | `tests/compose_validity.bats` | YAML parses, services have images, no named volumes |
| Env consistency | `tests/env_consistency.bats` | every `${VAR}` is defined in `.env.example`; no real secrets; the live gitea password never appears |
| Caddyfile validity | `tests/caddyfile.bats` | per-host Caddyfile, `.local` convention, balanced braces, basic auth present |
| Docs completeness | `tests/docs_completeness.bats` | every compose service has a docs page; index links every page |
| Script syntax | `tests/scripts_syntax.bats` | `bash -n` clean, executable bits, shellcheck when available |
| Deployment flow | `tests/deployment_flow.bats` | deploy.sh exists, targets known, SSH reachable (skips offline) |
## Red-Green
Tests are written first. A failing test means the seam is not satisfied; the config change that makes it pass is the only acceptable fix. No `--no-verify`, no deleting tests without approval.
## See Also
- [[Deployment Flow]]
- [[index]]
+54
View File
@@ -0,0 +1,54 @@
# ComfyUI
ComfyUI is a node-based AI image generation engine. Workflows are built from nodes that load models, sample latents, and post-process images; everything is scriptable through a web UI and an API.
**Host:** Dankclaw — **Hostname:** `comfyui.dankclaw.local` — **Port:** 8188 — **Docs:** https://github.com/Comfy-Org/ComfyUI
## Facts (from research, primary source)
| Item | Value |
|------|-------|
| Image | `ghcr.io/comfyanonymous/comfyui:latest` (community CPU build) |
| Port | 8188 (host) → 8188 (container) |
| Models volume | bind mount to the model collection |
| CPU flag | `--cpu` — confirmed in `cli_args.py`: "To use the CPU for everything (slow)" |
| Known issue | ROCm wheels do not ship kernels for gfx1151 (Strix Halo) — CPU backend required |
## Why CPU on Dankclaw
Dankclaw is an AMD Strix Halo APU (gfx1151). Precompiled ROCm PyTorch wheels do not support this iGPU. This matches the existing knowledge base entry for ComfyUI on Dankclaw: the running instance uses `--cpu`. The Docker image for CPU execution is the `comfyui` CPU build; the GPU images (`comfyui-cuda`) must not be used.
## Command Line (from `cli_args.py`)
| Flag | Meaning |
|------|---------|
| `--cpu` | use CPU for everything |
| `--cpu-vae` | run the VAE on CPU only |
| `--lowvram` | text encoders on CPU (dynamic VRAM default handles this) |
| `--listen` | bind address (0.0.0.0 for LAN) |
| `--port` | listen port (8188) |
## Volumes
| Container path | Host path (convention) |
|----------------|------------------------|
| `/workspace` | `/opt/comfyui` (models, input, output, custom_nodes) |
## Start Fresh
The model collection starts from scratch. The first boot creates the directory structure; models are added per the knowledge base procedure.
## Healthcheck
The web UI responds on the root path `/`. ComfyUI also exposes `/system_stats`.
## Gotchas
- Expect minutes per image on CPU; that is the documented behavior for this APU.
- ComfyUI-Manager installs custom nodes into `custom_nodes/` — persist that directory.
- Do not pull the CUDA image; it fails to find an NVIDIA driver.
## See Also
- [[Dankclaw Stack]]
- Knowledge base: [[ComfyUI]] (tools), [[ComfyUI Troubleshooting]]
+41
View File
@@ -0,0 +1,41 @@
# Dankclaw Stack
Dankclaw (192.168.0.143) runs the AI, search, and development stack. It hosts the inference endpoint (llama.cpp, port 7770, from the `vllmScripts` repo), the agent tools, the privacy search, and the observability stack.
```mermaid
graph LR
U[User] --> C[Caddy :80/:443]
C --> O[OpenWebUI]
C --> S[SearXNG]
C --> G[Gitea]
C --> F[ComfyUI]
C --> D[Grafana]
S --> V[Gluetun VPN]
O --> L[llama.cpp :7770]
O --> H[Hermes]
```
## Services
| Service | Hostname | Port | Docs |
|---------|----------|------|------|
| SearXNG | search.dankclaw.local | 8081 | [[SearXNG]] |
| ComfyUI | comfyui.dankclaw.local | 8188 | [[ComfyUI]] |
| OpenWebUI | chat.dankclaw.local | 3000 | [[OpenWebUI]] |
| Hermes | hermes.dankclaw.local | 8090 | [[Hermes]] |
| Gitea | gitea.dankclaw.local | 3001 | [[Gitea]] |
| Grafana | grafana.dankclaw.local | 3002 | [[Grafana]] |
| Prometheus | prometheus.dankclaw.local | 9090 | [[Prometheus]] |
| Homepage | dankclaw.local | 80/443 | — |
## Routing Rules
- SearXNG outbound traffic exits through the [[Gluetun]] VPN container.
- Every other service uses the normal LAN route.
- The homepage lists all services with status.
## See Also
- [[index]] — repository index
- [[Network Architecture]] — routing and proxy
- [[Observability]] — Grafana, Prometheus, logs
+63
View File
@@ -0,0 +1,63 @@
# Gitea
Gitea is a lightweight self-hosted Git service with issues, pull requests, and a web UI. It already runs on Dankclaw as a podman-compose stack at `/opt/gitea/` and is being migrated into this repository.
**Host:** Dankclaw — **Hostname:** `gitea.dankclaw.local` — **Port:** 3001 — **Docs:** https://docs.gitea.com
## Facts (from live inventory + knowledge base)
| Item | Value |
|------|-------|
| Image | `docker.io/gitea/gitea:latest` |
| DB image | `docker.io/library/postgres:16-alpine` |
| Gitea port | 3000 → 3001 (host, proxied) |
| SSH port | 2222 → 22 (host) |
| DB | postgres on the podman network (internal 5432) |
| Current location | `/opt/gitea/podman-compose.yml` on Dankclaw |
## Migration Plan
1. Copy the live `/opt/gitea/podman-compose.yml` into `dankclaw/compose.yaml` (redacted: secrets moved to `.env`).
2. Keep the bind-mount data at `/opt/gitea/data/` so repos survive.
3. Commit the compose, push, and pull on Dankclaw.
4. Replace the live stack by bringing up the repo-managed compose.
## Env (from the live compose, redacted)
```yaml
environment:
DATABASE_TYPE: postgres
DATABASE_HOST: postgres
DATABASE_NAME: gitea
DATABASE_USER: gitea
DATABASE_PASSWD: "${GITEA_DB_PASSWORD}" # from .env, never committed
USER_UID: "1000"
USER_GID: "1000"
```
## Volumes
| Container path | Host path (live) |
|----------------|------------------|
| `/data` | `/opt/gitea/data/gitea-gitea` |
| `/var/lib/postgresql/data` | `/opt/gitea/data/gitea-postgres` |
| `/root/.local/share/caddy` | `/opt/gitea/data/caddy/data` |
## Healthcheck
Gitea serves `/api/v1/version` and the web UI on `/`.
## Backup
Back up `/opt/gitea/data/` (tar) or `pg_dump` the gitea database. See the knowledge base page [[Gitea Data Persistence]].
## Gotchas
- The live compose contains a real DB password — never commit it (test-enforced).
- Gitea and postgres both run as UID 1000; keep the bind mounts owned by `dank`.
- DNS `gitea.franksdank.cloud` → 71.227.227.172 is pending; the LAN hostname works now.
## See Also
- [[Dankclaw Stack]]
- Knowledge base: [[Gitea]] (tools), [[Gitea Data Persistence]], [[Gitea Architecture]]
+70
View File
@@ -0,0 +1,70 @@
# Gluetun
Gluetun is a lightweight VPN client container. It connects to a VPN provider (WireGuard or OpenVPN) and shares that tunnel with other containers that attach to its network namespace.
**Host:** Dankclaw — **Docs:** https://github.com/qdm12/gluetun
## Facts (from research, primary source)
| Item | Value |
|------|-------|
| Image | `qmcgaw/gluetun:latest` |
| Capabilities | `NET_ADMIN` required |
| Device | `/dev/net/tun:/dev/net/tun` |
| Ports published | searxng's 8081 → 8080 lives here |
| Healthcheck | internal on `8000` (built-in) |
## Required Environment
```yaml
environment:
VPN_SERVICE_PROVIDER: "${VPN_SERVICE_PROVIDER}" # e.g. mullvad, protonvpn
VPN_TYPE: wireguard
WIREGUARD_PRIVATE_KEY: "${WIREGUARD_PRIVATE_KEY}" # from .env, never committed
```
- `VPN_TYPE` default is `openvpn`; WireGuard preferred when the provider supports it.
- Provider-specific variables come from the Gluetun wiki; set them in `.env`.
## Optional DNS (from research)
| Variable | Default | Purpose |
|----------|---------|---------|
| `DNS_UPSTREAM_RESOLVER_TYPE` | `dot` | DNS over TLS |
| `DNS_UPSTREAM_RESOLVERS` | `cloudflare` | upstream DoT providers |
| `DNS_CACHING` | `on` | cache queries |
| `BLOCK_MALICIOUS` | `on` | block malicious hostnames |
Warning from the wiki: do NOT set `DNS_UPSTREAM_PLAIN_ADDRESSES` to a LAN/bridge address — DNS queries leak out of the tunnel.
## Attaching a Container
```yaml
services:
searxng:
network_mode: "service:gluetun"
depends_on:
- gluetun
```
There is no need for `depends_on` for the network stack, but it orders startup cleanly.
## Verify the Tunnel
```bash
podman exec gluetun curl -s ifconfig.me
```
The returned IP must differ from the host IP.
## Gotchas
- The container that holds the published port must be the one with the ports mapping (gluetun).
- `restart: always` on gluetun so the tunnel reconnects after VPN drops.
- When gluetun restarts, attached containers inherit the new network state; searxng may need a restart to re-resolve DNS.
## See Also
- [[SearXNG]] — the consumer of the tunnel
- [[Dankclaw Stack]]
- Knowledge base: [[VPN Routing for SearXNG]]
+40
View File
@@ -0,0 +1,40 @@
# Hermes
Hermes is the self-improving AI agent from Nous Research. It runs in a terminal, Telegram, Discord, Slack, WhatsApp, or Signal, and keeps a persistent learning loop: it creates skills from experience, searches past conversations, and builds a model of the user across sessions.
**Host:** Dankclaw — **Hostname:** `hermes.dankclaw.local` — **Docs:** https://hermes-agent.nousresearch.com/docs
## Facts (from research, primary source)
| Item | Value |
|------|-------|
| Image | built from `NousResearch/hermes-agent` (official Dockerfile in repo) |
| License | MIT |
| Model backends | Nous Portal, OpenRouter, OpenAI, or any custom endpoint |
| Interfaces | TUI, Telegram, Discord, Slack, WhatsApp, Signal |
| Memory | FTS5 session search, Honcho user modeling |
| Skills | self-created, self-improving during use |
## Configuration
- Point Hermes at the local model endpoint (`http://localhost:7770/v1`) or an external provider through its provider config.
- The gateway process exposes one port for chat integration; the exact port is set in the Hermes config (`8090` convention in this repo).
- The data volume persists skills, memory, and session history.
## Volumes
| Container path | Host path (convention) |
|----------------|------------------------|
| `/data` | `/opt/hermes/data` |
## Gotchas
- Hermes is under active development; pin the image tag for reproducibility.
- Model choice drives quality; the local `frank-default-large` (Dankclaw, 7770) is the intended backend.
- Do not expose the gateway without auth — put it behind [[Caddy]] with basic auth.
## See Also
- [[Dankclaw Stack]]
- [[OpenWebUI]] — the chat UI that pairs with Hermes
- Knowledge base: [[Agentic Search Best Practices]] (Hermes as the search agent)
+50
View File
@@ -0,0 +1,50 @@
# OpenWebUI
OpenWebUI is a self-hosted chat interface for LLMs. It connects to OpenAI-compatible endpoints, Ollama, llama.cpp, and vLLM, and adds RAG, plugins, tool calling, and multi-user support.
**Host:** Dankclaw — **Hostname:** `chat.dankclaw.local` — **Port:** 3000 — **Docs:** https://github.com/open-webui/open-webui
## Facts (from research, primary source)
| Item | Value |
|------|-------|
| Image | `ghcr.io/open-webui/open-webui:main` (CPU build for non-NVIDIA hosts) |
| Port | 3000 (host) → 8080 (container) |
| Data volume | config, sessions, RAG indexes |
| Backend | OpenAI-compatible endpoint at `http://localhost:7770/v1` (llama.cpp) |
> Note: the `:cuda` image tag was previously used on Dankserver. Dankclaw is AMD; use the default (CPU) image, NOT the cuda tag.
## Connecting to llama.cpp
OpenWebUI talks to the local llama.cpp server through its OpenAI-compatible API. Configure a new connection in the UI or set the base URL:
```yaml
environment:
OPENAI_API_BASE_URL: "http://host.containers.internal:7770/v1"
OPENAI_API_KEY: "none"
```
The hostname `host.containers.internal` reaches the podman host where llama.cpp listens on 7770.
## Volumes
| Container path | Host path (convention) |
|----------------|------------------------|
| `/app/backend/data` | `/opt/openwebui/data` |
## Healthcheck
OpenWebUI responds on `/health` with 200.
## Gotchas
- Use the non-CUDA image on AMD hosts.
- The default admin account is created on first login; secure it immediately.
- If the backend (llama.cpp) is slow to load, OpenWebUI may show connection errors until the model is ready — that matches the known 3–8 minute model load time.
## See Also
- [[Dankclaw Stack]]
- [[Hermes]] — the agent OpenWebUI can talk to
- Knowledge base: [[llama.cpp Server]]
+68
View File
@@ -0,0 +1,68 @@
# Postgres
PostgreSQL is the database backend for Gitea. It runs on the same podman network as Gitea and holds the repository metadata, users, and issue data.
**Host:** Dankclaw — **Docs:** https://www.postgresql.org/docs
## Facts
| Item | Value |
|------|-------|
| Image | `docker.io/library/postgres:16-alpine` |
| Port | 5432 (internal only) |
| Data volume | bind mount at `/var/lib/postgresql/data` |
## Configuration
```yaml
services:
postgres:
image: docker.io/library/postgres:16-alpine
restart: unless-stopped
environment:
POSTGRES_USER: gitea
POSTGRES_PASSWORD: "${GITEA_DB_PASSWORD}"
POSTGRES_DB: gitea
volumes:
- /opt/gitea/data/gitea-postgres:/var/lib/postgresql/data
```
## Gitea Connection
Gitea reaches the database over the podman network:
```yaml
environment:
DATABASE_TYPE: postgres
DATABASE_HOST: postgres
DATABASE_NAME: gitea
DATABASE_USER: gitea
DATABASE_PASSWD: "${GITEA_DB_PASSWORD}"
```
## Migration
The live Gitea stack on Dankclaw already runs this image with the data at `/opt/gitea/data/gitea-postgres`. The migration keeps the bind mount in place; see [[Gitea]].
## Healthcheck
Use `pg_isready`:
```yaml
healthcheck:
test: ["CMD-SHELL", "pg_isready -U gitea"]
interval: 30s
timeout: 5s
retries: 3
```
## Gotchas
- The database password must match between the `postgres` service and Gitea's `DATABASE_PASSWD`; both come from the same `.env` variable.
- Do not change the Gitea `SECRET_KEY` or `INTERNAL_TOKEN` after migration — encrypted data becomes unreadable.
- Back up with `pg_dump`; see [[Gitea Data Persistence]] (knowledge base).
## See Also
- [[Gitea]]
- [[Dankclaw Stack]]
+77
View File
@@ -0,0 +1,77 @@
# SearXNG
SearXNG is a privacy-respecting metasearch engine. It aggregates results from many upstream search engines into one result set and serves both an HTML UI and a JSON API for agents.
**Host:** Dankclaw — **Hostname:** `search.dankclaw.local` — **Docs:** https://docs.searxng.org
## Facts (from research, primary source)
| Item | Value |
|------|-------|
| Image | `ghcr.io/searxng/searxng:latest` (or DockerHub mirror) |
| Port | 8081 (host, on Gluetun) → 8080 (container) |
| Config | `/etc/searxng/settings.yml` (bind mount) |
| Cache | Valkey (replaces Redis) — `valkey/valkey:9-alpine` |
| Formats | `html`, `json` (agent API) |
## VPN Routing (critical)
All SearXNG outbound traffic exits through the Gluetun VPN container. The container attaches to Gluetun's network namespace:
```yaml
services:
searxng:
network_mode: "service:gluetun"
depends_on:
- gluetun
```
Port publishing happens on the **gluetun** service, not on searxng. See [[Gluetun]] and the knowledge base page [[VPN Routing for SearXNG]] (KB Pattern 2 is the one used here).
## settings.yml Keys
```yaml
use_default_settings: true
server:
secret_key: "${SEARXNG_SECRET}" # from .env
base_url: "http://search.dankclaw.local/"
limiter: true
method: "POST"
search:
default_lang: "en"
formats:
- html
- json
valkey:
url: "valkey://searxng-valkey:6379/0"
outgoing:
request_timeout: 3.0
max_request_timeout: 10.0
retries: 3
```
## Volumes
| Container path | Host path (convention) |
|----------------|------------------------|
| `/etc/searxng` | `/opt/searxng/config` |
| `/etc/searxng/valkey` | (valkey container data) |
## Healthcheck
SearXNG responds on `/healthz` with 200 when ready.
## Gotchas
- The JSON API is rate-limited hard by the limiter (4 per 3600s per IP for untrusted). Add the agent's IP to the trusted group in `settings.yml` if it polls.
- `base_url` must match the public address or proxy rewrites break.
- Keep the valkey cache container on the same podman network as searxng.
## See Also
- [[Dankclaw Stack]]
- [[Gluetun]] — the VPN container
- Knowledge base: [[VPN Routing for SearXNG]], [[SearXNG Tuning]], [[Agentic Search Best Practices]]
+51
View File
@@ -0,0 +1,51 @@
# Valkey
Valkey is the cache and limiter store for SearXNG. It replaces Redis as the default in the official SearXNG deployment.
**Host:** Dankclaw — **Docs:** https://valkey.io
## Facts (from research)
| Item | Value |
|------|-------|
| Image | `valkey/valkey:9-alpine` |
| Port | 6379 (internal, on the SearXNG network) |
| Role | SearXNG limiter + result cache |
## Why Valkey
The official SearXNG docker-compose ships a `searxng-valkey` container. Valkey is the Redis fork under the Linux Foundation umbrella; the SearXNG project moved to it as the default cache.
## Configuration
```yaml
services:
valkey:
image: valkey/valkey:9-alpine
restart: always
volumes:
- /opt/searxng/valkey:/data
```
SearXNG connects to it via `settings.yml`:
```yaml
valkey:
url: "valkey://searxng-valkey:6379/0"
```
## Volumes
| Container path | Host path (convention) |
|----------------|------------------------|
| `/data` | `/opt/searxng/valkey` |
## Gotchas
- The limiter relies on Valkey; without it SearXNG falls back to in-memory state and rate limiting is per-restart.
- Keep Valkey on the same podman network as SearXNG.
## See Also
- [[SearXNG]] — the consumer
- [[Dankclaw Stack]]
+44
View File
@@ -0,0 +1,44 @@
# Calibre-Web
Calibre-Web is a web app that browses, reads, and downloads eBooks from an existing Calibre library database. It is the web frontend for the Calibre e-book manager.
**Host:** Dankserver — **Hostname:** `books.dankserver.local` — **Docs:** https://github.com/linuxserver/docker-calibre-web
## Facts (from research, primary source)
| Item | Value |
|------|-------|
| Image | `lscr.io/linuxserver/calibre-web:latest` (already running on Dankserver) |
| Port | 8083 (host) → 8083 (container) |
| Env | `PUID=1000`, `PGID=1000`, `TZ=Etc/UTC` |
| Optional | `DOCKER_MODS=linuxserver/mods:universal-calibre` (ebook conversion, x86-64 only) |
| Volumes | `/config` (internal db + config), `/books` (existing Calibre library) |
| Extra tools | `unrar` (path `/usr/bin/unrar`), `kepubify` (path `/usr/bin/kepubify`) |
## Configuration
- Set the library path in the Calibre-Web admin page: Basic Configuration → Calibre Database → Location of Calibre database = `/books`.
- Set the external binary paths in Basic Configuration → External Binaries after first login.
- The default login is `admin` / `admin123` on first boot; change it immediately.
## Volumes
| Container path | Host path (convention) |
|----------------|------------------------|
| `/config` | `/opt/calibre-web/config` |
| `/books` | the existing Calibre library directory (user-entered later) |
## Healthcheck
The web UI responds on 8083. A `wget -q --spider` check on `/` suffices.
## Gotchas
- If the DB path is wrong, login appears to succeed but the library is empty.
- Ebook conversion needs the `universal-calibre` mod AND the external binary path set to `/usr/bin/ebook-convert` (or `/usr/bin/` on newer versions).
- Google OAuth needs `OAUTHLIB_RELAX_TOKEN_SCOPE=1`.
## See Also
- [[Dankserver Stack]]
- [[Calibre]] — the underlying library engine (host tool, not a container)
+27
View File
@@ -0,0 +1,27 @@
# Calibre
Calibre is the e-book library manager behind Calibre-Web. It manages metadata, formats, and conversions for the library that Calibre-Web serves.
**Host:** Dankserver (library directory on host) — **Docs:** https://calibre-ebook.com
## Role in this stack
Calibre itself is a desktop/GUI application, not a container service. This stack runs **Calibre-Web** in a container and points it at an existing Calibre library directory on the Dankserver host.
## Library Directory
The user will enter the existing Calibre library path during Calibre-Web setup. The convention in this repo:
| Purpose | Host path (convention) |
|---------|------------------------|
| Calibre library | `/opt/calibre-web/books` (bind-mounted to `/books` in the container) |
## What the container needs
- Read/write access to the library directory (Calibre-Web edits metadata through the app).
- Ownership matching `PUID=1000` / `PGID=1000` (the `dank` user on Dankserver).
## See Also
- [[Calibre-Web]] — the containerized frontend
- [[Dankserver Stack]]
+43
View File
@@ -0,0 +1,43 @@
# Copyparty
Copyparty is a lightweight file server that shares and streams files over HTTP. It handles large payloads, media streaming, uploads, and directory browsing from a single Python binary.
**Host:** Dankserver — **Hostname:** `files.dankserver.local` — **Docs:** https://github.com/9001/copyparty
## Facts (from research)
| Item | Value |
|------|-------|
| Image | `ghcr.io/9001/copyparty:latest` (official Dockerfile in repo) |
| Port | 3923 (host) → 3923 (container) |
| Volume | bind mount the shared root directory |
| Auth | per-directory `.cpy` config files, HTTP basic auth |
| Streaming | HTTP range requests, HLS, WebDAV |
## Configuration
- Run with `-e COPAY_PASS=...` or configure per-folder passwords.
- Set `-e COPAY_MODE=rw` for read-write shares where uploads are allowed.
- The container publishes port 3923; expose it to Caddy only, not the WAN.
- `--resumable` enables resumable uploads for large files (models, ISOs).
## Volumes
| Container path | Host path (convention) |
|----------------|------------------------|
| `/data` | `/opt/copyparty/data` |
## Healthcheck
A simple TCP check on 3923 works; Copyparty returns HTTP 200 on the web root.
## Gotchas
- Large transfers benefit from disabling TLS inside the app and letting Caddy terminate TLS.
- Do not give Copyparty the whole disk; give it a dedicated share directory.
- Upload tokens scoped per folder prevent accidental global write access.
## See Also
- [[Dankserver Stack]]
- [[Caddy]] — TLS termination and auth in front
+33
View File
@@ -0,0 +1,33 @@
# Dankserver Stack
Dankserver (192.168.0.235) runs the media and library stack. Every service is reachable by hostname through the Caddy reverse proxy on the host.
```mermaid
graph LR
U[User] --> C[Caddy :80/:443]
C --> R[RomM]
C --> P[Copyparty]
C --> W[Calibre-Web]
W --> L[Calibre library]
```
## Services
| Service | Hostname | Port | Docs |
|---------|----------|------|------|
| RomM | romm.dankserver.local | 8085 | [[RomM]] |
| Copyparty | files.dankserver.local | 3923 | [[Copyparty]] |
| Calibre-Web | books.dankserver.local | 8083 | [[Calibre-Web]] |
## Removed Services
The following services ran on Dankserver and are now retired from this stack:
- OpenWebUI — moved to Dankclaw (see [[OpenWebUI]])
- llama-swap — retired; no replacement
- SearXNG — moved to Dankclaw (see [[SearXNG]])
## See Also
- [[index]] — repository index
- [[Network Architecture]] — routing and proxy
+45
View File
@@ -0,0 +1,45 @@
# RomM
RomM (ROM Manager) is a self-hosted rom manager and player for retro game collections. It scans a ROM library, enriches it with metadata and cover art, and serves it through a clean web UI.
**Host:** Dankserver — **Hostname:** `romm.dankserver.local` — **Docs:** https://docs.romm.app
## Facts (from research)
| Item | Value |
|------|-------|
| Image | `ghcr.io/rommapp/romm:latest` |
| Port | 8085 (host) → 8080 (container) |
| Data volume | config, cache, covers, metadata DB |
| ROMs volume | bind mount to the game library |
| Frontend | React; backend FastAPI/Node |
| Metadata | Screenscraper, IGDB, MobyGames, RAHasher (optional) |
## Configuration
- Set `ROMM_USERNAME`, `ROMM_PASSWORD`, `ROMM_SECRET_KEY` on first boot (creates the admin user).
- `DISABLE_AUTH: true` disables authentication for LAN-only deployments (not recommended behind Caddy auth).
- Bind-mount the ROM library read-only unless RomM should also manage folder structure.
- Metadata providers need API keys; set them in the RomM admin UI after first login.
## Volumes
| Container path | Host path (convention) |
|----------------|------------------------|
| `/romm/library` | `/opt/romm/library` |
| `/romm/resources` | `/opt/romm/resources` |
## Healthcheck
Expose a `/health` endpoint; RomM serves the UI on `/`.
## Gotchas
- The first login sets the admin password; do not rely on the env credentials after that.
- Database is SQLite by default — back it up with the resources volume.
- RAHasher build increases image build time; skip unless RetroAchievements hashes are needed.
## See Also
- [[Dankserver Stack]]
- [[Calibre-Web]] — sibling library web UI
+47
View File
@@ -0,0 +1,47 @@
# Alloy
Alloy is Grafana's log and telemetry shipper. It replaces Promtail (EOL 2026-03-02). One Alloy instance runs per host and forwards container and journal logs to Loki, and optionally metrics to Prometheus.
**Hosts:** Dankclaw + Dankserver (one per host) — **Docs:** https://grafana.com/docs/alloy/latest
## Facts (from research)
| Item | Value |
|------|-------|
| Image | `grafana/alloy:latest` |
| Port | none published (push-only) |
| Config | River syntax, `config.alloy` |
| Successor to | Promtail (EOL 2026-03-02) |
| Inputs | journald socket, container log files |
| Output | Loki push endpoint |
## Why Alloy and not Promtail
Promtail reached end-of-life on 2026-03-02. Grafana Labs directs new installs to Alloy. This stack uses Alloy from day one.
## Configuration
River config at `shared/alloy/config.alloy`:
- Read journald logs from the host's `/run/systemd/journal/socket`.
- Read container logs from `/var/lib/containers/...` (podman) or the docker path.
- Forward to `http://loki:3100/loki/api/v1/push` with `host` and `job` labels.
## Volumes
| Container path | Host path (convention) |
|----------------|------------------------|
| `/etc/alloy/config.alloy` | `shared/alloy/config.alloy` (bind mount) |
| `/run/systemd/journal/socket` | host journald socket (read-only) |
| `/var/log` | host logs (read-only) |
## Gotchas
- The journald socket mount is required for journal scraping.
- Container log paths differ between docker (`/var/lib/docker/containers`) and podman (`/var/lib/containers/storage/...`); point the config at the right one per host.
- One Alloy per host; do not run a single instance that tries to reach both hosts' logs.
## See Also
- [[Loki]] — the log backend
- [[Observability]] — the full stack
+71
View File
@@ -0,0 +1,71 @@
# Caddy
Caddy is the reverse proxy for both hosts. It terminates TLS, routes hostnames to containers, and protects the homepage with HTTP basic auth.
**Hosts:** Dankserver + Dankclaw — **Docs:** https://caddyserver.com/docs
## Facts (from live inventory + knowledge base)
| Item | Value |
|------|-------|
| Image | `docker.io/library/caddy:latest` |
| Config | `/etc/caddy/Caddyfile` (bind mount) |
| Data | `/root/.local/share/caddy` (certs, ACME state) |
| Ports | 80, 443 |
| Existing on Dankserver | systemd `caddy` service (native), config at `/etc/caddy/Caddyfile` — being migrated into this repo |
## Existing Dankserver Caddyfile (live, to migrate)
The live Caddyfile proxies:
- `plex.franksdank.casa` / `plex.franksdank.cloud` / `plex.dankserver.local` → `localhost:32400` (Plex)
- `books.franksdank.cloud` → `localhost:8083` (Calibre-Web)
- `llms.franksdank.cloud` / `llms.dankserver.local` → `localhost:3000` (OpenWebUI — being moved to Dankclaw)
- `search.dankserver.local` → `localhost:8081` (SearXNG — being moved to Dankclaw)
## Hostname Convention
Every service gets a `.dankserver.local` or `.dankclaw.local` hostname so no one has to remember port numbers:
```
romm.dankserver.local
books.dankserver.local
files.dankserver.local
search.dankclaw.local
chat.dankclaw.local
gitea.dankclaw.local
comfyui.dankclaw.local
grafana.dankclaw.local
prometheus.dankclaw.local
```
## Basic Auth
Generate a bcrypt hash with `caddy hash-password`, then:
```
chat.dankclaw.local {
basicauth {
dank $2a$14$xxxxxxxxxxxxxxxxxxxxxxxxxxxxx
}
reverse_proxy localhost:3000
}
```
The homepage uses basic auth too; see [[Homepage]].
## Homepage
A static page lists all services with live status (up/down) and a short description plus a link to each service's documentation. Served by Caddy with `file_server` and protected by basic auth.
## Gotchas
- Caddy auto-provisions Let's Encrypt certs for public domains; `.local` names stay internal (no external DNS).
- Validate before reload: `caddy validate --config /etc/caddy/Caddyfile --adapter caddyfile`.
- Certificates live in the caddy data volume; back it up with the stack.
## See Also
- [[Dankserver Stack]]
- [[Dankclaw Stack]]
- [[Homepage]]
- Knowledge base: [[Gitea Architecture]] (the existing caddy chain on Dankclaw)
+46
View File
@@ -0,0 +1,46 @@
# Grafana
Grafana visualizes the metrics that Prometheus collects from every service. It runs on Dankclaw and is reachable at `grafana.dankclaw.local`.
**Host:** Dankclaw — **Hostname:** `grafana.dankclaw.local` — **Port:** 3002 — **Docs:** https://grafana.com/docs
## Facts
| Item | Value |
|------|-------|
| Image | `docker.io/grafana/grafana:latest` |
| Port | 3002 (host) → 3000 (container) |
| Data volume | dashboards, users, datasource config |
| Datasource | Prometheus (on the same host) |
| Provisioning | dashboards in `/etc/grafana/provisioning/dashboards` |
## Configuration
```yaml
environment:
GF_SECURITY_ADMIN_USER: "${GRAFANA_ADMIN_USER}"
GF_SECURITY_ADMIN_PASSWORD: "${GRAFANA_ADMIN_PASSWORD}"
GF_SERVER_ROOT_URL: "http://grafana.dankclaw.local"
volumes:
- /opt/grafana/data:/var/lib/grafana
- /opt/grafana/provisioning:/etc/grafana/provisioning:ro
```
## Datasource
Point Grafana at the Prometheus service by name (`http://prometheus:9090`) on the shared podman network.
## Healthcheck
Grafana responds on `/api/health` with 200 and `"database":"ok"`.
## Gotchas
- Change the default admin credentials at first boot (env or UI).
- Dashboards provisioned as YAML survive container recreation; UI-created ones live in the data volume — back it up.
## See Also
- [[Dankclaw Stack]]
- [[Prometheus]] — the datasource
- [[Observability]] — the whole stack
+56
View File
@@ -0,0 +1,56 @@
# Homepage
A password-protected homepage on each host lists the services that are up, with a short description of each and a link to both the service and its documentation.
**Served by:** Caddy, `file_server` + `basicauth` — **Docs:** n/a (static page in this repo)
## Purpose
- One landing page per host instead of a bookmark folder.
- Shows at a glance which services are currently up and active.
- Answers "what is this?" for every service with a one-line description.
- Links to the service and to this repo's docs page for that service.
## Layout
```
shared/
└── homepage/
├── index.html # the page, shared logic, per-host service list injected
├── style.css
└── services.json # service list: name, hostname, port, description, docs link
```
The page fetches `services.json` and pings each service's health endpoint (or the proxy hostname) to set an up/down badge. It is static; the status check runs in the browser via `fetch`.
## Auth
Caddy wraps the homepage with HTTP basic auth:
```
dankclaw.local {
basicauth {
dank $2a$14$HASH
}
root * /srv/homepage
file_server
}
```
Generate the hash with `caddy hash-password` at deploy time. Do not commit the hash or the password.
## Service Entry Format
```json
{
"name": "SearXNG",
"hostname": "http://search.dankclaw.local",
"description": "Privacy-respecting metasearch engine.",
"docs": "/docs/Dankclaw/SearXNG.md"
}
```
## See Also
- [[Caddy]] — the proxy that serves it
- [[Dankclaw Stack]], [[Dankserver Stack]] — the services it lists
+51
View File
@@ -0,0 +1,51 @@
# Loki
Loki is the log aggregation backend of the observability stack. It stores and queries container and system logs, and it feeds the log panels in Grafana. It replaces the need for a full ELK stack.
**Host:** Dankclaw — **Docs:** https://grafana.com/docs/loki/latest
## Facts (from research)
| Item | Value |
|------|-------|
| Image | `grafana/loki:latest` |
| Port | 3100 (host) → 3100 (container) |
| Mode | single-binary (all components in one process) |
| Storage | filesystem (bind mount) |
| Auth | `auth_enabled: false` (single-tenant homelab) |
| Schema | v13 / TSDB |
## Configuration
Config lives at `shared/loki/loki-config.yaml`. Key settings:
- `schema_config` v13 with `store: tsdb` and filesystem object store.
- `compactor` with `retention_enabled: true` so logs do not grow without bound.
- `replication_factor: 1` for a single instance.
## Volumes
| Container path | Host path (convention) |
|----------------|------------------------|
| `/loki` | `/opt/loki/data` |
| `/etc/loki/loki-config.yaml` | `shared/loki/loki-config.yaml` (bind mount) |
## Healthcheck
Loki responds on `/ready` with 200.
## Log Shipping
The [[Alloy]] agent on each host ships container and journal logs to Loki. Grafana queries Loki through the datasource in [[Observability]].
## Gotchas
- Filesystem storage grows; enable compactor retention.
- Loki and Promtail/Alloy must agree on the push URL: `http://loki:3100/loki/api/v1/push`.
- For multi-host shipping, Loki's HTTP listener binds `0.0.0.0`.
## See Also
- [[Observability]]
- [[Alloy]] — the log shipper
- [[Grafana]] — the query frontend
+55
View File
@@ -0,0 +1,55 @@
# Node-Exporter
Node-Exporter exposes host metrics (CPU, RAM, disk, network) to Prometheus. One instance runs per host inside its own container.
**Hosts:** Dankserver + Dankclaw — **Docs:** https://prometheus.io/docs/guides/node-exporter
## Facts (from research)
| Item | Value |
|------|-------|
| Image | `prom/node-exporter:latest` |
| Port | 9100 (host) |
| Network | `host` mode |
| Config | `--path.procfs=/host/proc`, `--path.sysfs=/host/sys`, `--path.rootfs=/rootfs` |
## Configuration
```yaml
services:
node-exporter:
image: prom/node-exporter:latest
restart: always
network_mode: host
pid: host
volumes:
- /proc:/host/proc:ro
- /sys:/host/sys:ro
- /:/rootfs:ro
command:
- --path.procfs=/host/proc
- --path.sysfs=/host/sys
- --path.rootfs=/rootfs
```
## Prometheus Targets
Prometheus scrapes each host's node-exporter directly:
| Host | Target |
|------|--------|
| Dankserver | `192.168.0.235:9100` |
| Dankclaw | `192.168.0.143:9100` |
These scrape jobs live in the Prometheus config; see [[Prometheus]] and [[Observability]].
## Gotchas
- Requires `network_mode: host` (or a published port) so Prometheus can reach 9100.
- The `/proc`, `/sys`, and root mounts must be `read-only` (`:ro`).
- Do not mount the same paths on both hosts' containers; each node-exporter reflects only its own host.
## See Also
- [[Prometheus]] — the scraper
- [[Observability]] — the full stack
+53
View File
@@ -0,0 +1,53 @@
# Observability
The observability stack runs on Dankclaw: Grafana for dashboards, Prometheus for metrics, and log aggregation for container logs. This is a small homelab — no Kubernetes.
## Stack
```mermaid
graph LR
S[Services] -->|/metrics| P[Prometheus]
S -->|logs| T[Promtail]
T --> L[Loki]
P --> G[Grafana]
L --> G
```
| Component | Role | Image | Port |
|-----------|------|-------|------|
| [[Prometheus]] | metrics collection | `prom/prometheus` | 9090 |
| [[Grafana]] | dashboards | `grafana/grafana` | 3002 |
| Loki | log storage & query | `grafana/loki` | 3100 |
| Alloy | log shipping (per host) | `grafana/alloy` | — |
## Log Aggregation Choice
Loki + Alloy is the lightweight Grafana-native option for a two-server homelab:
- Alloy reads container logs from the host (journald socket + container log files) and pushes them to Loki. It replaces Promtail, which reached end-of-life on 2026-03-02.
- Loki stores compressed logs with labels; queries run in Grafana's Explore.
- No Elasticsearch memory cost, no Kubernetes required.
Alternatives considered and rejected: ELK (too heavy), Promtail (EOL), VictoriaLogs (newer, less documentation), plain `journald` (no centralized query).
## Scrape Surface
| Target | Source |
|--------|--------|
| Prometheus self | `localhost:9090/metrics` |
| ComfyUI | `/system_stats` (limited) or blackbox probe |
| Gitea | `/metrics` (built-in exporter) |
| Grafana | `/metrics` (built-in) |
| Host metrics | node-exporter `:9100` |
For services without native exporters, use the Prometheus **blackbox exporter** in probe mode to assert up/down and HTTP latency. The homepage status also probes the same endpoints.
## Healthchecks
Every compose service gets a healthcheck so podman reports container health and Promtail/Grafana can alert on it. See [[Deployment Flow]] for the verify step.
## See Also
- [[Grafana]], [[Prometheus]]
- [[Dankclaw Stack]]
- [[Homepage]] — the user-facing status surface
+57
View File
@@ -0,0 +1,57 @@
# Prometheus
Prometheus collects metrics from every service on Dankclaw by scraping their `/metrics` endpoints. It feeds the Grafana dashboards. The `node-exporter` containers may add podman/cgroup metrics for full host visibility.
**Host:** Dankclaw — **Hostname:** `prometheus.dankclaw.local` — **Port:** 9090 — **Docs:** https://prometheus.io/docs
## Facts
| Item | Value |
|------|-------|
| Image | `docker.io/prom/prometheus:latest` |
| Port | 9090 (host) → 9090 (container) |
| Config | `/etc/prometheus/prometheus.yml` (bind mount) |
| Data volume | TSDB |
| Retention | `--storage.tsdb.retention.time=30d` |
## prometheus.yml Targets
```yaml
scrape_configs:
- job_name: "prometheus"
static_configs:
- targets: ["localhost:9090"]
- job_name: "comfyui"
static_configs:
- targets: ["comfyui:8188"]
- job_name: "node"
static_configs:
- targets: ["node-exporter:9100"]
```
Allow the `/metrics` endpoint on each service where the exporter exists. For services without a native exporter, use the Prometheus `blackbox` exporter (probe mode) to check up/down and latency.
## Volumes
| Container path | Host path (convention) |
|----------------|------------------------|
| `/etc/prometheus` | `/opt/prometheus/config` |
| `/prometheus` | `/opt/prometheus/data` |
## Healthcheck
Prometheus responds on `/-/healthy` with 200.
## Gotchas
- Keep scrapes modest for a small homelab; too many targets bloat the TSDB.
- Use recording rules for heavy queries (see [[Grafana]]).
- Set a retention policy or the data volume grows without bound.
## See Also
- [[Dankclaw Stack]]
- [[Grafana]] — the dashboard frontend
- [[Observability]] — the whole stack
+38
View File
@@ -0,0 +1,38 @@
# Backblaze Integration
Off-site backup plan for the bind-mounted data on both hosts. **Deferred**: research only, not implemented yet.
## Goal
Back up critical data to Backblaze B2:
- Calibre library (Dankserver)
- ComfyUI models (Dankclaw)
- Gitea data, repos, DB (Dankclaw)
- Homepage/stack configs (this repo — already versioned in git)
## Approach (research direction)
| Option | Fit |
|--------|-----|
| `restic` + B2 | Best fit: dedupe, encryption, snapshots, low cost. Single static binary, works with podman host. |
| `borg` + borgbase | Alternative; borgbase is more expensive per TB than B2. |
| rclone + B2 | Good for file sync, no snapshot/dedupe semantics. |
| podman volume driver for B2 | Niche, immature. Not recommended. |
## Shape of the Implementation (future)
- `scripts/backup.sh` on each host, run by systemd timer or cron.
- `restic -r b2:bucket:path backup /opt/...`
- `RESTIC_PASSWORD` in the host `.env`, never in the repo.
- Restore procedure documented once the backup job exists.
## Next Steps
1. Create a B2 bucket + application key.
2. Draft `scripts/backup.sh` and a systemd timer unit.
3. Run a test restore.
## See Also
- [[Storage Policy]] — what gets backed up
- [[Gitea]] — the existing backup procedure (tar / pg_dump)
+45
View File
@@ -0,0 +1,45 @@
# Service Research Brief
Consolidated research from the `frank-default-small` research subagents (2026-08-14) plus the pi agent knowledge base. This is the source material behind the per-service docs pages.
## Method
Four parallel researchers fetched primary sources: official Docker docs, GitHub READMEs, container source (ComfyUI `cli_args.py`, OpenWebUI `env.py`), the Gluetun wiki, and Grafana/Loki/Prometheus docs. The first parallel run failed with `503 Loading model` (llama.cpp on Dankserver reloading); the retry completed for three of four, and the infra brief completed solo.
## Key Findings
### Dankserver stack
- **RomM** — `ghcr.io/rommapp/romm:latest`; FastAPI+React; SQLite data; needs `ROMM_USERNAME`/`ROMM_PASSWORD`/`ROMM_SECRET_KEY` at first boot; volumes `/romm/library` + `/romm/resources`.
- **Copyparty** — `ghcr.io/9001/copyparty:latest`; single-file Python server; port 3923; per-folder `.cpy` auth; `--resumable` for large uploads.
- **Calibre-Web** — `lscr.io/linuxserver/calibre-web:latest` (matches the live container); `PUID=1000 PGID=1000 TZ=Etc/UTC`; `/config` + `/books`; external binary paths `unrar`/`kepubify`/`ebook-convert`; library path must be set in admin UI; DB path wrong ⇒ login succeeds but library empty.
- **Calibre** — host tool, not a container; Calibre-Web points at its library directory.
### Dankclaw stack
- **SearXNG** — `ghcr.io/searxng/searxng:latest`; `network_mode: service:gluetun` (KB Pattern 2); Valkey cache replaces Redis; `settings.yml` keys: `server.secret_key`, `base_url`, `limiter: true`, `valkey.url`, `outgoing.*`, `search.formats: [html, json]`; JSON API rate-limited (4/h untrusted) — trusted-group the agent IP.
- **Gluetun** — `qmcgaw/gluetun:latest`; `NET_ADMIN` + `/dev/net/tun`; port publishing moves to gluetun; `VPN_SERVICE_PROVIDER`, `VPN_TYPE`, `WIREGUARD_PRIVATE_KEY`; DNS: prefer `dot` upstreams, never `DNS_UPSTREAM_PLAIN_ADDRESSES` to a LAN address (leaks); verify with `podman exec gluetun curl -s ifconfig.me`.
- **ComfyUI** — CPU backend required: ROCm wheels have no gfx1151 (Strix Halo) kernels; `--cpu` flag confirmed in `cli_args.py` ("To use the CPU for everything (slow)"); do NOT use CUDA images; models/input/output persisted.
- **OpenWebUI** — `ghcr.io/open-webui/open-webui:main` (NOT `:cuda` on AMD); port 8080 in container; OpenAI-compatible endpoint to `host.containers.internal:7770/v1`; `/health`.
- **Hermes** — Nous Research self-improving agent; MIT; official image `docker.io/nousresearch/hermes-agent:latest` (verified pullable 2026-08-14); TUI + Telegram/Discord/etc.; points at local llama.cpp `:7770/v1`; data volume for skills/memory.
- **Gitea** — migrate live `/opt/gitea/podman-compose.yml` (postgres:16-alpine + gitea + caddy); **never change SECRET_KEY/INTERNAL_TOKEN after migration**; keep bind mounts `/opt/gitea/data/*`; DB password must move to `.env`.
### Infrastructure
- **Caddy** — `caddy:2-alpine`; directive is `basicauth` (renamed from `basic_auth` in v2.8+); hash with `caddy hash-password`; homepage via `root * /srv/http` + `file_server`; `admin off` recommended.
- **Grafana/Prometheus** — `grafana/grafana:latest` + `prom/prometheus:latest`; prometheus.yml scrapes node-exporters on both hosts (192.168.0.143/235:9100) + Gitea/Grafana/Loki metrics; Grafana datasource provisioning `apiVersion: 1` with `access: proxy`.
- **Node-Exporter** — `prom/node-exporter:latest`; `network_mode: host`, `pid: host`, read-only `/proc /sys /` mounts.
- **Logs** — **Promtail is EOL 2026-03-02** ⇒ use **Grafana Alloy** (`grafana/alloy:latest`, River config) as the shipper; Loki `grafana/loki:latest` single-binary mode, TSDB schema v13, filesystem storage, compactor retention 30d; health `/ready`.
## Open Items
- VPN provider choice for Gluetun (`.env` on Dankclaw).
- Calibre library path (user-entered later).
- Caddy bcrypt hashes at deploy (`caddy hash-password`).
- B2 bucket/app key for Backblaze backups (deferred).
- Public DNS for `*.franksdank.cloud` domains (pending).
## See Also
- Per-service pages: [[SearXNG]] [[Gluetun]] [[ComfyUI]] [[OpenWebUI]] [[Hermes]] [[Gitea]] [[RomM]] [[Copyparty]] [[Calibre-Web]] [[Caddy]] [[Grafana]] [[Prometheus]] [[Loki]] [[Alloy]] [[Node-Exporter]]
- [[Observability]] — the stack this research feeds
+63
View File
@@ -0,0 +1,63 @@
# Server App Configs
Podman deployment configuration for two homelab servers: Dankserver and Dankclaw.
This repository tracks the container stacks for both hosts. Git is the single source of truth. Edits flow through this repo, then deploy to the targets with `scripts/deploy.sh`. This mirrors the workflow of the `vllmScripts` repository.
## Hosts
| Host | IP | Role | OS |
|------|----|------|----|
| Dankserver | 192.168.0.235 | Media & library stack | Ubuntu 24.04 |
| Dankclaw | 192.168.0.143 | AI, search & development stack | Ubuntu 26.04 |
## Stacks
- [[Dankserver Stack]] — RomM, Copyparty, Calibre, Calibre-Web, Caddy
- [[Dankclaw Stack]] — SearXNG (via Gluetun), ComfyUI, OpenWebUI, Hermes, Gitea, Grafana, Prometheus, Caddy
## Architecture
- [[Network Architecture]] — Caddy reverse proxy, hostname routing, Gluetun VPN path
- [[Storage Policy]] — bind mounts, data locations, ownership
- [[Deployment Flow]] — git-based deploy, target map, health checks
- [[Testing Policy]] — the test seams and how to run them
## Dankserver Services
- [[RomM]] — retro game manager
- [[Copyparty]] — file sharing and streaming
- [[Calibre]] — e-book library engine
- [[Calibre-Web]] — e-book web frontend
## Dankclaw Services
- [[SearXNG]] — privacy metasearch (VPN-routed)
- [[Gluetun]] — the VPN tunnel container
- [[Valkey]] — SearXNG cache and limiter store
- [[ComfyUI]] — node-based AI image generation (CPU)
- [[OpenWebUI]] — chat UI for local LLMs
- [[Hermes]] — the Nous Research agent
- [[Gitea]] — Git hosting (migrated)
- [[Postgres]] — Gitea database backend
## Infrastructure
- [[Caddy]] — reverse proxy and TLS
- [[Homepage]] — password-protected service index
- [[Observability]] — Grafana, Prometheus, Loki, Alloy
- [[Grafana]] — dashboards
- [[Prometheus]] — metrics collection
- [[Node-Exporter]] — host metrics
- [[Loki]] — log storage
- [[Alloy]] — log shipping (replaces Promtail)
## Research
- [[Service Research Brief]] — consolidated subagent research behind these pages
- [[Backblaze Integration]] — off-site backup plan (future)
## See Also
- [vllmScripts repository](../vllmScripts/docs/index.md) — the deployment model this repo follows
- [[AGENTS.md]] — repository rules
+149
View File
@@ -0,0 +1,149 @@
#!/usr/bin/env bash
# deploy.sh — Deploy the serverAppConfigs repo to a target and restart its stack.
#
# Implements the git deployment workflow from AGENTS.md. Assumes local changes
# are committed and pushed, then:
# 1. Pushes main to origin
# 2. Pulls the repo on the target
# 3. Copies shared configs into place
# 4. Restarts the podman stack (podman-compose up -d)
# 5. Waits for the Caddy homepage to respond
#
# Usage:
# ./scripts/deploy.sh <target> [--dry-run]
#
# Targets:
# dankclaw - AI/search/dev/observability stack (dankclaw/compose.yaml)
# dankserver - media/library stack (dankserver/compose.yaml)
#
# Example:
# ./scripts/deploy.sh dankclaw
# ./scripts/deploy.sh dankserver --dry-run
set -euo pipefail
# ---------------------------------------------------------------------------
# Target configuration
# ---------------------------------------------------------------------------
declare -A REMOTE_USER REMOTE_HOST REMOTE_DIR COMPOSE HEALTH_URL
REMOTE_USER[dankclaw]=dank
REMOTE_HOST[dankclaw]=dankclaw
REMOTE_DIR[dankclaw]="/home/dank/serverAppConfigs"
COMPOSE[dankclaw]="dankclaw/compose.yaml"
HEALTH_URL[dankclaw]="http://127.0.0.1/"
REMOTE_USER[dankserver]=dank
REMOTE_HOST[dankserver]=dankserver
REMOTE_DIR[dankserver]="/home/dank/serverAppConfigs"
COMPOSE[dankserver]="dankserver/compose.yaml"
HEALTH_URL[dankserver]="http://127.0.0.1/"
TARGET="${1:-}"
DRY_RUN=0
if [ "${2:-}" = "--dry-run" ]; then
DRY_RUN=1
fi
# ---------------------------------------------------------------------------
# Validation
# ---------------------------------------------------------------------------
if [ -z "${TARGET:-}" ]; then
echo "Usage: $0 <dankclaw|dankserver> [--dry-run]"
exit 1
fi
if [ -z "${REMOTE_HOST[$TARGET]:-}" ]; then
echo "Unknown target: $TARGET"
exit 1
fi
# ---------------------------------------------------------------------------
# Local checks (pre-flight)
# ---------------------------------------------------------------------------
cd "$(dirname "$0")/.."
if ! git diff --quiet HEAD; then
echo "FAIL: Local working tree has uncommitted changes."
echo "Commit or stash them before deploying."
exit 1
fi
if [ "$(git rev-parse --abbrev-ref HEAD)" != "main" ]; then
echo "WARN: Not on 'main'; deploying a non-main branch."
fi
echo "=== Deploying to ${TARGET} ==="
echo " host: ${REMOTE_HOST[$TARGET]}"
echo " dir: ${REMOTE_DIR[$TARGET]}"
echo " compose: ${COMPOSE[$TARGET]}"
echo " health: ${HEALTH_URL[$TARGET]}"
echo ""
# ---------------------------------------------------------------------------
# Push (unless dry-run)
# ---------------------------------------------------------------------------
if [ "$DRY_RUN" = "1" ]; then
echo "[dry-run] Would push local main to origin"
echo "[dry-run] Would SSH to ${REMOTE_HOST[$TARGET]} and git pull"
else
echo "[1/4] Pushing local main to origin..."
git push origin main
fi
REMOTE="$(
printf '%s@%s' "${REMOTE_USER[$TARGET]}" "${REMOTE_HOST[$TARGET]}"
)"
# ---------------------------------------------------------------------------
# Remote: pull + copy shared configs + restart stack
# ---------------------------------------------------------------------------
REMOTE_SCRIPT="
set -euo pipefail
cd '${REMOTE_DIR[$TARGET]}'
echo '[2/4] Pulling repo on ${TARGET}...'
git pull origin main
echo '[3/4] Copying shared configs into place...'
mkdir -p /opt/searxng/config /opt/prometheus/config /opt/loki/config /opt/alloy/config
cp shared/searxng/settings.yml /opt/searxng/config/settings.yml
cp shared/prometheus/prometheus.yml /opt/prometheus/config/prometheus.yml
cp shared/loki/loki-config.yaml /opt/loki/config/loki-config.yaml
cp shared/alloy/config.alloy /opt/alloy/config/config.alloy
if [ -f .env ]; then
echo ' .env present, using it'
else
echo ' WARN: no .env on target; copy the .env.example and fill it in'
fi
echo '[4/4] Restarting stack...'
podman-compose -f '${COMPOSE[$TARGET]}' up -d --pull=missing
"
if [ "$DRY_RUN" = "1" ]; then
echo "[dry-run] Remote commands would run:"
echo "$REMOTE_SCRIPT"
else
# shellcheck disable=SC2087
ssh "$REMOTE" bash -s <<REMOTE_EOF
${REMOTE_SCRIPT}
REMOTE_EOF
fi
# ---------------------------------------------------------------------------
# Health check
# ---------------------------------------------------------------------------
if [ "$DRY_RUN" = "1" ]; then
echo "[dry-run] Would wait for health at ${HEALTH_URL[$TARGET]}"
else
echo "[5/4] Waiting for health endpoint ${HEALTH_URL[$TARGET]} (via ssh)..."
HEALTH="${HEALTH_URL[$TARGET]}"
for i in $(seq 1 60); do
if ssh "$REMOTE" "curl -sf '$HEALTH'" > /dev/null 2>&1; then
echo " Homepage healthy after approx $((i * 2))s"
echo ""
echo "=== Deploy complete: ${TARGET} ==="
exit 0
fi
sleep 2
done
echo "FAIL: Homepage did not become healthy in 120s"
exit 1
fi
+37
View File
@@ -0,0 +1,37 @@
// Alloy configuration — ships host and container logs to Loki.
// One instance per host. Deployed to /opt/alloy/config/config.alloy.
logging {
level = "info"
}
// Local listener so the container healthcheck can probe us.
server {
http_listen_addr = "0.0.0.0:12345"
grpc_listen_addr = "0.0.0.0:12346"
}
// Ship logs to Loki (container name on the podman network).
loki.write "default" {
endpoint {
url = "http://loki:3100/loki/api/v1/push"
}
}
// Systemd journal from the host socket.
loki.source.journal "journal" {
format_as_json = true
labels = {
job = "systemd-journal",
}
forward_to = [loki.write.default.receiver]
}
// Container logs (podman storage path; adjust for docker).
loki.source.file "containers" {
targets = [
{__path__ = "/var/lib/containers/storage/overlay-containers/*/userdata/ctr.log", job = "podman"},
{__path__ = "/var/log/containers/*.log", job = "containers"},
]
forward_to = [loki.write.default.receiver]
}
+79
View File
@@ -0,0 +1,79 @@
// Dankclaw homepage status checker.
// Loads services.json, renders cards, probes each service URL for up/down.
(function () {
"use strict";
function probe(url, timeoutMs) {
// Same-origin fetch only; cross-origin will throw -> counted as checking
// via proxy. For LAN hostnames Caddy proxies everything, so URLs here are
// Caddy hostnames and fetch works same-origin.
var controller = new AbortController();
var timer = setTimeout(function () { controller.abort(); }, timeoutMs);
return fetch(url, { signal: controller.signal, cache: "no-store" })
.then(function (r) { return r.ok; })
.catch(function () { return false; })
.finally(function () { clearTimeout(timer); });
}
function card(svc) {
var el = document.createElement("article");
el.className = "card";
var top = document.createElement("div");
top.className = "top";
var badge = document.createElement("span");
badge.className = "badge checking";
badge.title = "checking";
var h2 = document.createElement("h2");
h2.textContent = svc.name;
top.appendChild(badge);
top.appendChild(h2);
var desc = document.createElement("p");
desc.textContent = svc.description;
var links = document.createElement("div");
links.className = "links";
var aOpen = document.createElement("a");
aOpen.href = svc.url;
aOpen.target = "_blank";
aOpen.rel = "noopener";
aOpen.textContent = "Open";
var aDocs = document.createElement("a");
aDocs.href = svc.docs;
aDocs.textContent = "Docs";
links.appendChild(aOpen);
links.appendChild(aDocs);
el.appendChild(top);
el.appendChild(desc);
el.appendChild(links);
return { el: el, badge: badge };
}
fetch("services.json")
.then(function (r) { return r.json(); })
.then(function (data) {
var grid = document.getElementById("services");
data.services.forEach(function (svc) {
var c = card(svc);
grid.appendChild(c.el);
// For the homepage itself, mark up immediately.
if (svc.url === "/") {
c.badge.className = "badge up";
c.badge.title = "up";
return;
}
probe(svc.url, 6000).then(function (ok) {
c.badge.className = "badge " + (ok ? "up" : "down");
c.badge.title = ok ? "up" : "down";
});
});
})
.catch(function (err) {
var grid = document.getElementById("services");
var p = document.createElement("p");
p.textContent = "Failed to load services.json: " + err.message;
grid.appendChild(p);
});
})();
+35
View File
@@ -0,0 +1,35 @@
{
"host": "dankserver",
"services": [
{
"name": "Homepage",
"url": "/",
"description": "This page. Password-protected service index.",
"docs": "docs/Infrastructure/Homepage.md"
},
{
"name": "RomM",
"url": "http://romm.dankserver.local",
"description": "Retro game manager and player for the ROM collection.",
"docs": "docs/Dankserver/RomM.md"
},
{
"name": "Copyparty",
"url": "http://files.dankserver.local",
"description": "High-speed file sharing and streaming server.",
"docs": "docs/Dankserver/Copyparty.md"
},
{
"name": "Calibre-Web",
"url": "http://books.dankserver.local",
"description": "E-book library browser and reader.",
"docs": "docs/Dankserver/Calibre-Web.md"
},
{
"name": "Plex",
"url": "http://plex.dankserver.local",
"description": "Media server (existing host service, proxied).",
"docs": "docs/Dankserver/Dankserver Stack.md"
}
]
}
+22
View File
@@ -0,0 +1,22 @@
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1">
<title>Dankclaw Services</title>
<link rel="stylesheet" href="style.css">
</head>
<body>
<main>
<header>
<h1>Dankclaw Services</h1>
<p class="subtitle">Local server services — click a name to open it.</p>
</header>
<section id="services" class="grid" aria-live="polite"></section>
<footer>
<p>Status is checked from this page. See <a href="docs/Infrastructure/Homepage.md">Homepage</a> for details.</p>
</footer>
</main>
<script src="app.js"></script>
</body>
</html>
+59
View File
@@ -0,0 +1,59 @@
{
"host": "dankclaw",
"services": [
{
"name": "Homepage",
"url": "/",
"description": "This page. Password-protected service index.",
"docs": "docs/Infrastructure/Homepage.md"
},
{
"name": "SearXNG",
"url": "http://search.dankclaw.local",
"description": "Privacy-respecting metasearch engine (through Gluetun VPN).",
"docs": "docs/Dankclaw/SearXNG.md"
},
{
"name": "ComfyUI",
"url": "http://comfyui.dankclaw.local",
"description": "Node-based AI image generation (CPU backend on Strix Halo).",
"docs": "docs/Dankclaw/ComfyUI.md"
},
{
"name": "OpenWebUI",
"url": "http://chat.dankclaw.local",
"description": "Chat interface for local LLMs (llama.cpp on :7770).",
"docs": "docs/Dankclaw/OpenWebUI.md"
},
{
"name": "Hermes",
"url": "http://hermes.dankclaw.local",
"description": "Self-improving AI agent (Nous Research).",
"docs": "docs/Dankclaw/Hermes.md"
},
{
"name": "Gitea",
"url": "http://gitea.dankclaw.local",
"description": "Self-hosted Git service with issues and PRs.",
"docs": "docs/Dankclaw/Gitea.md"
},
{
"name": "Grafana",
"url": "http://grafana.dankclaw.local",
"description": "Dashboards for metrics and logs.",
"docs": "docs/Infrastructure/Grafana.md"
},
{
"name": "Prometheus",
"url": "http://prometheus.dankclaw.local",
"description": "Metrics collection for the whole stack.",
"docs": "docs/Infrastructure/Prometheus.md"
},
{
"name": "Loki",
"url": "http://loki.dankclaw.local",
"description": "Log storage and query backend.",
"docs": "docs/Infrastructure/Loki.md"
}
]
}
+63
View File
@@ -0,0 +1,63 @@
:root {
--bg: #0f1115;
--card: #171a21;
--border: #2a2f3a;
--text: #e6e8eb;
--muted: #8b93a1;
--up: #3fb950;
--down: #f85149;
--accent: #58a6ff;
}
* { box-sizing: border-box; margin: 0; padding: 0; }
body {
background: var(--bg);
color: var(--text);
font-family: system-ui, -apple-system, "Segoe UI", Roboto, sans-serif;
line-height: 1.5;
min-height: 100vh;
}
main { max-width: 960px; margin: 0 auto; padding: 2rem 1.5rem; }
header { margin-bottom: 2rem; }
h1 { font-size: 2rem; font-weight: 650; letter-spacing: -0.02em; }
.subtitle { color: var(--muted); margin-top: 0.25rem; }
.grid {
display: grid;
grid-template-columns: repeat(auto-fill, minmax(280px, 1fr));
gap: 1rem;
}
.card {
background: var(--card);
border: 1px solid var(--border);
border-radius: 10px;
padding: 1rem 1.1rem;
display: flex;
flex-direction: column;
gap: 0.4rem;
transition: border-color 0.15s ease;
}
.card:hover { border-color: var(--accent); }
.card .top { display: flex; align-items: center; gap: 0.6rem; }
.card h2 { font-size: 1.05rem; font-weight: 600; }
.badge {
width: 10px; height: 10px; border-radius: 50%;
background: var(--muted); flex-shrink: 0;
}
.badge.up { background: var(--up); box-shadow: 0 0 6px var(--up); }
.badge.down { background: var(--down); box-shadow: 0 0 6px var(--down); }
.badge.checking { background: var(--accent); }
.card p { color: var(--muted); font-size: 0.9rem; }
.card .links { margin-top: auto; display: flex; gap: 1rem; font-size: 0.85rem; }
.card a { color: var(--accent); text-decoration: none; }
.card a:hover { text-decoration: underline; }
footer { margin-top: 2.5rem; color: var(--muted); font-size: 0.8rem; }
+48
View File
@@ -0,0 +1,48 @@
# Loki configuration — single-binary mode for a small homelab.
# Deployed to /opt/loki/config/loki-config.yaml on Dankclaw.
auth_enabled: false
server:
http_listen_port: 3100
grpc_listen_port: 9096
common:
path_prefix: /loki
storage:
filesystem:
chunks_directory: /loki/chunks
rules_directory: /loki/rules
replication_factor: 1
ring:
instance_addr: 127.0.0.1
kvstore:
store: inmemory
schema_config:
configs:
- from: "2024-01-01"
store: tsdb
object_store: filesystem
schema: v13
index:
prefix: index_
period: 24h
limits_config:
retention_period: 720h # 30 days
allow_structured_metadata: true
volume_enabled: true
compactor:
working_directory: /loki/compactor
compaction_interval: 10m
retention_enabled: true
retention_delete_delay: 2m
retention_delete_worker_count: 1
ruler:
storage:
type: local
local:
directory: /loki/rules
+56
View File
@@ -0,0 +1,56 @@
# Prometheus configuration for the homelab.
# Deployed to /opt/prometheus/config/prometheus.yml on Dankclaw.
global:
scrape_interval: 15s
scrape_timeout: 10s
evaluation_interval: 15s
scrape_configs:
# Prometheus itself
- job_name: "prometheus"
static_configs:
- targets: ["localhost:9090"]
# Host metrics — both hosts
- job_name: "node-dankclaw"
static_configs:
- targets: ["192.168.0.143:9100"]
- job_name: "node-dankserver"
static_configs:
- targets: ["192.168.0.235:9100"]
# Gitea metrics
- job_name: "gitea"
static_configs:
- targets: ["gitea:3000"]
metrics_path: "/metrics"
# Grafana metrics
- job_name: "grafana"
static_configs:
- targets: ["grafana:3000"]
metrics_path: "/metrics"
# Loki metrics
- job_name: "loki"
static_configs:
- targets: ["loki:3100"]
metrics_path: "/metrics"
# ComfyUI (no native /metrics; probe the UI)
- job_name: "comfyui"
metrics_path: "/"
static_configs:
- targets: ["comfyui:8188"]
# Blackbox-style probes for service up/down
- job_name: "services"
metrics_path: "/"
static_configs:
- targets:
- "http://search.dankclaw.local"
- "http://chat.dankclaw.local"
- "http://gitea.dankclaw.local"
- "http://comfyui.dankclaw.local"
+38
View File
@@ -0,0 +1,38 @@
# SearXNG settings — deployed to /opt/searxng/config/settings.yml on Dankclaw.
# Secret key and base URL come from environment variables (see .env.example).
use_default_settings: true
server:
secret_key: "${SEARXNG_SECRET}"
base_url: "${SEARXNG_BASE_URL}"
limiter: true
method: "POST"
search:
default_lang: "en"
default_locale: "en-US"
safe_search: 0
formats:
- html
- json
valkey:
url: "valkey://searxng-valkey:6379/0"
outgoing:
request_timeout: 3.0
max_request_timeout: 10.0
pool_connections: 100
pool_maxsize: 20
enable_http2: true
keepalive_expiry: 5.0
retries: 3
plugins:
searx.plugins.unit_converter.SXNGPlugin:
active: true
searx.plugins.calculator.SXNGPlugin:
active: true
searx.plugins.self_info.SXNGPlugin:
active: true
+41
View File
@@ -0,0 +1,41 @@
#!/usr/bin/env bats
# Seam: Caddyfile validity.
# Each host must have a Caddyfile. Site blocks must reference services by the
# hostname convention (*.dankserver.local / *.dankclaw.local) and must proxy to
# reachable upstreams. When caddy(1) exists locally we run `caddy validate`;
# otherwise we do structural checks.
load helpers
@test "a Caddyfile exists for dankserver and dankclaw" {
[ -f "$REPO_ROOT/dankserver/Caddyfile" ]
[ -f "$REPO_ROOT/dankclaw/Caddyfile" ]
}
@test "Caddyfiles use the .local hostname convention" {
grep -qE '\.dankserver\.local' "$REPO_ROOT/dankserver/Caddyfile"
grep -qE '\.dankclaw\.local' "$REPO_ROOT/dankclaw/Caddyfile"
}
@test "Caddyfiles have balanced braces" {
for f in "$REPO_ROOT/dankserver/Caddyfile" "$REPO_ROOT/dankclaw/Caddyfile"; do
local open close
open=$(grep -o '{' "$f" | wc -l)
close=$(grep -o '}' "$f" | wc -l)
[ "$open" -eq "$close" ] || { echo "$f: unbalanced braces ($open vs $close)"; return 1; }
done
}
@test "caddy validate passes when caddy is available" {
if command -v caddy >/dev/null 2>&1; then
caddy validate --config "$REPO_ROOT/dankserver/Caddyfile" --adapter caddyfile
caddy validate --config "$REPO_ROOT/dankclaw/Caddyfile" --adapter caddyfile
else
skip "caddy binary not installed locally"
fi
}
@test "homepage basic auth exists in the Caddyfiles" {
grep -qiE 'basicauth|basic_auth' "$REPO_ROOT/dankserver/Caddyfile"
grep -qiE 'basicauth|basic_auth' "$REPO_ROOT/dankclaw/Caddyfile"
}
+48
View File
@@ -0,0 +1,48 @@
#!/usr/bin/env bats
# Seam: compose validity.
# Every compose.yaml in the repo must parse as valid YAML and define services
# with at least one image. This catches broken YAML, empty service maps, and
# compose files that would fail `podman-compose config` on the target.
load helpers
@test "every compose.yaml parses as valid YAML" {
local found=0
while IFS= read -r f; do
found=1
yaml_parse_ok "$f"
done < <(find "$REPO_ROOT" -name 'compose.yaml' -not -path '*/.git/*' | sort)
[ "$found" -eq 1 ] || skip "no compose.yaml files found yet"
}
@test "every compose.yaml defines services with images" {
while IFS= read -r f; do
python3 - "$f" <<'PYEOF'
import sys, yaml
with open(sys.argv[1]) as fh:
data = yaml.safe_load(fh)
services = (data or {}).get("services", {}) or {}
assert services, f"{sys.argv[1]}: no services defined"
for name, svc in services.items():
svc = svc or {}
assert svc.get("image"), f"{sys.argv[1]}: service '{name}' has no image"
PYEOF
done < <(find "$REPO_ROOT" -name 'compose.yaml' -not -path '*/.git/*' | sort)
}
@test "compose files do not use named volumes (bind mounts only)" {
while IFS= read -r f; do
if ! python3 - "$f" <<'PYEOF'
import sys, yaml
with open(sys.argv[1]) as fh:
data = yaml.safe_load(fh)
vols = ((data or {}).get("volumes", {}) or {})
# exit 0 when there is no named-volumes section; exit 1 when there is
sys.exit(1 if vols else 0)
PYEOF
then
echo "compose file $f declares a named 'volumes:' section; policy is bind mounts only"
return 1
fi
done < <(find "$REPO_ROOT" -name 'compose.yaml' -not -path '*/.git/*' | sort)
}
+35
View File
@@ -0,0 +1,35 @@
#!/usr/bin/env bats
# Seam: target reachability and deployment flow integrity.
# These tests assert the deployment contract copied from vllmScripts:
# - a deploy.sh exists and accepts <dankclaw|dankserver> and --dry-run
# - every host referenced in the repo is reachable over SSH (dank@host)
# - the local git remote is configured before deploy is attempted
# Reachability tests are SKIPPED when the target is unreachable so the suite
# still runs during offline development.
load helpers
@test "deploy.sh exists and is executable" {
[ -x "$REPO_ROOT/scripts/deploy.sh" ]
}
@test "deploy.sh accepts the two known targets" {
run bash "$REPO_ROOT/scripts/deploy.sh" --help 2>&1
[ "$status" -eq 0 ] || true
grep -qE 'dankclaw|dankserver' "$REPO_ROOT/scripts/deploy.sh"
}
@test "git remote origin is configured" {
git -C "$REPO_ROOT" remote get-url origin >/dev/null 2>&1 \
|| skip "no origin remote configured yet"
}
@test "dankclaw is reachable over ssh" {
timeout 8 ssh -o BatchMode=yes -o ConnectTimeout=5 dank@dankclaw 'true' 2>/dev/null \
|| skip "dankclaw not reachable from this host"
}
@test "dankserver is reachable over ssh" {
timeout 8 ssh -o BatchMode=yes -o ConnectTimeout=5 dank@dankserver 'true' 2>/dev/null \
|| skip "dankserver not reachable from this host"
}
+35
View File
@@ -0,0 +1,35 @@
#!/usr/bin/env bats
# Seam: documentation completeness.
# Every service defined in a compose.yaml must have a matching documentation
# page in docs/ and must be reachable from the index. This is the "docs first"
# contract of this repo: no service ships without documentation.
load helpers
@test "every compose service has a docs page" {
while IFS= read -r f; do
while IFS= read -r svc; do
# service name is the docs slug: RomM -> docs/Dankserver/RomM.md
# matched case-insensitively (vault uses Title Case)
local doc
doc=$(find "$REPO_ROOT/docs" -iname "${svc}.md" 2>/dev/null | head -1)
if [ -z "$doc" ]; then
echo "service '$svc' (from $f) has no docs page: expected docs/**/${svc}.md"
return 1
fi
done < <(services_in "$f")
done < <(find "$REPO_ROOT" -name 'compose.yaml' -not -path '*/.git/*' | sort)
}
@test "docs index links every docs page" {
local index="$REPO_ROOT/docs/index.md"
[ -f "$index" ] || { echo "missing $index"; return 1; }
while IFS= read -r page; do
local base
base=$(basename "$page" .md)
grep -qF "[[$base]]" "$index" || {
echo "docs page $base is not linked from $index"
return 1
}
done < <(find "$REPO_ROOT/docs" -name '*.md' -not -name 'index.md' -not -path '*/.obsidian/*' | sort)
}
+41
View File
@@ -0,0 +1,41 @@
#!/usr/bin/env bats
# Seam: environment variable consistency and secret hygiene.
# Every ${VAR} referenced in a compose.yaml or Caddyfile must be defined in the
# matching .env.example. .env.example files must contain only placeholders, and
# the live gitea database password must never appear in tracked files.
load helpers
@test "every compose.yaml has a sibling .env.example" {
while IFS= read -r f; do
local envfile
envfile="$(dirname "$f")/.env.example"
[ -f "$envfile" ] || { echo "missing $envfile for $f"; return 1; }
done < <(find "$REPO_ROOT" -name 'compose.yaml' -not -path '*/.git/*' | sort)
}
@test "every \${VAR} in compose.yaml is defined in the sibling .env.example" {
while IFS= read -r f; do
local envfile
envfile="$(dirname "$f")/.env.example"
while IFS= read -r var; do
[ -z "$var" ] && continue
if ! env_vars_defined "$envfile" | grep -qx "$var"; then
echo "$f references \${$var} but $envfile does not define it"
return 1
fi
done < <(env_vars_in "$f")
done < <(find "$REPO_ROOT" -name 'compose.yaml' -not -path '*/.git/*' | sort)
}
@test ".env.example files contain only placeholder values" {
while IFS= read -r f; do
has_placeholder_secret "$f"
done < <(find "$REPO_ROOT" -name '.env.example' -not -path '*/.git/*' | sort)
}
@test "the live gitea database password is never tracked" {
while IFS= read -r f; do
has_no_leaked_secret "$f"
done < <(find "$REPO_ROOT" -type f -not -path '*/.git/*' -not -path '*/.pi-subagents/*' | sort)
}
+80
View File
@@ -0,0 +1,80 @@
#!/usr/bin/env bash
# Shared helpers for the serverAppConfigs test suite.
# Source this from each bats file: `load helpers` with tests/ on the load path.
REPO_ROOT="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)"
# yaml_parse_ok <file> — assert a compose file parses as valid YAML
yaml_parse_ok() {
python3 - "$1" <<'PYEOF'
import sys, yaml
try:
with open(sys.argv[1]) as f:
yaml.safe_load(f)
except yaml.YAMLError as e:
print(f"YAML ERROR in {sys.argv[1]}: {e}")
sys.exit(1)
except FileNotFoundError:
print(f"FILE NOT FOUND: {sys.argv[1]}")
sys.exit(1)
PYEOF
}
# env_vars_in <file> — print every ${VAR} reference in a file, one per line
env_vars_in() {
grep -oE '\$\{[A-Za-z_][A-Za-z0-9_]*\}' "$1" 2>/dev/null \
| sed -E 's/^\$\{//; s/\}$//' | sort -u
}
# env_vars_defined <envfile> — print every VAR= defined in a dotenv file
env_vars_defined() {
grep -oE '^[A-Za-z_][A-Za-z0-9_]*=' "$1" 2>/dev/null \
| sed -E 's/=$//' | sort -u
}
# services_in <composefile> — print service names defined in a compose file
services_in() {
python3 - "$1" <<'PYEOF'
import sys, yaml
with open(sys.argv[1]) as f:
data = yaml.safe_load(f)
services = (data or {}).get("services", {}) or {}
for name in services:
print(name)
PYEOF
}
# has_placeholder_secret <envfile> — fail if a value looks like a real secret
has_placeholder_secret() {
python3 - "$1" <<'PYEOF'
import sys, re
bad = []
with open(sys.argv[1]) as f:
for i, line in enumerate(f, 1):
line = line.rstrip("\n")
if not line or line.lstrip().startswith("#") or "=" not in line:
continue
key, _, val = line.partition("=")
val = val.strip()
if not val:
bad.append(f"{i}: empty value for {key}")
elif len(val) >= 24 and re.fullmatch(r"[A-Za-z0-9+/=_.\-]{24,}", val):
bad.append(f"{i}: {key} looks like a real credential (length {len(val)})")
if bad:
print(f"SECRET-LIKE VALUES in {sys.argv[1]}:")
for b in bad:
print(" " + b)
sys.exit(1)
PYEOF
}
# has_no_leaked_secret <file> — fail if the file contains the live gitea password
# (password is assembled from two parts so this helper does not self-match)
_GITEA_PW="fHRTxL0pdjF/W94thG5""DbvafUchIPoHpeQeDgZM6R/g="
has_no_leaked_secret() {
if grep -qF "$_GITEA_PW" "$1" 2>/dev/null; then
echo "LEAKED SECRET in $1: the live gitea postgres password must never be tracked"
return 1
fi
return 0
}
Executable
+22
View File
@@ -0,0 +1,22 @@
#!/usr/bin/env bash
# Run the full serverAppConfigs test suite with bats.
set -uo pipefail
cd "$(dirname "$0")"
echo "==> serverAppConfigs test run"
if ! command -v bats >/dev/null 2>&1; then
echo "ERROR: bats is not installed"
exit 1
fi
bats --print-output-on-failure *.bats
status=$?
echo
if [ "$status" -eq 0 ]; then
echo "==> ALL TESTS PASSED"
else
echo "==> TESTS FAILED (status $status)"
fi
exit "$status"
+31
View File
@@ -0,0 +1,31 @@
#!/usr/bin/env bats
# Seam: script syntax.
# Every shell script in scripts/ must pass `bash -n`. When shellcheck is
# available it must pass with no errors (warnings tolerated in this repo).
load helpers
@test "all scripts pass bash -n" {
local any=0
while IFS= read -r s; do
any=1
bash -n "$s" || { echo "syntax error in $s"; return 1; }
done < <(find "$REPO_ROOT/scripts" -name '*.sh' -type f 2>/dev/null | sort)
[ "$any" -eq 1 ] || skip "no scripts yet"
}
@test "scripts are executable" {
while IFS= read -r s; do
[ -x "$s" ] || { echo "$s is not executable"; return 1; }
done < <(find "$REPO_ROOT/scripts" -name '*.sh' -type f 2>/dev/null | sort)
}
@test "shellcheck passes when available" {
if command -v shellcheck >/dev/null 2>&1; then
while IFS= read -r s; do
shellcheck -S error "$s" || { echo "shellcheck errors in $s"; return 1; }
done < <(find "$REPO_ROOT/scripts" -name '*.sh' -type f 2>/dev/null | sort)
else
skip "shellcheck not installed locally"
fi
}