Skip to main content

Services

What runs

The services that run, on which hosts, and how they fit together. Back to What It Is, or on to Flows to follow one job end to end.

Each project named here fills a slot and can be swapped.

The map​

In short​

  1. One folder. workspace/ holds notes/, .decree/ (your automations) and .capture/ (Capture). code-server opens it with claude and opencode in the terminal. Nextcloud can sync it to other devices.
  2. decree runs it. Machines in workspace/.decree/ run on a schedule, an inbox message or a webhook, and report through ntfy. Defaults include a daily digest and weekly and monthly look backs. With Monitoring, run logs go to Loki.
  3. A model is a URL. Everything that needs one uses an OpenAI-compatible /v1 address: Ollama here, llama.cpp on another box, anything that serves /v1.

The AI, slot by slot​

None of this is in Core. Each row is a flag; flows that need one turn it on.

SlotShips asWhat it does
ModelsOllama, or any OpenAI-compatible serverServes the models at EXIST_MODEL_URL. Each role can point elsewhere: Configuration.
AgentpiAn agent with tools on the same model URL, in code-server's terminal and your automations. Baked in.
SearchqmdYour notes, PDFs and Word files by meaning and exact words, for you and every agent.
The webFirecrawlTurns a URL into markdown a model can read.
Speech → text (voice)wyoming-whisperWhat Home Assistant listens with. CPU only, so it never evicts a model from the GPU.
Text → speech (voice)wyoming-piperThe voice Home Assistant answers in. CPU only.
Voice front endHome AssistantWake word, microphones, speakers, and control of the house.
Speech → text (recordings)WhisperXLong recordings with speaker labels, on the GPU, run by decree.
Text → speech (HTTP)ChatterboxCloneable voices over an OpenAI-compatible API. Nothing in the stack calls it yet.
Chat front endOpen WebUIChat in a browser.
SearchqmdSearches your notes, PDFs and Word files by words and by meaning, for you or for Claude in code-server. CPU only, no model needed.
pi doesn't edit the stack

Change the stack with a coding agent on the host, against a frontier model. pi works on workspace/.

How it's assembled​

A true/false flag per service. Everything enabled is rendered, merged into one compose file and one env file, and run on one network.

No plugin system, no registry, no orchestrator.

1. Choose — a flag per service​

Every service is a folder, and every folder gets one flag in .env.shared:

EXIST_IS_<CATEGORY>_<SLUG>=true|false

The name comes from the path: ai/firecrawl → EXIST_IS_AI_FIRECRAWL. Categories are the top-level folders: ai/, services/, nas/, hosting/. A disabled service writes nothing to disk.

2. Render — templates become live files​

./existential.sh renders every enabled service's *.exist.* templates, generating secrets (EXIST_32_CHAR_HEX_KEY and others).

docker-compose.exist.yml → docker-compose.yml
.env.exist → .env
config.exist.yml → config.yml

Templates are tracked in git; rendered files never are.

  • Each file renders once. An existing file is left alone, so your answers and edits survive. ./existential.sh reset archives rendered files to archive/<timestamp>/ so the next run renders fresh.
  • New .env keys are appended on every run, with their comments, and listed in the output. Existing values, blanks included, are never changed. That makes git pull && ./existential.sh an upgrade.
  • Files with nothing you could lose (no secrets, no answers) are regenerated every run and say DO NOT EDIT: Dashy's dashy-conf.yml. Set # EXIST_KEEP: true in the header to keep yours.
  • Config inside a service's own datastore (Home Assistant's proxy settings, Nextcloud's trusted domains, ntfy's user) is set by that service's entrypoint.sh on every start.

3. Merge — one compose file, one env file​

Every enabled service's docker-compose.yml merges into one at the repo root, and every .env into one root .env.

docker compose up -d # always from the repo root, never from a service folder

The root compose file is regenerated every run. Edit the service's docker-compose.exist.yml instead.

Adding a service is adding a folder​

Create <category>/<slug>/docker-compose.exist.yml and add its EXIST_IS_* flag. The folder name is the container prefix, the flag and the hostname.

One network​

Everything joins one Docker network, exist, and reaches the rest by container name:

http://ollama:11434 http://firecrawl:3002 http://automation-webhook:8801
  • Core's two web apps publish one port each, on loopback, for tailscale serve.
  • Other services publish nothing; browsers reach them through Caddy at https://<slug>.<domain>.
  • Containers talk plain HTTP by name.

Core over Tailscale​

code-server's previews and the ntfy phone app both need a certificate every device trusts. Tailscale issues a Let's Encrypt certificate for the host's tailnet name, so ./existential.sh runs tailscale serve:

https://<host>.<tailnet>.ts.net → 127.0.0.1:42080 (code-server)
https://<host>.<tailnet>.ts.net:8443 → 127.0.0.1:44680 (ntfy)

Nothing to install on devices, nothing on the LAN. Setup steps: Getting Started.

Hostnames that just work​

For the Caddy add-on. EXIST_DOMAIN defaults to <host-ip-with-dashes>.nip.io, from this host's LAN address:

EXIST_LOCAL_HOST_IP=192.168.1.20 (LAN address)
↓
EXIST_DOMAIN=192-168-1-20.nip.io → https://dashy.192-168-1-20.nip.io
  • nip.io is public wildcard DNS: <anything>.<ip>.nip.io resolves to that IP. No DNS setup, and a new service needs no DNS change.
  • A LAN address only routes inside your network. Nothing is exposed. From elsewhere, use a VPN.
  • With Tailscale up, the tailnet IP (tailscale ip -4) is used instead.

:::danger If you use Tailscale, don't use a MagicDNS name EXIST_DOMAIN=my-box.tailnet.ts.net fails: MagicDNS has no wildcard under a node name, so dashy.my-box.tailnet.ts.net doesn't resolve. Use the tailnet IP in nip.io form. tailscale serve has the same limit, one hostname per node, which is why it serves only Core. :::

Downsides of nip.io:

  • Without internet, names stop resolving, even on the host.
  • nip.io sees your host's IP and each service name you look up.

pihole or a domain you own fixes both.

Upgrading an existing install​

Services read EXIST_DOMAIN at container start, so changing it applies on the next docker compose up -d. An older install may still have baked values in its rendered .env files. Delete these lines if present:

FileDelete
hosting/caddy/.envCADDY_DOMAIN=
services/mealie/.envMEALIE_BASE_URL=
nas/collabora/.envCOLLABORA_DOMAIN=, COLLABORA_NEXTCLOUD_DOMAIN=
  • A Caddyfile missing a newly enabled service's block: copy the block from Caddyfile.exist.Caddyfile, or run ./existential.sh reset.
  • reset archives every rendered file plus decree's run logs and migration ledger, lists what it will move, and asks first. It never touches _data or _backup volumes; it offers to delete _cache ones.
  • Permission denied: Docker created a missing bind-mount directory as root, usually from docker compose up -d in a tree that was never rendered. ./existential.sh run fix-permissions (--dry-run first) gives them back to you without sudo.

Two independent choices, not one upgrade path​

  1. How do names resolve? Public DNS, or pihole.
  2. Does the browser trust the cert? The stack's own CA, or a real certificate.
Internal CA (trust once per device)Real certificate (nothing to trust)
nip.io (default)Zero setup. Needs internet for DNS.Not possible — you don't control the nip.io zone.
piholeFully offline. Resolves locally.Fully offline and no warnings. See below.

Reachability is a separate question, answered by your network or VPN. The bottom-right cell needs a domain you own, with no public A record and no inbound access.

Owned domain without exposing your network​

The DNS-01 challenge needs no inbound connection. Your host creates a TXT record through your DNS provider's API; Let's Encrypt reads it from public DNS.

your host ──outbound──▶ DNS provider API (creates _acme-challenge TXT)
Let's Encrypt ──reads──▶ public DNS (never touches your network)

meanwhile:
pihole ──▶ local.example.com = 192.168.1.50 (resolution stays entirely on your LAN)

Result:

  • a trusted certificate on every device
  • no public A record, no port forwarding
  • local DNS via pihole, working offline
  • one outbound API call at renewal, about every 60 days

Setting it up​

You need a domain and a DNS provider with an API. HTTP-01 won't work: it needs inbound access.

Option A: issue the cert elsewhere and copy it in.

# on any computer with internet, using your provider's DNS plugin
acme.sh --issue --dns dns_cf -d '*.local.example.com' -d local.example.com

Copy the result into hosting/caddy/certs/ as internal.pem and internal-key.pem; Caddy uses an existing cert as is. Renew every 90 days.

Option B: Caddy renews it. The shipped caddy:2.11.4 image has no DNS provider plugins, so this needs a custom xcaddy image with your provider's module and an API token.

Then point EXIST_DOMAIN at your domain and add the pihole record:

EXIST_DOMAIN=local.example.com
address=/local.example.com/192.168.1.50 # pihole, resolves entirely on-LAN
Why not nip.io or .internal

No CA issues for .internal, and you can't write to the nip.io zone. Both need the internal CA.

Core vs. complementary services​

This is about the network, not about what ships on.

  • Core: talks to other services by container name.
  • Complementary: reached by a URL you could point anywhere, or by nothing. It can move to another host without changing anything else.
ServiceWhy it's complementary
NAS (Nextcloud, Collabora)Reached over rclone and HTTP, by URL.
ImmichNothing in the stack talks to it.
Home AssistantNothing talks to it. It lives where your Zigbee/Z-Wave dongles are.
pi-holeDNS for your whole network. No service calls it.
Monitoring (Grafana, Loki, Prometheus, Uptime-Kuma)Nothing depends on it.

Even on one host, think of it as separate stacks, so moving one later is a config change.

┌─ NAS stack ──────────────┐ Storage and files.
│ Nextcloud, Collabora │ Wants disks, not GPU.
│ │ Reached over rclone / WebDAV URLs.
└──────────────────────────┘

┌─ Core stack ─────────────┐ The actual system.
│ code-server, decree, │ Wants CPU and RAM, no GPU.
│ ntfy │ One network, container-name DNS.
└──────────────────────────┘

┌─ AI box ─────────────────┐ The models.
│ Ollama or llama.cpp, │ Wants the GPU.
│ ComfyUI, … │ Reached over an OpenAI-compatible /v1 URL.
└──────────────────────────┘

┌─ Standalone ─────────────┐ Independent appliances.
│ Immich pi-hole │ Own lifecycle, own host if you like.
│ Home Assistant │ HA follows your USB radios.
└──────────────────────────┘
  • NAS: storage wants disks and its own reboot schedule. Moving it is an endpoint change; workspace/ stays in sync.
  • Models: restarting a model server shouldn't interrupt anything. The AI box is one URL in .env.shared.
  • pi-hole: your LAN's DNS should stay up while you rebuild the stack.
  • Home Assistant: it lives where your radios are, and your lights should keep working while you rebuild.

Moving one to another host​

  1. Set that service's EXIST_IS_* flag to false on the original host and re-run ./existential.sh.
  2. Bring the service up on the new host.
  3. Update the endpoint that pointed at it: the rclone remote, the model URL, or the DNS record.

Watching every host​

Once the stack spans hosts, each can report its free disk space and its decree machines' runs to one Loki, for Grafana's Hosts dashboard: turn on the worker flow on each, with one of them the controller the others ask where to report. See Controller and Workers.