Configuration
Host settings live in .env.shared (rendered once from .env.exist.shared, gitignored).
./existential.sh merges it into the root .env. The file has one comment line per key; this
page has the detail.
What happens when you git pull
Rendered files are never overwritten. Instead, each run appends any key your .env file is
missing, with its comment, and lists it:
updated: .env.shared — 2 new key(s) from the template
+ EXIST_NTFY_USER
+ EXIST_PUBLIC_DOMAIN (needs a value)
git pull && ./existential.sh is the upgrade. It never:
- changes a value you set
- fills a blank (blank means something: "not asked yet", or "use the global URL")
- removes a key (
./existential.sh validate driftlists extras) - asks a question: a key that needs input arrives blank, marked
(needs a value)
New services arrive with their flag false.
Images work differently: when a template names a different image than your rendered compose
file, the run lists it and asks before updating the file. Each run then pulls and builds every
enabled image without touching running containers; docker compose up -d switches to them.
Service enablement
EXIST_IS_<CATEGORY>_<SLUG>=true|false, one per service folder
(Services). Set by hand or by
./existential.sh flow on <name>.
Models
Every model is named once, in the Model Selection block; everything else reads it. The block is filled from a tier table when the first GPU service is enabled (GPU vendor, then VRAM). To change it:
./existential.sh run models
After editing anything in the block by hand:
./existential.sh run ollama pull-models
docker compose up -d # recreates what reads the models
The VRAM budget
On one card, at all times:
chat model + KV cache < your VRAM
Download sizes:
| Tag | Size | Capabilities | Tier |
|---|---|---|---|
qwen3-vl:2b | 1.9 GB | tools, vision | CPU-only |
qwen3-vl:4b | 3.3 GB | tools, vision | 6 GB |
gemma4:e2b-it-qat | 4.3 GB | tools, vision | 8 GB (default) |
gemma4:e4b-it-qat | 6.1 GB | tools, vision | 12 GB |
gemma4:12b-it-qat | 7.2 GB | tools, vision | 16 GB |
gemma4:26b-a4b-it-qat | 16 GB | tools, vision | 24 GB |
gemma4:31b-it-bf16 | 63 GB | tools, vision | 96 GB (bf16) |
- Up to 24 GB is quantised; 96 GB runs full precision.
- Every tier runs 64k context (an agent's tool loop needs it). Below about 12 GB the KV cache spills to RAM and runs slower.
- The table is
src/utils/model-tiers.sh. Edit it there; a unit test checks it matches.
Traps
- gemma4 "E" tags count effective parameters, not size:
gemma4:e4bdownloads at 9.6 GB. Use the-qattags above. - Mixture-of-experts models keep every expert resident: a 30B model with 3B active is still 25 GB.
Rules for any model you pick
- It must have ollama's
toolscapability, or pi can't act. - Prefer multimodal, so images don't evict it for a separate vision model.
EXIST_MODEL_CHAT_NUM_CTXis a floor of 65536. Below it ollama silently truncates an agent's prompt. Raising it costs VRAM.EXIST_MODEL_VISIONships as the chat tag, so one model stays loaded. BlankEXIST_MODEL_VISIONdisables image handling.- Speech runs on CPU (wyoming-whisper, wyoming-piper):
EXIST_MODEL_STT:tiny | base | small | medium | large-v3EXIST_MODEL_TTS_VOICE: a piper voice,<lang>_<REGION>-<name>-<quality>(samples)EXIST_MODEL_STT_LANGUAGE: the language you speak (enby default); short commands need it- WhisperX is separate: GPU, long recordings, speaker labels.
Endpoints — putting roles on different hosts
EXIST_MODEL_URL (default http://ollama:11434/v1) is the OpenAI-compatible /v1 URL
every role uses unless it has its own: local ollama, ollama elsewhere, llama.cpp
(http://192.168.1.20:8080/v1), anything that answers /v1/chat/completions.
- Every caller speaks only
/v1, so the backend doesn't matter. - Ollama-only steps (pull,
run ollama unload,/api/show) run only withEXIST_IS_AI_OLLAMA=true. - The vision server must accept images (llama.cpp:
--mmproj). - Models elsewhere: set
EXIST_IS_AI_OLLAMA=false.
The Model Endpoints block sends each role somewhere different. Every key ships blank,
meaning "use EXIST_MODEL_URL".
| Key | Role | Who reads it |
|---|---|---|
EXIST_MODEL_URL_CHAT | Chat / reasoning | pi, open-webui, Home Assistant, decree scripts, the newsletter |
EXIST_MODEL_URL_VISION | Vision / OCR | image-ocr |
A worked split, with a 24 GB card and an 8 GB one:
EXIST_MODEL_URL=http://bigbox:8080/v1 # chat lands here
EXIST_MODEL_URL_VISION=http://littlebox:11434/v1
Chat keeps the big card; OCR stops evicting it. Then ./existential.sh and
docker compose up -d.
./existential.sh run modelsprints where each role resolves. Migrations pull each model to the host that serves it.- The old
EXIST_OLLAMA_URL*keys are carried over on upgrade,/v1added. - A Separate AI Box: llama.cpp presets, ComfyUI, on-demand TTS.
ComfyUI moves the same way, with EXIST_COMFYUI_URL (default http://comfyui:8188):
EXIST_COMFYUI_URL=http://bigbox:8188
EXIST_IS_AI_COMFYUI=false # nothing local to start
One thing doesn't follow the block: Open WebUI's compose file is rendered once. Edit
ai/open-webui/docker-compose.yml too, or ./existential.sh reset.
Speech has no endpoint keys
They run on CPU, and Home Assistant addresses them by host and port in its own settings. To
move one: publish its port on the other host (the commented ports: block), disable it here,
and point Home Assistant at it.
GPU vendor
EXIST_GPU_VENDOR: nvidia, amd, none or external. Asked once, when the first GPU service
is enabled (never for Core). Ships blank, meaning not yet asked. Change it with
./existential.sh run models.
What it changes, in src/generate-compose.ts:
| Value | Effect |
|---|---|
nvidia | Nothing. The templates already declare the nvidia device reservation. |
amd | The nvidia reservation is stripped, and each service's x-exist-gpu.amd block is merged in instead (Vulkan/ROCm). |
none | The nvidia reservation is stripped, and x-exist-gpu.none merged. |
external | Same treatment as none — this host has no card to reserve. Ollama is not run locally at all; the models come from EXIST_MODEL_URL on another host. |
- One nvidia reservation on a host without the runtime fails
docker compose upfor the whole stack, which is why it's stripped. - Blank falls back to
EXIST_VRAM_GB(0→none, a number →nvidia); both blank →none. externalturns off local Ollama and asks for a model URL. That's how a GPU-less machine runs the agent path.- e2e runs a tiny model set on CPU (
src/test/fixtures/env.shared), so it needs no card.
EXIST_VRAM_GB is the tier picked; it also ships blank.
- Custom (last entry) takes a model name, sets the
EXIST_MODEL_*keys, and checks nothing. The model needs tool calling, vision and 64k context. 0is CPU-only, set by answering No GPU. Expect seconds per token.
Identity and permissions
EXIST_USERNAME/EXIST_PASSWORD: your login for ntfy and every admin account a service creates. Blank until a service that needs them is first turned on (Core does), then asked once. Enter givesadminand a generated 24-character password, which is never printed. A typed password must be 8+ characters ofA-Za-z0-9._-(it's written unquoted into many files).EXIST_EMAIL: blank until an add-on that needs one (Open WebUI, NocoDB, Appsmith) is first enabled, then asked once.EXIST_PUID/EXIST_PGID: the user and group every container runs as, so files in volumes stay yours. Detected fromid -u/id -gon the first run.
Hostnames, DNS and TLS
EXIST_TAILNET_NAME: Core's address (box.tail1234.ts.net), set oncetailscale serveworks. ntfy and the digest's links use it. See Core over Tailscale.EXIST_CONTROLLER: with theworkerflow on, the controller this host reports to, its tailnet name or 100.x address. Blank, this host is the controller. See Controller and Workers.EXIST_REPORTING_URL: on the controller, the Loki every host reports to (http://<host>:3100). Blank means this host's own Loki, when it is on.
The rest are for Caddy (Hostnames that just work):
EXIST_LOCAL_HOST_IP: detected (LAN address, or the tailnet address with Tailscale up). Keep it aboveEXIST_DOMAINin the file; placeholders resolve top-down.EXIST_DOMAIN: defaults to<ip-with-dashes>.nip.io. Never a MagicDNS name: there's no wildcard under one.EXIST_PUBLIC_DOMAIN: adds Let's Encrypt certs. Needs a domain you own,<domain>and*.<domain>pointing at your public IP, and ports 80 and 443 forwarded. Then./existential.sh run caddy public-domain.
docker compose up creates the exist network.
Storage
- Data is host bind mounts under
<repo>/volumes/<name>, never Docker volumes. EXIST_NFS_ADDRESS: the NAS's NFS address, asked the first run a service with annfs: truevolume is on. Blank is not yet asked;nonekeeps every volume local.EXIST_NFS_PATH_<VOLUME>: one pernfs: truevolume of an enabled service (e.g.EXIST_NFS_PATH_NEXTCLOUD_DATA), appended when asked: that volume's folder on the NAS, ornone(Enter) to keep it on this host. Compose mounts it over NFS.EXIST_NFS_SHARE_<NAME>: the same for a volume that holds your own files rather than a container's data:EXIST_NFS_SHARE_NEXTCLOUD(shown in Nextcloud at/nas) andEXIST_NFS_SHARE_WORKSPACE(whereworkspace/syncs to). See Storage.EXIST_BACKUP_RCLONE_REMOTE: an rclone remote (<remote>:<path>), set by./existential.sh run rclone. Blank disables scheduled DB backups.
Shared service credentials
Generated on render, shared between services:
EXIST_NTFY_URL: ntfy, by container name.EXIST_NTFY_USER/EXIST_NTFY_PASSWORDare the publisher, created by ntfy on first boot.EXIST_DECREE_FILES_WEBHOOK_AUTH_TOKEN: what Nextcloud's file-event webhooks send to/files. See File Processor.EXIST_NEXTCLOUD_DOMAIN,_USER,_APP_PASSWORD,_WORKSPACE_DIR: the Nextcloudworkspace/syncs with. See Syncing with a Nextcloud.