# From Clone to Localhost: The Bundled Stack

> Docker Compose, env defaults, and bundled sidecars (ChromaDB, SearXNG, ntfy) — the non-obvious pieces of the install path, including the auto-generated admin password and the SSH key flow for Cookbook remote servers.

- Repository: pewdiepie-archdaemon/odysseus
- GitHub: https://github.com/pewdiepie-archdaemon/odysseus
- Human wiki: https://grok-wiki.com/public/wiki/pewdiepie-archdaemon-odysseus-8b8805c93124
- Complete Markdown: https://grok-wiki.com/public/wiki/pewdiepie-archdaemon-odysseus-8b8805c93124/llms-full.txt

## Source Files

- `docker-compose.yml`
- `Dockerfile`
- `.env.example`
- `setup.py`
- `install-service.sh`
- `odysseus-ui.service`

---

<details>
<summary>Relevant source files</summary>
The following files were used as context for generating this wiki page:

- [docker-compose.yml](docker-compose.yml)
- [Dockerfile](Dockerfile)
- [docker/entrypoint.sh](docker/entrypoint.sh)
- [.env.example](.env.example)
- [setup.py](setup.py)
- [install-service.sh](install-service.sh)
- [odysseus-ui.service](odysseus-ui.service)
- [routes/cookbook_routes.py](routes/cookbook_routes.py)
- [config/searxng/settings.yml](config/searxng/settings.yml)
- [README.md](README.md)
</details>

# From Clone to Localhost: The Bundled Stack

Most "self-hosted AI workspace" repos punt on the rough edges: the vector database, the web search backend, the notification fan-out, the SSH key for talking to a remote GPU box. Odysseus bundles all of that into a single `docker compose up` and tries to make the rest invisible. This page walks through what actually starts when you bring the stack up, where the defaults come from, and the two install-path details that surprise people: the auto-generated admin password and the SSH key that Odysseus minted for itself inside the container.

The whole install path is unusually compressed — four services, one Dockerfile, one `.env.example`, one setup script, and one systemd unit — but each piece carries non-obvious load. If you skim them, the system looks generic; if you read them carefully, you see why the defaults are the way they are.

## What Compose Actually Starts

`docker compose up -d --build` brings up four containers in one network. Three of them are sidecars the app talks to over the Compose-internal DNS; the fourth is Odysseus itself.

| Service | Image | Container port | Host port | Purpose |
|---|---|---|---|---|
| `odysseus` | built from `./Dockerfile` | 7000 | `7000` | FastAPI app (uvicorn) |
| `chromadb` | `chromadb/chroma:latest` | 8000 | `8100` | Vector store for semantic memory |
| `searxng` | `searxng/searxng:latest` | 8080 | `127.0.0.1:8080` | Meta-search backend for web search |
| `ntfy` | `binwiederhier/ntfy` | 80 | `8091` | Local push-notification server |

Two routing details are worth knowing. SearXNG is intentionally bound to `127.0.0.1:8080` on the host, so it is not reachable from the LAN even though it's exposed on a port — only Odysseus inside Compose talks to it. ChromaDB is exposed on `8100` (not `8000`) on the host because port 8000 is a common collision with locally hosted model servers; inside the Compose network, Odysseus connects to `chromadb:8000` directly.

The Odysseus container itself depends on `searxng: service_healthy` and `chromadb: service_started`, where SearXNG ships its own HTTP probe via `urllib.request.urlopen` against `/` with a five-second timeout, retried up to 20 times. That gate matters: without it, the app warms up its search subsystem against a SearXNG that's still booting and logs spurious "DEGRADED" lines.

```yaml
healthcheck:
  test: ["CMD-SHELL", "python -c \"import urllib.request; urllib.request.urlopen('http://localhost:8080/', timeout=5).read(1)\""]
  interval: 5s
  timeout: 6s
  retries: 20
  start_period: 10s
```

Sources: [docker-compose.yml:1-79](), [README.md:174-180]()

## Env Defaults and the In-Network Overrides

`.env.example` documents the user-facing knobs; `docker-compose.yml` quietly overrides three of them so the container network "just works" without the user editing anything.

| Variable | `.env.example` default | Compose override |
|---|---|---|
| `SEARXNG_INSTANCE` | `http://localhost:8080` | `http://searxng:8080` |
| `CHROMADB_HOST` | `localhost` | `chromadb` |
| `CHROMADB_PORT` | `8100` | `8000` |

The host-side defaults are tuned for a manual install (uvicorn run directly on macOS or Linux, with `docker run -p 8100:8000 chromadb/chroma`). In Compose the in-network hostnames are correct because containers resolve each other by service name on the default user-defined bridge.

Anything else in `.env.example` is genuinely optional: `LLM_HOST` defaults to `localhost`, `LOCALHOST_BYPASS` defaults to `false` (the loopback auth bypass is dev-only), `AUTH_ENABLED` defaults to `true`, embeddings fall back to a local `fastembed` ONNX model if no HTTP endpoint is configured, and the in-process schedulers can be turned off with `ODYSSEUS_INPROCESS_POLLERS=0` / `ODYSSEUS_INPROCESS_TASKS=0` when an external cron drives them. The README's "defaults work out of the box" claim is load-bearing: you can `cp .env.example .env` and run.

Sources: [.env.example:1-103](), [docker-compose.yml:17-30](), [README.md:160-180]()

## The PUID/PGID Footgun, Solved at Entrypoint

The most subtle piece of the install path is the Docker entrypoint. The Dockerfile installs `gosu` alongside the usual system deps (`build-essential`, `cmake`, `git`, `tmux`, `openssh-client`, `nodejs`, `npm`) and then `COPY`s an entrypoint shell script that fixes a classic self-host bug: a container running as root writes root-owned files into bind-mounted host directories, and the host user (or any non-root caller) then can't update them. The result is silent EPERM failures on skill extraction, prefs saves, and mail attachments.

The entrypoint resolves `PUID`/`PGID` (defaulting to `1000:1000`, the typical first-user UID on Linux), reuses an existing matching user/group if one already exists in `/etc/passwd`, and otherwise creates `odysseus` with the right ids. Then it walks `/app`, `/app/data`, and `/app/logs` with `find ... -not -uid "$PUID"` and chowns only files that need fixing — keeping startup `O(touched-files)` rather than `O(everything)`, so terabyte-sized maildirs don't slow startup. Finally it `exec`s the actual command under `gosu` so signals from `docker stop` reach uvicorn directly without an extra shell layer.

```sh
for dir in /app /app/data /app/logs; do
    if [ -d "$dir" ]; then
        find "$dir" -not -uid "$PUID" -print0 2>/dev/null \
            | xargs -0 -r chown "$PUID:$PGID" 2>/dev/null || true
    fi
done

exec gosu "$PUID:$PGID" "$@"
```

If your host user isn't UID 1000, you override `PUID=...`/`PGID=...` in `.env` (the compose file forwards them with `${PUID:-1000}` defaults). The trick is that the chown sweep covers not just the bind mounts but also paths the app writes to inside the image's source tree at runtime — `services/cache/{search,content}/*`, `services/search_analytics.json`, the TTS cache — which were created as root during `docker build`.

Sources: [Dockerfile:1-47](), [docker/entrypoint.sh:1-52](), [docker-compose.yml:21-30]()

## The Auto-Generated Admin Password

There is no preset password. There is also no manual setup step on the manual install path — `python setup.py` does it for you, and if `ODYSSEUS_ADMIN_PASSWORD` isn't set in the environment, it generates an 18-byte URL-safe random password with `secrets.token_urlsafe(18)`, bcrypt-hashes it, writes `data/auth.json`, and prints the plaintext to stdout exactly once:

```python
username = os.getenv("ODYSSEUS_ADMIN_USER", "admin").strip() or "admin"
password = os.getenv("ODYSSEUS_ADMIN_PASSWORD") or __import__("secrets").token_urlsafe(18)
hashed = bcrypt.hashpw(password.encode(), bcrypt.gensalt()).decode()
auth_data = {
    "users": {
        username: {
            "password_hash": hashed,
            "is_admin": True,
        }
    }
}
```

`setup.py` is idempotent — if `data/auth.json` already exists, it prints `[skip] auth.json already exists` and does nothing. The Docker path takes a different route: the `Dockerfile`'s `CMD` is `uvicorn app:app --host 0.0.0.0 --port 7000`, so `setup.py` doesn't run inside the container. Instead, `core/auth.py` boots with `self._config = {}` and logs `"No auth config found — first-run setup required"`; the UI then routes you to `/api/auth/setup`, which calls `auth_manager.setup(username, password)` only while `is_configured` is false (rate-limited at 3 requests per 5 minutes per host).

So the two install paths produce two different first-login experiences:

```text
Manual (python setup.py)        Docker (compose up)
-------------------------       ----------------------------
generates random password       no auth.json on disk
prints once on stdout           UI shows first-run setup page
writes data/auth.json           POST /api/auth/setup creates admin
log in with printed pwd         log in with the password you typed
```

`ODYSSEUS_ADMIN_PASSWORD` in `.env` is a third option that pre-seeds the password on either path. The README is explicit that you'd only do that if you don't want the generated one shown on the manual install.

Sources: [setup.py:46-75](), [core/auth.py:60-76](), [routes/auth_routes.py:78-90](), [README.md:38-46]()

## The Cookbook SSH Key Flow

Cookbook is Odysseus's hardware-aware model fitter and runner. When you point it at a remote GPU server, it talks to that box over SSH — and because the app runs inside Docker, "Cookbook's home directory" isn't yours. The compose file mounts `./data/ssh` from the host to `/app/.ssh` inside the container, and the Cookbook routes manage a dedicated `ed25519` key inside that directory.

The key is generated lazily by an admin-only POST to `/api/cookbook/ssh-key`:

```python
def _cookbook_ssh_dir() -> Path:
    app_ssh = Path("/app/.ssh")
    if Path("/app").exists():
        return app_ssh
    return Path.home() / ".ssh"

def _cookbook_ssh_key_path() -> Path:
    return _cookbook_ssh_dir() / "id_ed25519"
```

```python
proc = await asyncio.create_subprocess_exec(
    "ssh-keygen", "-t", "ed25519", "-N", "", "-C", "odysseus-cookbook", "-f", str(key_path),
    ...
)
```

Three details matter for operators. First, the key has no passphrase (`-N ""`) and a recognisable comment (`-C "odysseus-cookbook"`), so when you see it in a remote `authorized_keys` file you know exactly who it is. Second, after generation the route locks down perms with `chmod 700` on the directory, `600` on the private key, and `644` on the public key — necessary because the bind mount inherits whatever the host gave you, which OpenSSH typically rejects. Third, the route auto-detects whether it's running in Docker: if `/app` exists, the key lives under `/app/.ssh` (the bind mount); otherwise it falls back to `~/.ssh` on the manual-install path.

The README also documents installing the key from the host without going through the UI:

```bash
ssh-copy-id -i data/ssh/id_ed25519.pub user@server
```

The local Hugging Face cache uses the same bind-mount trick: `./data/huggingface` on the host maps to `/app/.cache/huggingface` in the container, so Cookbook's "local" downloads survive image rebuilds.

Sources: [routes/cookbook_routes.py:206-255](), [docker-compose.yml:6-14](), [README.md:55-65]()

## SearXNG: The Smallest Config That Works

The bundled SearXNG instance carries the minimum config necessary to make it useful as a programmatic backend:

```yaml
use_default_settings: true

server:
  secret_key: "odysseus-local-searxng-json-2026-05-30"

search:
  formats:
    - html
    - json
```

The `formats: [html, json]` line is the operative one — without it SearXNG returns HTML only and Odysseus can't parse results. The secret key is hard-coded because the instance is bound to `127.0.0.1` and only reached from inside the Compose network; rotating it doesn't buy security in that topology. If you front Odysseus with a public reverse proxy, this is still fine since SearXNG never gets exposed.

Sources: [config/searxng/settings.yml:1-9](), [docker-compose.yml:48-63]()

## The Systemd Path (Option Three)

For non-Docker installs the repo ships `odysseus-ui.service` and a tiny installer. The unit file is a template — `User`, `WorkingDirectory`, and `ExecStart` all carry literal `YOURUSER` placeholders you must edit:

```ini
User=YOURUSER
WorkingDirectory=/home/YOURUSER/odysseus-ui
ExecStart=/home/YOURUSER/odysseus-ui/venv/bin/uvicorn app:app --port 8000 --host 0.0.0.0
Restart=always
RestartSec=3
EnvironmentFile=-/home/YOURUSER/odysseus-ui/.env
```

`install-service.sh` is a five-command wrapper: copy the unit into `/etc/systemd/system/`, `daemon-reload`, `enable`, `start`, `status`. Two oddities worth flagging: the unit defaults to port `8000` (not `7000` like the Docker path), and `EnvironmentFile` is prefixed with `-` so a missing `.env` won't fail the unit. There is no port-binding restriction, so you should pair it with a reverse proxy if you bind `0.0.0.0` — the SECURITY notes in `README.md:138-149` lean on Caddy with auto-renewed Let's Encrypt certs.

Sources: [install-service.sh:1-20](), [odysseus-ui.service:1-19]()

## What Builders Should Notice

Three patterns in this install path are reusable:

- **The PUID/PGID entrypoint** is the cleanest fix for the bind-mount footgun I've seen in a small project — `gosu` plus a one-pass chown that only touches files with the wrong uid. It is a much better default than running as root or shipping a UID-baked image.
- **The dual-default trick** — `.env.example` is tuned for the manual install path, and Compose overrides the in-network names — gives both audiences a working config without conditionals or templating.
- **The auto-detecting SSH-key location** in `routes/cookbook_routes.py` is a small but important nicety: the same code path generates `~/.ssh/id_ed25519` on bare-metal installs and `/app/.ssh/id_ed25519` in Docker, with no env var to set. It is the kind of thing that costs ten lines and saves a thousand support tickets.

What's still on the user: writing the public key to your remote GPU box (Cookbook prints it in the UI, but doesn't push it for you), and overriding `PUID`/`PGID` if your host UID isn't 1000. Everything else — the vector store, the search backend, the notification server, the admin password, the SSH key — is generated, mounted, and chown'd for you before uvicorn binds the port.

Sources: [docker/entrypoint.sh:1-52](), [routes/cookbook_routes.py:206-255](), [setup.py:46-75]()
