diff --git a/.dockerignore b/.dockerignore
new file mode 100644
index 0000000..ef1e1fa
--- /dev/null
+++ b/.dockerignore
@@ -0,0 +1,18 @@
+# Whitelist build-context: only `server/` and `common/` go into the image.
+# Cargo's path dep `server/Cargo.toml` -> `path = "../common"` requires
+# both directories side by side, so we must include `common/` explicitly.
+*
+!server/
+!common/
+
+# Re-exclude artifacts inside the whitelisted dirs.
+server/target/
+common/target/
+**/*.log
+**/*.bk
+
+# Secrets and operator-owned configs MUST NEVER end up in the image —
+# they are bind-mounted at runtime from /opt/stacks/doctate-server/config/.
+server/.env
+server/users.toml
+server/settings.toml
diff --git a/deploy/doctate-server.compose.yml b/deploy/doctate-server.compose.yml
new file mode 100644
index 0000000..0f0dd12
--- /dev/null
+++ b/deploy/doctate-server.compose.yml
@@ -0,0 +1,47 @@
+# Compose file for the doctate-server container on minerva.
+# Operator config + persistent data live under /opt/stacks/doctate-server/
+# (Dockge convention). The image itself is self-contained except at the
+# bind-mount points listed under `volumes:`.
+#
+# `network_mode: host` means the container binds directly on minerva's
+# network namespace. Settings.toml URLs like http://minerva.lan:9001
+# resolve identically inside and outside the container — no Docker DNS
+# magic, no port mapping.
+
+services:
+ doctate-server:
+ image: doctate-server:latest
+ container_name: doctate-server
+ network_mode: host
+ restart: unless-stopped
+ working_dir: /app
+ environment:
+ # Required — server panics on boot if missing (see config.rs).
+ USERS_FILE: /app/users.toml
+ SERVER_PORT: "3000"
+ DATA_PATH: /data
+ # Optional — explicit values to avoid silent defaults.
+ SETTINGS_FILE: /app/settings.toml
+ LOG_PATH: /var/log/recorder
+ LOG_LEVEL: info
+ LOG_MAX_DAYS: "90"
+ VOCAB_DIR: /app/vocab
+ HUNSPELL_DICT: /usr/share/hunspell/de_DE.dic
+ SESSION_TIMEOUT_HOURS: "8"
+ # false because no TLS reverse-proxy is in front of this service yet.
+ # Browsers refuse Secure cookies on plain HTTP — set to true once a
+ # TLS proxy (e.g. nginx on the Unraid box) terminates in front of
+ # this container. Override via /opt/stacks/doctate-server/config/.env.
+ COOKIE_SECURE: "false"
+ ASR_BACKEND: whisper
+ volumes:
+ # Operator-owned configs — read-only, atomically edited on host.
+ - /opt/stacks/doctate-server/config/.env:/app/.env:ro
+ - /opt/stacks/doctate-server/config/settings.toml:/app/settings.toml:ro
+ - /opt/stacks/doctate-server/config/users.toml:/app/users.toml:ro
+ # Persistent data — container writes here.
+ - /opt/stacks/doctate-server/data:/data
+ - /opt/stacks/doctate-server/logs:/var/log/recorder
+ # Optional vocab override — uncomment if a curated list is maintained
+ # on minerva that should win over the bundled /app/vocab/*.txt.
+ # - /opt/stacks/doctate-server/config/vocab:/app/vocab:ro
diff --git a/docs/deployment.md b/docs/deployment.md
new file mode 100644
index 0000000..cc42530
--- /dev/null
+++ b/docs/deployment.md
@@ -0,0 +1,155 @@
+# Deployment
+
+The doctate-server runs in two flavors that can coexist:
+
+- **Local dev** — `cargo run -p doctate-server` (or `scripts/run-server.sh`),
+ reads `server/.env` + `server/settings.toml` + `server/users.toml` from
+ the repo working tree, writes to `tmpdata/`.
+- **Production on minerva** — Docker container, reads operator-owned
+ configs from `/opt/stacks/doctate-server/config/`, writes to
+ `/opt/stacks/doctate-server/data/`.
+
+Both have independent configs, independent data directories, and
+listen on different hosts — no interference.
+
+## Architecture
+
+```mermaid
+graph LR
+ Dev[Dev laptop
scripts/deploy-server.sh] -->|ssh + scp| Host
+
+ subgraph Host[minerva.lan]
+ Stack["/opt/stacks/doctate-server/"]
+ Container["doctate-server container
network_mode: host
:3000"]
+
+ Stack -->|bind-mount :ro| Configs[("config/.env
config/settings.toml
config/users.toml")]
+ Stack -->|bind-mount :rw| Data[("data/
logs/")]
+
+ Configs -.->|read on boot| Container
+ Container -.->|writes recordings + logs| Data
+ end
+
+ Host -->|http :3000| nginx[nginx on Unraid
TLS termination]
+ nginx -->|https| Browser
+```
+
+## First deploy
+
+The script handles bootstrap idempotently. The first run will fail at
+phase 3 (placeholder guard) so you can fill in the configs.
+
+1. **Run the script** from the dev laptop:
+
+ ```bash
+ ./scripts/deploy-server.sh
+ ```
+
+ Phases 1–2 create `/opt/stacks/doctate-server/{config,data,logs}` and
+ seed the three config files from `server/*.example`. Phase 3 then
+ aborts with placeholder errors.
+
+2. **Edit configs on minerva** (via `nano` over SSH or through Dockge).
+ Required fields:
+
+ | File | Edit |
+ |---|---|
+ | `config/users.toml` | At least one `[[user]]` block with a real `api_key` and a real bcrypt hash for `web_password` |
+ | `config/settings.toml` | `[whisper] url`, `[ollama] url` for the production hosts |
+ | `config/.env` | Optionally `IONOS_API_KEY=…` if any LLM backend uses Ionos |
+
+ Generate the bcrypt hash inside the container — the image is already
+ loaded on minerva after phase 5 of run 1:
+
+ ```bash
+ ssh minerva.lan 'docker run --rm -i doctate-server:latest hash-password'
+ ```
+
+ Copy the resulting `$2b$12$…` line into `users.toml`.
+
+3. **Re-run the script**:
+
+ ```bash
+ ./scripts/deploy-server.sh
+ ```
+
+ This time it runs all phases, ending with a green health-check and a
+ rollback hint.
+
+## Re-deploy
+
+Once the configs are in place, every subsequent deploy is one command:
+
+```bash
+./scripts/deploy-server.sh
+```
+
+The script is idempotent. Phase 1 (`mkdir -p`) and phase 2 (template
+seeding) are no-ops on a populated host. Phase 3 (placeholder guard)
+passes. Phases 4–9 build, transfer, restart, and verify health.
+
+The image is tagged twice on minerva: as `doctate-server:latest` and
+as `doctate-server:`. The script keeps the **last 5
+SHA-tagged builds** automatically (phase 9) and prunes older ones plus
+their dangling layers, so disk usage stays bounded even after many
+deploys. Adjust `KEEP=5` near the bottom of `scripts/deploy-server.sh`
+if you want a different rollback window.
+
+## User management
+
+Adding or rotating a user means editing `users.toml` on minerva and
+restarting the container so the boot-time parser picks up the change:
+
+```bash
+ssh minerva.lan
+docker run --rm -i doctate-server:latest hash-password # silent prompt, prints hash
+nano /opt/stacks/doctate-server/config/users.toml # paste the hash
+docker compose -f /opt/stacks/doctate-server/compose.yaml restart doctate-server
+```
+
+The `users.toml` mount is read-only by design — the container is not
+allowed to modify the auth file. Edits happen on the host.
+
+## Rollback
+
+Each deploy leaves a `doctate-server:` tag behind on minerva, so
+rolling back to a previous build is one retag plus a restart:
+
+```bash
+ssh minerva.lan
+docker images doctate-server # list tags + dates
+docker tag doctate-server: doctate-server:latest
+cd /opt/stacks/doctate-server && docker compose up -d --force-recreate
+```
+
+Persistent data (`/opt/stacks/doctate-server/data/`) is unaffected by
+rollback — only the binary swaps.
+
+## Troubleshooting
+
+| Symptom | First check |
+|---|---|
+| Container restarts in a loop | `ssh minerva.lan 'docker logs --tail 80 doctate-server'` — most boot panics name the missing env-var |
+| `/api/health` doesn't respond | `ssh minerva.lan 'ss -tlnp \| grep :3000'` — is the server actually bound? |
+| Login refused on HTTPS | `COOKIE_SECURE` and TLS-proxy mismatch — see "TLS" section below |
+| File missing inside container | `docker compose -f /opt/stacks/doctate-server/compose.yaml exec doctate-server ls /app` |
+| Health-check status | `ssh minerva.lan 'docker inspect --format "{{.State.Health.Status}}" doctate-server'` |
+| Config edit not picked up | `docker compose ... restart doctate-server` — most config is read on boot, not per request |
+| Stale image tag on minerva | `docker images --filter dangling=true` — retag or `docker image prune` |
+
+## TLS / nginx reverse proxy
+
+The container ships with `COOKIE_SECURE=false` because the default
+deployment is plain HTTP between minerva:3000 and whatever sits in
+front of it. When nginx on the Unraid box terminates TLS and forwards
+to `http://minerva.lan:3000`, set the cookie flag accordingly so
+browsers actually accept the session cookie:
+
+```bash
+ssh minerva.lan
+echo 'COOKIE_SECURE=true' >> /opt/stacks/doctate-server/config/.env
+docker compose -f /opt/stacks/doctate-server/compose.yaml restart doctate-server
+```
+
+Without this override under a TLS proxy, the login flow looks like it
+works (`POST /api/login` returns 200) but the next request lacks the
+cookie and bounces back to the login page.
diff --git a/scripts/deploy-server.sh b/scripts/deploy-server.sh
new file mode 100755
index 0000000..7419531
--- /dev/null
+++ b/scripts/deploy-server.sh
@@ -0,0 +1,124 @@
+#!/usr/bin/env bash
+# Deploy doctate-server to a remote Docker host (default: minerva.lan).
+# Idempotent: re-runs preserve operator-owned config, data, logs.
+#
+# Usage: ./scripts/deploy-server.sh [host]
+# host default: minerva.lan
+#
+# See docs/deployment.md for first-time bootstrap.
+
+set -euo pipefail
+
+HOST="${1:-minerva.lan}"
+REMOTE="/opt/stacks/doctate-server"
+REPO_ROOT="$(cd "$(dirname "$0")/.." && pwd)"
+GIT_SHA="$(cd "$REPO_ROOT" && git rev-parse --short HEAD)"
+IMAGE_LATEST="doctate-server:latest"
+IMAGE_TAGGED="doctate-server:$GIT_SHA"
+
+cd "$REPO_ROOT"
+
+# ─── Phase 0 ─── local pre-flight
+echo ">>> [0/10] cargo fmt --check (server)"
+( cd server && cargo fmt --check )
+
+if ! git -C "$REPO_ROOT" diff --quiet || ! git -C "$REPO_ROOT" diff --cached --quiet; then
+ echo " WARNING: working tree is dirty — building from on-disk state, not from $GIT_SHA" >&2
+fi
+
+# ─── Phase 1 ─── remote bootstrap (idempotent)
+echo ">>> [1/10] Ensuring remote layout at $HOST:$REMOTE"
+ssh "$HOST" "mkdir -p $REMOTE/config $REMOTE/data $REMOTE/logs"
+
+# ─── Phase 2 ─── seed templates only if missing
+echo ">>> [2/10] Seeding config templates (only if missing)"
+for pair in \
+ ".env.example:.env" \
+ "settings.toml.example:settings.toml" \
+ "users.toml.example:users.toml"
+do
+ src="server/${pair%%:*}"
+ dst="${pair##*:}"
+ remote_path="$REMOTE/config/$dst"
+ if ssh "$HOST" "test -f $remote_path"; then
+ echo " KEEP $remote_path"
+ else
+ echo " SEED $remote_path <- $src"
+ scp -q "$src" "$HOST:$remote_path"
+ fi
+done
+
+# ─── Phase 3 ─── placeholder guard
+echo ">>> [3/10] Verifying configs have been edited from defaults"
+if ssh "$HOST" "grep -qE '\\\$2b\\\$12\\\$\\.\\.\\.|change-me-to-a-secure-key' $REMOTE/config/users.toml" 2>/dev/null; then
+ cat >&2 <>> [4/10] Building $IMAGE_TAGGED + $IMAGE_LATEST"
+DOCKER_BUILDKIT=1 docker build \
+ -f server/Dockerfile \
+ -t "$IMAGE_TAGGED" \
+ -t "$IMAGE_LATEST" \
+ .
+
+# ─── Phase 5 ─── stream image to host
+echo ">>> [5/10] Transferring image to $HOST (gzip-streamed via ssh)"
+docker save "$IMAGE_TAGGED" "$IMAGE_LATEST" | gzip | ssh "$HOST" 'gunzip | docker load'
+
+# ─── Phase 6 ─── compose file sync
+echo ">>> [6/10] Syncing compose file"
+scp -q deploy/doctate-server.compose.yml "$HOST:$REMOTE/compose.yaml"
+
+# ─── Phase 7 ─── restart
+echo ">>> [7/10] (Re)starting container"
+ssh "$HOST" "cd $REMOTE && docker compose up -d --force-recreate"
+
+# ─── Phase 8 ─── health poll
+echo ">>> [8/10] Waiting for /api/health"
+for i in $(seq 1 20); do
+ if curl -fsS "http://$HOST:3000/api/health" >/dev/null 2>&1; then
+ echo " OK after ${i} attempt(s)"
+ # ─── Phase 9 ─── prune old image tags (keep last KEEP + :latest)
+ # Containers hold images via internal IDs, not tags, so removing
+ # an old -tag never affects the running container as long as
+ # :latest still points to it.
+ KEEP=5
+ echo ">>> [9/10] Pruning old image tags on $HOST (keep last $KEEP)"
+ ssh "$HOST" "
+ set -e
+ docker images doctate-server --format '{{.CreatedAt}}\t{{.Tag}}' | \
+ grep -v -P '\\tlatest\$' | \
+ sort -r | \
+ awk 'NR > $KEEP { print \$NF }' | \
+ xargs -I{} -r docker rmi doctate-server:{} 2>/dev/null || true
+ docker image prune -f >/dev/null 2>&1 || true
+ "
+ # ─── Phase 10 ─── final echo
+ echo ">>> [10/10] Deployed $GIT_SHA"
+ echo
+ echo "Rollback:"
+ echo " ssh $HOST 'docker tag doctate-server: $IMAGE_LATEST && cd $REMOTE && docker compose up -d --force-recreate'"
+ exit 0
+ fi
+ sleep 3
+done
+
+# health timeout
+echo ">>> TIMEOUT (60s) — last 50 lines of container logs:" >&2
+ssh "$HOST" "docker logs --tail 50 doctate-server" >&2 || true
+exit 1
diff --git a/server/Dockerfile b/server/Dockerfile
new file mode 100644
index 0000000..3f28abc
--- /dev/null
+++ b/server/Dockerfile
@@ -0,0 +1,63 @@
+# syntax=docker/dockerfile:1.7
+# Multi-stage build for doctate-server. Stage 1 compiles the Rust binary;
+# stage 2 ships only the binary + runtime tools (ffmpeg, hunspell dict).
+
+# -----------------------------------------------------------------------
+# Stage 1: builder
+# -----------------------------------------------------------------------
+FROM rust:1.94-slim-bookworm AS builder
+
+# pkg-config + ca-certificates suffice — reqwest uses rustls-tls
+# (no libssl-dev needed), and our other crates don't probe native libs.
+RUN apt-get update && apt-get install -y --no-install-recommends \
+ pkg-config ca-certificates && \
+ rm -rf /var/lib/apt/lists/*
+
+WORKDIR /build
+# Copy `common/` AND `server/` so the path dependency resolves
+# (server/Cargo.toml references `../common`).
+COPY common/ ./common/
+COPY server/ ./server/
+
+WORKDIR /build/server
+# BuildKit cache mounts: registry/ and target/ persist between builds
+# but are NOT part of the resulting image layers. Output binaries
+# therefore have to be copied to /out (outside the cache mount) so
+# stage 2 can pick them up via COPY --from=builder.
+RUN --mount=type=cache,target=/usr/local/cargo/registry \
+ --mount=type=cache,target=/build/server/target \
+ cargo build --release --bin doctate-server --bin hash-password && \
+ mkdir -p /out && \
+ cp target/release/doctate-server /out/ && \
+ cp target/release/hash-password /out/
+
+# -----------------------------------------------------------------------
+# Stage 2: runtime
+# -----------------------------------------------------------------------
+FROM debian:bookworm-slim AS runtime
+
+# ffmpeg/ffprobe are invoked as subprocesses by server/src/transcribe/ffmpeg.rs.
+# hunspell-de-de installs the dict files /usr/share/hunspell/de_DE.{dic,aff}
+# which the `spellbook` Rust library reads via the HUNSPELL_DICT env var.
+# tzdata gives us proper local-time formatting in logs.
+# curl is required by HEALTHCHECK below.
+RUN apt-get update && apt-get install -y --no-install-recommends \
+ ffmpeg hunspell-de-de ca-certificates curl tzdata && \
+ rm -rf /var/lib/apt/lists/*
+
+WORKDIR /app
+
+COPY --from=builder /out/doctate-server /usr/local/bin/
+COPY --from=builder /out/hash-password /usr/local/bin/
+
+# Default vocab ships with the image; operator can override via bind-mount.
+COPY server/vocab/ /app/vocab/
+
+EXPOSE 3000
+
+# Container-level health probe. SERVER_PORT defaults to 3000 if unset
+# (matches the value in server/.env.example).
+HEALTHCHECK --interval=30s --timeout=5s --start-period=10s --retries=3 \
+ CMD curl -fsS "http://127.0.0.1:${SERVER_PORT:-3000}/api/health" || exit 1
+
+CMD ["doctate-server"]