duo-streak-daemon: the stack declared in Nix¶
system/services/duo.nix. The app's stack (a Playwright daemon + API + web + Postgres) through
Docker Compose, declared in Nix, brought up at boot by systemd. The solver is the host's NATIVE
Ollama (system/services/ollama.nix), reached on localhost:11434 through network_mode: host,
exactly as the app was designed.
Declarative end to end¶
- The code is the
duo-streak-daemonflake input, a commit pinned inflake.lock. The compose builds straight from the store path: no mutable clone, no manual build. - The secrets are a sops template rendered into
/run/secrets/rendered/duo.env: never in plain text in git, never in/nix/store. - The compose manifest itself is generated here (
writeText), because the repo ships the DEV one and this is the DEPLOY one: contexts are store paths and the secrets arrive throughenv_file. - A FIXED project name
-p duo. Without it the project name would become the store path's hash and the volumes and containers would change on every bump.
The auto-gate¶
The module only activates when the duo_db_password secret exists. Until it is provisioned it
stays INERT and the system keeps building normally. Same pattern as caddy.nix.
Turning it on, once¶
- Bitwarden, create the items. The VALUE always goes in the password field, because
sync-secretsusesbw get password:
| Item | Purpose |
|---|---|
Duo DB Password |
required; generate a strong one |
Gemini API Key |
optional, a solver fallback |
ntfy Topic |
the streak-at-risk alert, a shared topic |
Duolingo |
optional, the account password (login fallback) |
Duolingo Email |
the fallback's pair, in the password field |
- Add the corresponding lines to
secrets/bitwarden-secrets.json:
"duo_db_password": "Duo DB Password",
"gemini_api_key": "Gemini API Key",
"ntfy_topic": "ntfy Topic",
"duolingo_password": "Duolingo",
"duolingo_username": "Duolingo Email"
sync-secrets, thensudo nixos-rebuild switch --flake .#nixos-kingston.
The Duolingo session¶
A one-time interactive login, because the anti-bot does not like a headless one. duo-login opens
the browser through Xwayland, you sign in, and the session persists in the duo_duo-data volume.
After that the daemon keeps the streak alive on its own, once a day. To run it right away:
duo-run-once (it ignores the "it already ran today").
Status as of writing: the login is still REJECTED, the Duolingo password needs confirming.
The Docker traps this module discovered¶
These three cost real debugging and were later inherited by grad-radar.nix:
- The socket-activation race.
after = docker.serviceis not enough: dockerd comes up through socket activation and BuildKit is still initializing on the first boot. Hence the wait-for-docker infoloop. - buildx is not discovered without a writable
DOCKER_CONFIG. The service's root does not find the plugin andcompose buildsilently falls back to the LEGACY builder, which does not supportRUN --mount=type=cache. That is the error that blocked everything. The fix is/run/duo/cli-pluginswith the plugins symlinked in, plusdaemon.settings.features.buildkit = true. - It has to be
docker compose, the PLUGIN. Only the plugin routes to buildx/BuildKit; the standalone binary does not.
TimeoutStartSec = 1800 because the first start builds three images (Playwright, Next.js).
Two details that look like noise¶
sops.templates."duo.env".owner = "v1cferr": the service runs as root and reads everything, butduo-loginruns as the user and needs to READ the file for its--env-file.restartTriggers: changing the compose OR the STRUCTURE of the.envmakes the switch restart the service, andup -drecreates the affected containers. A change in the VALUE of a secret alone does NOT change the hash, so in that case you needsystemctl restart duo-stack.