deploy/compose.yaml defines project "mealquest": the mealquest service built
from the root Dockerfile, running as 10001:10001, configured from deploy/.env
and Docker secrets in deploy/secrets/, with the mealquest-data volume at
/data, published on ${MEALQUEST_BIND_ADDRESS}:8090, a /health healthcheck and
restart unless-stopped, on the private mealquest network.
deploy/.env.example documents every MEALQUEST_* variable: the ones operators
set, the ones fixed by compose.yaml, and the planned ones not yet read.
.gitignore keeps deploy/.env and deploy/secrets/ out while tracking the
example.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-05 07:53:43 -04:00
9 changed files with 0 additions and 1047 deletions
The first deploy ran before Feature 07 reached `main`. The checkout is at
`main``9cddbbb` and the Feature 07 files under `deploy/` were copied in
from the feature branch: `deploy/compose.yaml` and `deploy/.env.example`
are listed in `.git/info/exclude`, the rest show as untracked. Before the
first routine deploy of a `main` that contains `deploy/`, remove those
`.git/info/exclude` lines and the copied files (keep `deploy/.env` and
`deploy/secrets/`), or `git checkout` refuses to overwrite them:
```sh
cd /home/overseer/mealquest
git status --porcelain --untracked-files=all deploy # the copied files
sed -i '/^deploy\/\(compose.yaml\|.env.example\)$/d' .git/info/exclude
git clean -n deploy # dry run: must not list .env or secrets/ (both git-ignored)
git clean -f deploy
```
Then follow "Routine deploy"; the checkout brings the tracked copies back.
## Authentik
Sign-in is OpenID Connect against Authentik at
<https://auth.cubecraftlabs.com>.
| Object | Setting |
| --- | --- |
| Provider | OAuth2/OpenID provider `mealquest`, client type **Confidential** |
| Redirect URI | `https://mealquest.cubecraftlabs.com/auth/callback` (strict) |
| Signing | RS256, with the instance's signing key |
| Scopes | MealQuest requests `openid profile email groups offline_access`; the provider's scope mappings include openid, profile, email and offline_access, and the `groups` claim is delivered by a property mapping (the default profile mapping carries `groups`) |
| Application | name MealQuest, slug `mealquest`, provider `mealquest` |
| Group | `mealquest-admin`, bound to the application; household members are added to it |
| Client ID | `i6zraqmWsF552b8nmte070LXz0SI5pRdArBxFxeq` (not a secret; in `deploy/.env` as `MEALQUEST_OIDC_CLIENT_ID`) |
| Client Secret | in `deploy/secrets/oidc_client_secret` only |
MealQuest refuses a sign-in unless the user's `groups` claim (from the ID
token, or from userinfo when the token has none) contains a group in
`MEALQUEST_OIDC_ALLOWED_GROUPS`.
Check discovery from the Docker host — the service discovers the issuer
| Cloudflare, zone `cubecraftlabs.com` | `CNAME mealquest.cubecraftlabs.com` → `cubecraftlabs.com`, **proxied = true** (orange cloud). Because it is proxied, public resolvers answer with Cloudflare's anycast addresses, A and AAAA (AAAA on 2026-10-05: `2606:4700:3032::6815:34ed`, `2606:4700:3035::ac43:cd75`), never the origin |
| Pi-hole primary 10.60.1.101 (`dns.ccl`), Local DNS records | `A mealquest.cubecraftlabs.com` → `10.60.1.33` (Traefik) |
| Pi-hole secondary 10.60.1.85 (`dns2.ccl`), Local DNS records | `A mealquest.cubecraftlabs.com` → `10.60.1.33` (Traefik) |
The local record must be on **both** Pi-holes, or clients using the
secondary go out through Cloudflare. Check:
```sh
for s in 10.60.1.101 10.60.1.85;do dig +short @$s mealquest.cubecraftlabs.com A;done
# 10.60.1.33 from each
```
**AAAA caveat.** The Pi-hole local record is A only, so both Pi-holes still
forward AAAA queries and return Cloudflare's IPv6 addresses
(`2606:4700:3032::6815:34ed`, `2606:4700:3035::ac43:cd75` on 2026-10-05). An
IPv6-capable LAN client may prefer those and reach MealQuest through
Cloudflare instead of directly through Traefik. It still works (Cloudflare
proxies to the public route), but it is not the LAN path. If that matters,
add a local AAAA override or block AAAA for this name on both Pi-holes.
## Host firewall
Docker publishes `10.60.1.14:8090` past the host's INPUT rules, so the
port is restricted in the `DOCKER-USER` chain instead: only Traefik
(10.60.1.33) and the host itself (10.60.1.14) may connect; every other
source is dropped.
Docker DNATs the published port to the container address and port 8080
**before**`DOCKER-USER`, so a plain `-p tcp --dport 8090` rule never
matches. The rules match the original destination `10.60.1.14:8090` with
To restore to a point in time, add `-timestamp 2026-10-05T23:00:00Z` (or
`-txid`) to the `restore` command.
### Count check
`/tmp/mqcount.py` prints row counts for key tables, the number of
application tables, a SHA-256 over every application row (first 16 hex
digits; Litestream's own `_litestream*` tables are excluded) and
`PRAGMA integrity_check`. Run it on a **copy** of the database, never on the
live file; copy `-wal`/`-shm` along with it when the app is running.
```python
import sqlite3, sys, hashlib
c = sqlite3.connect(sys.argv[1])
tabs = [r[0] for r in c.execute("select name from sqlite_master where type=\"table\" and name not like \"sqlite_%\" and name not like \"_litestream%\" order by name")]
for t in ("dinners", "week_plans", "schema_migrations"):
print(t, c.execute(f"select count(*) from {t}").fetchone()[0])
h = hashlib.sha256()
for t in tabs:
for row in c.execute(f"select * from \"{t}\" order by rowid"):
The first drill earlier the same day (before `-level all` was recorded)
gave the same before/after result: checksum `836e230e16c58dc7`, 26 tables,
integrity ok.
### Media backup checks — 2026-10-05
`sudo systemctl start mealquest-media-backup.service` on 10.60.1.14:
| Case | Result |
| --- | --- |
| Normal run | `Result=success`; journal: `synced /run/mealquest-data/media to joshua@10.60.1.50:/volume1/homes/Joshua/mealquest-backups/media/ as uid 10001` |
| A test file added | `Number of regular files transferred: 1`; removing it then gave `Number of deleted files: 1 (reg: 1)` |
| `media` replaced by a symlink to `/etc` | `Result=exit-code`, status 1; `/run/mealquest-data/media is a symlink to /etc; refusing to back it up` |
| `media` removed | `Result=exit-code`, status 1; `media directory /run/mealquest-data/media is missing` |
| `sudo -u mealquest` writing into the volume via `/var/lib/docker/...` | `Permission denied` — uid 10001 cannot traverse `/var/lib/docker` (mode 0710 root) |
## Secret rotation
Rotate a secret when it may have leaked, when someone with access leaves,
or on the owner's schedule. Never paste a secret into a ticket, chat or
this file.
### App key (`deploy/secrets/secret_key`)
The app key encrypts every stored secret: the `_ct` columns
`ai_settings.api_key_ct` (LLM API key), `recipe_search_settings.api_key_ct`
(recipe-search API key), `integrations_google.refresh_token_ct` (Google
Calendar refresh token) and `chat_settings.webhook_url_ct` (Google Chat
webhook URL). **Rotating it re-encrypts nothing**: a value encrypted with
the old key can no longer be read. A `mealquest rotate-key` helper that
re-encrypts in place is planned but does not exist yet.
**What is recoverable today.** The service currently exposes only
household settings; no shipped flow writes or re-enters any of these four
secrets. The re-entry flows are planned: the LLM key in Settings → AI
assistant (the AI settings feature), the recipe-search key (Feature 10),
Google Calendar reconnect (Feature 16) and the Google Chat webhook
(Feature 19). So today the rotation is safe **only if no `_ct` value is
stored**, which the pre-check below proves. If the pre-check finds a
stored value, do not rotate: it could not be re-entered afterwards. Keep
the current key and wait for the matching flow (or the `rotate-key`
helper).
**The pre-check and the key replacement run with the whole stack
stopped**, from the pre-check until the new key is in place. A raw copy of
`mealquest.db` and `mealquest.db-wal` taken while the stack runs is **not**
a consistent snapshot: the two files are copied one after the other, and a
checkpoint (by the app or by Litestream) between the two copies can move
committed pages out of the WAL into `mealquest.db` after `mealquest.db` was
already copied, then reset the WAL before it is copied. The copy then
lacks those commits — possibly a stored `_ct` value — and the pre-check
reports a false 0. A running app could also store a `_ct` value after a
correct pre-check and before the key is replaced. Stopping both containers
removes every writer (Litestream also writes and checkpoints), and the
clean close checkpoints the WAL into `mealquest.db`, so the file alone is
complete.
Run from `/home/overseer/mealquest`:
```sh
D=/var/lib/docker/volumes/mealquest-data/_data
# 1. Stop the whole stack (app and Litestream) and leave it stopped until
# step 4. Nothing may hold the database open.
docker compose -f deploy/compose.yaml down
sudo ls -la "$D" # mealquest.db and media/ only: no -wal, no -shm; if
# either exists, the close was not clean — stop here
# 2. Pre-check, on a copy of the now-quiescent database.
# Nightly trigger for mealquest-media-backup.service. Persistent= runs a
# missed backup at the next boot.
[Unit]
Description=Nightly MealQuest media backup
[Timer]
OnCalendar=*-*-* 03:30:00
Persistent=true
[Install]
WantedBy=timers.target
Reference in New Issue
Block a user
Blocking a user prevents them from interacting with repositories, such as opening or commenting on pull requests or issues. Learn more about blocking a user.