feat: containerize doctate-server for minerva deployment
Multi-stage Dockerfile, host-network compose, idempotent deploy script. Configs and persistent data bind-mounted from /opt/stacks/doctate-server. deploy-server.sh handles bootstrap + re-deploy in one command with auto-prune (keep 5 last SHA-tagged images). See docs/deployment.md for bootstrap, rollback, and TLS-proxy notes.
This commit is contained in:
@@ -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
|
||||||
@@ -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
|
||||||
@@ -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<br/>scripts/deploy-server.sh] -->|ssh + scp| Host
|
||||||
|
|
||||||
|
subgraph Host[minerva.lan]
|
||||||
|
Stack["/opt/stacks/doctate-server/"]
|
||||||
|
Container["doctate-server container<br/>network_mode: host<br/>:3000"]
|
||||||
|
|
||||||
|
Stack -->|bind-mount :ro| Configs[("config/.env<br/>config/settings.toml<br/>config/users.toml")]
|
||||||
|
Stack -->|bind-mount :rw| Data[("data/<br/>logs/")]
|
||||||
|
|
||||||
|
Configs -.->|read on boot| Container
|
||||||
|
Container -.->|writes recordings + logs| Data
|
||||||
|
end
|
||||||
|
|
||||||
|
Host -->|http :3000| nginx[nginx on Unraid<br/>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:<git-short-sha>`. 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:<sha>` 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:<previous-sha> 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.
|
||||||
Executable
+124
@@ -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 <<EOF
|
||||||
|
|
||||||
|
ERROR: $REMOTE/config/users.toml still has placeholder values.
|
||||||
|
|
||||||
|
Edit it on $HOST (e.g. via nano or Dockge) and replace:
|
||||||
|
- '\$2b\$12\$...' with a real bcrypt hash
|
||||||
|
- 'change-me-to-a-secure-key' with a real api_key
|
||||||
|
|
||||||
|
For the bcrypt hash, the image is already loaded on $HOST (after the
|
||||||
|
first run reaches phase 5); generate one with:
|
||||||
|
docker run --rm -i $IMAGE_LATEST hash-password
|
||||||
|
|
||||||
|
Then re-run this script. See docs/deployment.md "First deploy" section.
|
||||||
|
EOF
|
||||||
|
exit 1
|
||||||
|
fi
|
||||||
|
|
||||||
|
# ─── Phase 4 ─── build
|
||||||
|
echo ">>> [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 <sha>-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:<prev-sha> $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
|
||||||
@@ -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"]
|
||||||
Reference in New Issue
Block a user