# 105 — `apps` Docker host for everything that doesn't justify its own LXC. Currently runs Artifacto, Booklore, PlantUML server, Portainer (and historically WriteFreely / blog), plus the [homelab-context distribution services](../infrastructure/homelab-context.md) (MCP + secrets-issuance) since 2026-05-20. ## At a glance - **Hostname:** `apps` - **IP:** `192.168.8.205` - **Privilege:** privileged (so container UID = host UID for bind-mount sanity) - **Resources:** 2 cores / 4 GiB RAM / 30 GiB rootfs - **Mounts:** `/mnt/library` ↔ `/mnt/library` - **Public hostnames:** see table below ## Public hostname → service map | Hostname | Container | Backend port | Notes | | --------------------------------- | ---------------- | ------------ | ----- | | `docker.hubris.network` | Portainer | `:9443` | Native OAuth2 via Authentik. Trusted-origins requires hostname only (no scheme/port). | | `books.hubris.network` | Booklore | `:6060` | Native OIDC. Redirect URI `/oauth2-callback`. | | `artifacto.hubris.network` | Artifacto | `:3100` | Public `/p/*`, `/static/*`, `/healthz` exposed via [VPS traefik](../infrastructure/ingress.md). | | `blog.hubris.network` | WriteFreely | `:8080` | Native OIDC via `[oauth.generic]`. | | `git.hubris.network/_plantuml/*` | PlantUML server | `:8079` | Same-origin route from [gitea (104)](104-gitea.md). | | `mcp.hubris.network` | homelab-mcp | `:9810` | MCP server over SSE. No SSO — agent-facing, gated at source-IP layer. See [homelab-context](../infrastructure/homelab-context.md). | | `secrets.hubris.network` | secrets-issuance | `:9820` | Per-client age-key issuance. Mesh+LAN source-IP gated (`MESH_SUBNETS`). | ## Stacks ### Artifacto (`/opt/artifacto/`) Self-hosted HTML-artifact share (Go + SQLite). Repo `dtoro/Artifacto` is the working tree itself — `git push origin main` triggers rebuild. - Compose: tracked `docker-compose.yml` binds `./data:/data` and `127.0.0.1:3100:3000`. `docker-compose.override.yml` uses the `!override` YAML tag on `volumes:` and `ports:` to redirect `data` to `/mnt/library/documents/artifacts`. - Data dir: `/mnt/library/documents/artifacts` — owner **UID 10001 : GID 10001** (matches the `app` user baked into the Dockerfile), mode 750. Parent `/mnt/library/documents` stays `www-data:www-data 750`; bind targets the child directly so no traversal needed. - `.env` (untracked) holds `ADMIN_PASSWORD`, `SESSION_SECRET` (`openssl rand -hex 32`), `BASE_URL=https://artifacto.hubris.network`, `SSO_GATEWAY_SECRET` (must match `ARTIFACTO_GATEWAY_SECRET` in `/etc/caddy/caddy.env` on [caddy](121-caddy.md)). Don't `git checkout .env`. - Auth: Authentik forward-auth on the admin surface (`/`, `/login`, `/a/*`, `/api/*`). `X-Authentik-Username` is honored only when the `X-Artifacto-Gateway` header matches `SSO_GATEWAY_SECRET` — prevents direct LAN connections from spoofing identity. - Public exposure: VPS traefik gates path scope to share URLs; admin surface returns 404 at the edge. See [ingress](../infrastructure/ingress.md). #### Auto-deploy pipeline (Artifacto) Receiver at `/opt/artifacto-deploy/` (outside the app repo): `deploy.sh` + `webhook.py`, secret in `/etc/artifacto-deploy/secret`, systemd unit `artifacto-deploy-webhook.service`. Listens on `0.0.0.0:9798`, validates `X-Gitea-Signature` HMAC-SHA256, filters to `refs/heads/main`, runs deploy in a daemon thread (returns 202 immediately). Gitea webhook id 7 on `dtoro/Artifacto`. - Logs: `pct exec 105 -- journalctl -u artifacto-deploy-webhook -f` - Manual deploy: `pct exec 105 -- /opt/artifacto-deploy/deploy.sh` - Health: `pct exec 105 -- curl -s http://127.0.0.1:9798/health` → `ok` - Slug length bumped 5 → 8 chars (in `internal/store/artifacts.go:41`) once the endpoint went public. ### PlantUML (`/opt/plantuml/`) `plantuml/plantuml-server:jetty` listening on `192.168.8.205:8079`, context path `/plantuml` (`BASE_URL=plantuml`). Reached only via [gitea](104-gitea.md)'s caddy `handle_path /_plantuml/*` rewrite — no DNS record of its own. Same-origin from gitea so the embedded markdown previewer can call it without CORS. ### Portainer Native OAuth2 (Settings → Authentication → OAuth → Custom). Manual endpoints (no OIDC discovery). Uses `portainer-uid` custom-claim scope from Authentik. Container is **not** compose-managed — safe to `docker run` recreate; data lives in named volume `portainer_data`. CLI flag: `--trusted-origins docker.hubris.network` (hostname only — `IsTrustedOrigin` rejects strings containing `://`). ### Booklore Native OIDC via Authentik (Settings → OIDC). Redirect URI `/oauth2-callback` (NOT `/api/oidc`). Container needs `extra_hosts: auth.hubris.network:192.168.8.175`. **Edit via Portainer UI** if it's a Portainer-managed stack. > ⚠️ **Never `docker compose up` Portainer-managed stacks from the host shell.** Portainer's compose state lives at `/var/lib/docker/volumes/portainer_data/_data/compose//`. Running `docker compose up -d ` from the host triggers recreates of OTHER services in the stack and silently destroys bind-mounted data. **This wiped Booklore's mariadb data on 2026-04-22.** Use the Portainer UI editor for compose changes. See [mesh migration](../infrastructure/mesh.md#critical-never-docker-compose-up-portainer-managed-stacks) for the full warning. ### homelab-mcp (`/opt/homelab-mcp/`) FastMCP server (Python venv at `/opt/homelab-mcp/.venv`). Reads from `/opt/homelab-context/` (this LXC is itself an enrolled [homelab-context](../infrastructure/homelab-context.md) client). Source is `dtoro/Homelab-Docs/mcp/server.py`; service unit `homelab-mcp.service` binds `0.0.0.0:9810`. DNS-rebinding protection disabled at the FastMCP layer because mesh+LAN gating is the actual trust boundary. - Endpoint: `https://mcp.hubris.network/sse` (Caddy → `:9810`). - 14 tools registered: `get_host`, `list_services`, `find_service`, `get_topology`, `search_docs`, `get_page`, `get_changelog`, `whoami`, `list_my_secrets` (context); `get_service_status`, `tail_log`, `list_lxcs`, `get_lxc_state`, `ping_service` (read-only management). - Management tools shell out via SSH using a dedicated `mcp-reader` key (planned; not provisioned yet — management tools fail until then). #### Auto-deploy pipeline (homelab-mcp) Receiver at `/opt/homelab-mcp/mcp/deploy/webhook/` (in-tree alongside the server code). Listens on `0.0.0.0:9811`, secret in `/etc/homelab-mcp-deploy/secret`, systemd unit `homelab-mcp-deploy.service`. Gitea webhook id 10 on `dtoro/Homelab-Docs`. Deploy script reinstalls the service unit and restarts on push. ### secrets-issuance (`/opt/secrets-issuance/`) Tiny HTTP service that issues per-client age keypairs the first time each client calls `/issue`. Idempotent: subsequent calls return the same key. Mesh+LAN source-IP gated via the `MESH_SUBNETS` env in `secrets-issuance.service`. State (per-client keys + denylist) lives at `/var/lib/secrets-issuance/` (NOT in the repo). - Endpoint: `https://secrets.hubris.network/issue` (Caddy → `:9820`). - `/revoke` is admin-token-gated by `/etc/secrets-issuance/admin-token`; shreds the local key file and adds the hostname to the denylist. Called by `homelab client remove`. - Trust subnets today: `100.122.0.0/16` (Netbird), `100.64.0.0/10` (Tailscale), `192.168.8.0/24` (LAN). Tighten if the LAN gets untrusted devices. #### Auto-deploy pipeline (secrets-issuance) Receiver at `/opt/secrets-issuance/secrets-issuance/deploy/webhook/`, port `9821`, secret in `/etc/secrets-issuance-deploy/secret`, unit `secrets-issuance-deploy.service`. Gitea webhook id 11. ### WriteFreely (`/opt/writefreely-docker/`) Native OIDC via `[oauth.generic]` in `config/config.ini`. `host = https://auth.hubris.network`, scope `openid profile email`, `map_user_id = sub`. Redirect URI in Authentik: `https://blog.hubris.network/oauth/callback/generic`. Container has `extra_hosts: auth.hubris.network:192.168.8.175`. First-time users with a local WF account must connect SSO via Customize/Settings → Linked Accounts. ## Permissions - LXC is privileged → in-container UID == host UID. - Member of [media GID 10000](../infrastructure/media-permissions.md). Adding `media` to the LXC alone is *not* enough for inner Docker containers — each container needs `--group-add 10000` or `user: ":10000"` to write into `/mnt/library`. ## Related - [Gitea (104)](104-gitea.md) — uses the PlantUML server - [Caddy (121)](121-caddy.md) - [Authentik (124)](124-authentik.md) - [DNS](../infrastructure/dns.md) - [Auto-deploy](../infrastructure/auto-deploy.md) - [Public ingress (Artifacto + blog)](../infrastructure/ingress.md) ## Changelog ### 2026-05-20 — homelab-mcp + secrets-issuance live Two new services from the [homelab-context distribution plan](../infrastructure/homelab-context.md): `homelab-mcp.service` on `:9810` (MCP read+management surface) and `secrets-issuance.service` on `:9820` (per-client age-key provisioning). Caddy fronts both with Let's Encrypt; new vhosts on [caddy](121-caddy.md), split-horizon DNS entries on [authentik (124)](124-authentik.md). Gitea webhook ids 10 + 11 wire auto-deploy. LXC is itself an enrolled context client (`/opt/homelab-context/`). ### 2026-04-28 — wiki entry created Initial documentation. ### 2026-04-22 — Artifacto deployed + Authentik gateway-secret auto-login wired Repo `dtoro/Artifacto`. Compose, data dir, `.env`, webhook (id 7), Caddyfile site block, gateway-secret pattern, public-internet share exposure on the VPS — all set up that day. Slug length bumped 5 → 8 for brute-force resistance. ### 2026-04-22 — Booklore mariadb data wiped (lesson, not a feature) `docker compose up -d` from the host shell on a Portainer-managed stack recreated services and re-initialized the bind-mounted `./mariadb/config`. Recovered by reseeding from snapshot. Rule recorded: edit Portainer-managed stacks via the Portainer UI. ### 2026-04-22 — Portainer wired into Authentik (native OAuth2) Custom-claim `portainer_uid` scope. CLI added `--trusted-origins docker.hubris.network`. ### 2026-04-22 — `app.ini` host whitelist on gitea extended Added `192.168.8.205` so the Artifacto webhook delivery wouldn't be rejected. See [gitea (104)](104-gitea.md). ### 2026-04-21 — Booklore + WriteFreely wired into Authentik OIDC Native OIDC for both. `extra_hosts` override added inside each container so DNS resolves `auth.hubris.network` to the Caddy IP. ### 2026-04-20 — PlantUML server deployed Used by [gitea (104)](104-gitea.md)'s footer.tmpl injection. Same-origin via caddy `handle_path /_plantuml/*` rewrite — no separate hostname.