duo-streak-daemon: the stack declared in Nix
Edit on GitHubmodules/nixos/services/duo.nix. The app's stack (a Playwright daemon + API + web + Postgres) through
Docker Compose, declared in Nix, brought up by systemd 2 minutes after boot (a timer, so the build
never races the desktop: see grad-radar.md). The solver is the host's NATIVE
Ollama (modules/nixos/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 Passwordrequired; generate a strong one Gemini API Keyoptional, a solver fallback ntfy Topicthe streak-at-risk alert, a shared topic Duolingooptional, the account password (login fallback) Duolingo Emailthe 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 .#ex-b560m-v5.
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.