> Every key, path, port and command, in one place. Dry on purpose.

This page is the exhaustive one. It describes the stack as it runs today, which is the Compose
deployment of the published images. Commands that belong to the `q15` binary are marked **not built**
and paired with what to type instead.

## Run it today, end to end

This is the sequence that produces a running stack on the published images, in order, with what you
should see at each step. It is longer than the four commands on [Install q15](/install/) because it is
the path that exists.

1. **Get the deployment directory.**

   Today that means a checkout, because the Compose file and the secret templates live in the
   repository. When the `q15` binary ships, this step is the installer instead, and it disappears.

   ```bash
   git clone --depth 1 https://github.com/q15co/q15.git
   cd q15
   ```

2. **Seed the configuration and secret files.**

   ```bash
   make compose-secrets-init
   cp deploy/compose/release.env.example deploy/compose/release.env
   ```

   You should now see nine new files: eight under `deploy/compose/secrets/` and `auth/auth.json` beside
   them. Each printed line is either `wrote` or `kept`; `kept` means the file was already there, because
   nothing is overwritten.

3. **Fill in what your deployment needs.**

   At minimum, a provider block with a key your provider accepts, and the matching file in `secrets/`
   with one value in it and mode 600. [Models](/models/) and [Credentials](/credentials/) cover those
   two, and the shipped config already points the embedding backend at the local `q15-tei` container.

4. **Decide the origin before you enrol.**

   ```bash title="release.env, or the environment"
   Q15_WEB_ORIGIN=https://chat.example.net
   ```

   The passkey you create in step 6 is bound to this hostname. Changing it later fails startup, so this
   is the one value you cannot repair afterwards.

5. **Start the stack.**

   ```bash
   podman compose --env-file deploy/compose/release.env \
     -f deploy/compose/docker-compose.image-first.yml up -d --wait
   ```

   `--wait` returns only when every health check passes, so the command ending is the answer.

   You should now see six containers running, with only the web tier publishing a port. If one is
   missing or restarting, `podman compose … logs -f <name>` names the input it could not read, and
   [Troubleshooting](/troubleshooting/) has the short list.

6. **Enrol your authenticator.**

   ```bash
   podman compose --env-file deploy/compose/release.env \
     -f deploy/compose/docker-compose.image-first.yml exec q15-web \
     /usr/local/bin/q15-web auth enroll 'Laptop'
   ```

   The command prints a blob and waits. Open the app, paste the blob, create the credential, and paste
   the one-line response back inside the five-minute window. See [Chat](/chat/) for the constraints on
   acceptable authenticators.

7. **Reach it.** The web tier binds loopback, so use an SSH forward, or put a TLS terminator in front
   and set the origin to that hostname. A worked reverse-proxy example is **in development**.

8. **Back it up before you need it.** `/memory` and `/workspace` are the two that cannot be recreated.
   [Backups](/backups/) has the list and the order.

## The stack

| Stack           | File                                            | Images                               | Use it for                                  |
| --------------- | ----------------------------------------------- | ------------------------------------ | ------------------------------------------- |
| **Deployment**  | `deploy/compose/docker-compose.image-first.yml` | Published `ghcr.io/q15co/q15-*` tags | A server that should stay running.          |
| **Development** | `docker-compose.yml` (repository root)          | Built locally from source            | Working on q15 itself. Project `q15-local`. |

Both are plain Compose files and the images are ordinary OCI images, so **podman** (the default) and
Docker run the same commands. The examples here use `podman compose`; on Docker, use `docker compose`
and change nothing else.

```bash title="deployment, from a checkout of the repository"
make compose-secrets-init
cp deploy/compose/release.env.example deploy/compose/release.env
# edit release.env, agent-config.yaml, proxy-policy.yaml and secrets/
podman compose --env-file deploy/compose/release.env \
  -f deploy/compose/docker-compose.image-first.yml up -d --wait
```

`--wait` returns only when every health check passes. Nothing is built on your host: the deployment
file uses `image:` only.

### Files

Paths are relative to `deploy/compose/`, which is where the Compose file expects them.

| File                | Mounted at                   | Purpose                                                                    |
| ------------------- | ---------------------------- | -------------------------------------------------------------------------- |
| `release.env`       | —                            | `Q15_IMAGE_TAG` and embedding-backend overrides. Passed with `--env-file`. |
| `agent-config.yaml` | `/etc/q15/agent/config.yaml` | Providers, tools, Telegram, the bridge socket.                             |
| `proxy-policy.yaml` | `/etc/q15/proxy/policy.yaml` | Egress passthrough and credential injection rules.                         |
| `auth/`             | `/etc/q15/auth`              | OpenAI OAuth credential state. Mount the **directory**, not the file.      |
| `secrets/<name>`    | `/run/q15-secrets/<name>`    | One file per credential, each paired with a `*_FILE` environment variable. |

`make compose-secrets-init` copies the tracked `*.example` templates to those names and never
overwrites a file that already exists.

### Volumes

| Volume               | Mount                | Contents                                                                         |
| -------------------- | -------------------- | -------------------------------------------------------------------------------- |
| `q15_workspace`      | `/workspace`         | The durable project tree, working files, embedding source registry.              |
| `q15_memory`         | `/memory`            | Identity, semantic knowledge, working state, turn history.                       |
| `q15_skills`         | `/skills`            | Installed skills. `@builtin/` ships with the image.                              |
| `q15_media`          | `/media`             | The media store: attachments and generated media.                                |
| `q15_agent_state`    | `/var/lib/q15/agent` | Scheduled-job definitions and run records.                                       |
| `q15_proxy_state`    | `/var/lib/q15/proxy` | Proxy-owned state, including the interception CA.                                |
| `q15_web_state`      | `/var/lib/q15-web`   | Web credential store and admin socket. Seeded UID 65532, GID 1000, mode 0700.    |
| `q15_bridge`         | `/run/q15`           | The bridge socket. Mounted in the agent and the web tier only, read-only on web. |
| `q15_qdrant_storage` | `/qdrant/storage`    | Embedding collections.                                                           |
| `q15_exec_nix_store` | `/nix`               | The executor's package store, kept warm across restarts.                         |
| `q15_tei_hf_cache`   | `/data`              | Embedding model weights.                                                         |

  A bind mount at `/nix` hides the shell and the Nix installation the image provides. A named volume
  preserves them through the image's first-use bootstrap.

### Ports

| Service      | Inside the stack                             | Published        |
| ------------ | -------------------------------------------- | ---------------- |
| `q15-web`    | `0.0.0.0:8080` (the deployment file sets it) | `127.0.0.1:8080` |
| `q15-agent`  | bridge socket at `/run/q15/bridge.sock`      | —                |
| `q15-exec`   | `:50051` (gRPC)                              | —                |
| `q15-proxy`  | `:18080` (HTTP proxy), `:50052` (admin gRPC) | —                |
| `q15-qdrant` | `:6334`                                      | —                |
| `q15-tei`    | `:80`                                        | —                |

Only the web tier publishes anything, and only on loopback. The port is pinned by the contract test,
not by a variable:

```bash
make compose-check
podman-compose -f docker-compose.yml config
podman-compose -f deploy/compose/docker-compose.image-first.yml config
```

`make test` runs the same check, so a change that moves the port or the mount set fails the gate.

### Routes

| Route                   | What it does                                                                      |
| ----------------------- | --------------------------------------------------------------------------------- |
| `GET /ws`               | The browser socket: streaming replies, tool activity, history paging, media.      |
| `GET /api/turns`        | Transcript history, newest first, paged with `after_seq` and `limit`.             |
| `POST /api/media`       | Uploads a sealed batch of attachments. One proof-authorized request per send.     |
| `GET /api/media/{hash}` | Serves a stored attachment back, streamed in 32 KiB encrypted chunks.             |
| `GET /` and assets      | The app itself, served from the binary. Hashed files only; no directory listings. |
| `GET /healthz`          | The only unauthenticated endpoint. Used by the container health check.            |

There is no password, no bearer token, no query token and no proxy-identity fallback. Unauthenticated
requests to anything but `/healthz` answer `401`, and an empty credential store starts locked.

### Content sealing

Every frame that carries content is sealed between the browser and the agent: chat bodies, reasoning,
tool arguments and results, live deltas, snapshots and replayed history. Unknown frame types are sealed
by default, so a new frame type fails closed rather than leaking. Only a small control set (ids,
counters, timestamps, error codes) travels in plaintext.

The keys are derived per connection and bound to the session: an ephemeral ECDH exchange between the
browser and the agent, then HKDF-SHA-256 key derivation, then AES-256-GCM per direction, with the chunk
index bound into each message. The web tier relays opaque bytes; it holds no key and parses no chat
content.

Attachments ride the same envelope. The composer holds files in memory until you send; one
proof-authorized upload carries a sealed batch, capped at **16 files and 8 MiB of decoded file bytes**
per send. The agent sniffs the type, stores the file under a content hash, and serves it back through
an authenticated descriptor carrying filename, type and size.

## Configuration

### `agent-config.yaml`

```yaml
providers:
  - name: ollama-cloud
    type: ollama
    base_url: https://ollama.com
    key_env: OLLAMA_API_KEY
    discovery:
      models_dev: true

agent:
  name: Q15
  memory_recent_turns: 6
  bridge:
    listen_target: unix:///run/q15/bridge.sock
  tools:
    web_search:
      brave_api_key_env: BRAVE_API_KEY
    embeddings:
      qdrant_url_env: Q15_QDRANT_URL
      provider: openai
      base_url_env: Q15_EMBEDDINGS_BASE_URL
      model: qwen3-embedding-0.6b
      dimensions: 1024
      batch_size: 128
    schedule:
      max_jobs: 64
      max_run_turns: 16
  telegram:
    token_env: Q15_TELEGRAM_TOKEN
    allowed_user_ids_env: Q15_TELEGRAM_ALLOWED_USER_IDS
```

| Key                                        | Type     | Default                       | Notes                                                                                                                                                               |
| ------------------------------------------ | -------- | ----------------------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
| `providers[].name`                         | string   | required                      | The label used in model refs.                                                                                                                                       |
| `providers[].type`                         | enum     | required                      | `ollama`, `openai-compatible`, `openai-codex`.                                                                                                                      |
| `providers[].base_url`                     | string   | provider default              | For example `https://ollama.com`, or your own server.                                                                                                               |
| `providers[].key_env`                      | string   | —                             | The environment variable holding the key. `*_FILE` also works.                                                                                                      |
| `providers[].discovery.models_dev`         | bool     | false                         | Enrich discovered models with cost, context and benchmark metadata.                                                                                                 |
| `providers[].discovery.include/exclude`    | []string | —                             | Glob filters over discovered model ids.                                                                                                                             |
| `agent.name`                               | string   | required                      | The agent's own name, written into its identity files. The planned installer derives the unit and volume names from it; the shipped Compose stack uses fixed names. |
| `agent.memory_recent_turns`                | int      | 6                             | How many recent turns are replayed verbatim.                                                                                                                        |
| `agent.bridge.listen_target`               | string   | `unix:///run/q15/bridge.sock` | Unix only. TCP is refused: this is the identity surface.                                                                                                            |
| `agent.tools.web_search.brave_api_key_env` | string   | —                             | Omit the block to run without web search.                                                                                                                           |
| `agent.tools.embeddings.*`                 | —        | see [Memory](/memory/)        | `provider` is `openai` (local TEI) or `gemini`; omit the block to disable.                                                                                          |
| `agent.tools.schedule.max_jobs`            | int      | 64                            | Bounded at 1000.                                                                                                                                                    |
| `agent.tools.schedule.max_run_turns`       | int      | 16                            | Bounded at 128.                                                                                                                                                     |
| `agent.telegram.token_env`                 | string   | —                             | Omit to run without Telegram.                                                                                                                                       |
| `agent.telegram.allowed_user_ids_env`      | string   | —                             | Required if Telegram is configured. An empty list is refused.                                                                                                       |

There is no model list in this file. Providers publish rosters, and the current model is runtime
state. See [Models](/models/).

### `proxy-policy.yaml`

```yaml
proxy:
  no_proxy:
    - localhost
    - 127.0.0.1
    - ::1
    - q15-proxy
    - q15-exec
  set_lowercase_proxy_env: true
  secrets:
    - github_token
  rules:
    - name: github-api
      match_hosts:
        - api.github.com
  env:
    - name: GH_TOKEN
      secret: github_token
      rules:
        - github-api
      in:
        - header
```

| Key                                 | Purpose                                                                            |
| ----------------------------------- | ---------------------------------------------------------------------------------- |
| `proxy.no_proxy`                    | Hosts that bypass the proxy entirely.                                              |
| `proxy.set_lowercase_proxy_env`     | Also export the proxy variables in lowercase.                                      |
| `proxy.secrets`                     | The secret aliases this policy may use.                                            |
| `proxy.rules[].match_hosts`         | Hostnames the rule applies to.                                                     |
| `proxy.rules[].match_path_prefixes` | Optional path restriction inside a host.                                           |
| `proxy.rules[].set_header`          | Headers to set, with `{{ secret.alias }}` placeholders.                            |
| `proxy.rules[].set_basic_auth`      | `username` plus a secret, injected as an `Authorization: Basic` header.            |
| `proxy.rules[].replace_placeholder` | Literal placeholders replaced in a query, header or body.                          |
| `proxy.env[]`                       | An environment variable the executor receives, backed by a secret, gated by rules. |
| `proxy.env[].in`                    | Where the value may appear: `header`, `env`, and so on.                            |

Full detail, including the interception model and response scrubbing, is in
[Credentials](/credentials/).

### Environment

| Variable                              | Default                                                    | Purpose                                                                                                   |
| ------------------------------------- | ---------------------------------------------------------- | --------------------------------------------------------------------------------------------------------- |
| `Q15_IMAGE_TAG`                       | `stable`                                                   | The release tag applied to all four q15 images.                                                           |
| `Q15_WEB_ORIGIN`                      | required                                                   | Exact public HTTPS origin, no trailing slash. Bound to passkeys.                                          |
| `Q15_WEB_LISTEN`                      | `127.0.0.1:8080`                                           | Listen address inside the container. The deployment file sets `0.0.0.0:8080` so the published port works. |
| `Q15_WEB_BRIDGE`                      | `unix:///run/q15/bridge.sock`                              | The agent bridge the web tier dials.                                                                      |
| `Q15_WEB_STATE_DIR`                   | `/var/lib/q15-web`                                         | Credential and session state.                                                                             |
| `Q15_WEB_TLS_CERT`, `Q15_WEB_TLS_KEY` | unset                                                      | Optional direct TLS; both or neither.                                                                     |
| `Q15_WEB_DIR`                         | unset                                                      | Serve a development bundle from a directory instead of the embedded one.                                  |
| `TEI_IMAGE`                           | `ghcr.io/huggingface/text-embeddings-inference:turing-1.9` | The embedding server image.                                                                               |
| `TEI_MODEL`                           | `Qwen/Qwen3-Embedding-0.6B`                                | The model the container serves.                                                                           |
| `TEI_SERVED_MODEL_NAME`               | `qwen3-embedding-0.6b`                                     | Must match `model` in `agent-config.yaml`.                                                                |
| `TEI_MAX_CLIENT_BATCH_SIZE`           | 128                                                        | Must be at least the config's `batch_size`.                                                               |

### The secret contract

Every binary reads a secret from `NAME`, and failing that from the file named by `NAME_FILE`. The file's
contents are trimmed, and an empty file is an error rather than an empty secret. That one rule is how
`secrets/github_token` reaches a container as `GITHUB_TOKEN_FILE=/run/q15-secrets/github_token`.

The tracked templates are:

```
secrets/brave_api_key            secrets/ollama_api_key
secrets/gemini_api_key           secrets/q15_telegram_allowed_user_ids
secrets/github_token             secrets/q15_telegram_token
secrets/moonshot_api_key         secrets/zai_api_key
auth/auth.json.example
```

They are templates: `make compose-secrets-init` copies each to its real name, and the real files are
git-ignored. See [Credentials](/credentials/) for what each one is used for.

## Commands

| Command                                       | Status        | Today                                                                    |
| --------------------------------------------- | ------------- | ------------------------------------------------------------------------ |
| `q15 init`                                    | **not built** | Write `agent-config.yaml`, `proxy-policy.yaml` and `secrets/` by hand.   |
| `q15 up`                                      | **not built** | `podman compose … up -d --wait`                                          |
| `q15 status`                                  | **not built** | `podman compose … ps`                                                    |
| `q15 logs -f <service>`                       | **not built** | `podman compose … logs -f q15-agent`                                     |
| `q15 doctor`                                  | **not built** | Container health checks plus the log lines that name the failing input.  |
| `q15 secret set`                              | **not built** | Write the file into `secrets/` and recreate the container that reads it. |
| `q15-web auth enroll\|list\|revoke DEVICE_ID` | built         | `podman compose exec q15-web /usr/local/bin/q15-web auth …`              |
| `q15-auth login\|status\|logout`              | built         | Run on the host against the `auth/` directory.                           |

## Releases

A release is one set of four images, always published together:

| Tag                       | Meaning                                                                                                     |
| ------------------------- | ----------------------------------------------------------------------------------------------------------- |
| `stable`                  | A moving tag. It advances on all four packages only after the same release is verified on all four.         |
| `YYYY.MM.DD.<run-number>` | One immutable release, for example `2026.10.10.84`. The same tag always selects one compatible set of four. |

Published tags are on the [q15 packages page](https://github.com/orgs/q15co/packages), and the images
pull without registry authentication. Pin an immutable tag if you want control over when your stack
moves. See [Updating](/updating/) for what does not roll back.

## Development stack

```bash
make project-setup      # the pinned toolchain, under ./.tools
make compose-up         # builds from the working tree and starts with --wait
make compose-ps
make compose-logs SERVICE=q15-agent
make compose-down
make verify             # lint, tests, contract checks
make build              # agent, auth, exec, proxy and web binaries into ./bin
make ui-dev             # the browser client against a local API
make ui-test            # browser unit tests
```

This stack builds from source and follows the moving `:main` tag. Use it to work on q15; use the
deployment stack to run it.
