Skip to content

Configuration

Every environment variable Almanac reads, grouped by subsystem. .env.example carries the same set with inline commentary; this page is the reference.

Local dev reads .env directly. In production, docker-compose.yml forwards the relevant variables from the host .env into each container. Several are pinned by Compose rather than taken from your file, and those are called out below.

Core

Read by the API. ALMANAC_API_URL is read by the MCP server.

VariablePurposeRequired when
ALMANAC_DB_PATHSQLite file location. Defaults to ./data/almanac.sqlite, relative to the API package. Set it explicitly when running outside up.sh.always
ALMANAC_API_PORT / ALMANAC_API_HOSTWhere the API listens. Defaults 3001 and 127.0.0.1.always
ALMANAC_API_URLBase URL the MCP server uses to reach the API. Defaults http://127.0.0.1:3001.always
ALMANAC_TRUST_PROXY_HEADERSAPI trusts X-Forwarded-Email from oauth2-proxy. Defaults false; must be the literal true to enable that auth path.behind a proxy
ALMANAC_ALLOWED_EMAILSEmail allowlist: a file path (one email per line) or a comma-separated list. Shared by the API and the MCP server. Empty means any authenticated email is allowed.production
ALMANAC_WEB_PORTPort the Vite dev server binds. Defaults 5173. Set it when running two stacks side by side.local dev
ALMANAC_DEV_EMAILEmail the Vite dev proxy injects as X-Forwarded-Email. The API auto-provisions this user.local dev
ALMANAC_LOG_LEVELPino level override: one of fatal, error, warn, info, debug, trace, silent. Unset uses debug in dev, info in production.optional

Compose already pins three of these

On the almanac-api service, docker-compose.yml hardcodes ALMANAC_TRUST_PROXY_HEADERS=true and ALMANAC_ALLOWED_EMAILS=/emails/allowed-users.txt, and passes ALMANAC_LOG_LEVEL through from your host .env. Setting the first two in .env has no effect in production.

The allowlist is enforced at three independent layers, all reading the same file. See Authentication → The allowlist is enforced three times.

MCP

Read by the MCP server.

VariablePurposeRequired when
ALMANAC_MCP_TRANSPORTstdio, http, or sse (legacy, deprecated, emits a startup warning). Defaults to http.always
ALMANAC_MCP_PORT / ALMANAC_MCP_HOSTWhere the MCP server listens. HTTP and SSE only; ignored under stdio. Defaults 3030 and 127.0.0.1.http/sse
ALMANAC_MCP_CLIENT_TOKENStatic PAT the MCP process uses for its own API calls. Consumed only under stdio, which has no incoming request to read a bearer from, so this is the process's identity. Boot fails if it's missing under stdio.stdio

Under http and sse, each client connection brings its own bearer in the Authorization header, validated by the API against personal_access_tokens. ALMANAC_MCP_CLIENT_TOKEN is unread in those modes.

Compose pins the MCP service to ALMANAC_MCP_TRANSPORT=http, ALMANAC_MCP_HOST=0.0.0.0, ALMANAC_MCP_PORT=3030, and ALMANAC_API_URL=http://almanac-api:3001.

OAuth 2.1 (MCP + browser SSO)

The stack defaults to Google as the SSO provider, so the examples below use Google values, but nothing here is Google-specific. Browser SSO takes any provider oauth2-proxy supports, and the MCP OAuth server takes any OpenID Connect issuer. See Authentication → Using a different provider.

VariablePurposeRequired when
OAUTH2_PROXY_PROVIDERoauth2-proxy provider for browser SSO. Defaults to google. github needs no issuer URL; oidc (or keycloak-oidc) does.optional
OAUTH2_PROXY_OIDC_ISSUER_URLIssuer URL when OAUTH2_PROXY_PROVIDER=oidc. Ignored by google and github, and safe to leave blank for them.oidc provider
OAUTH2_PROXY_SKIP_PROVIDER_BUTTONSkip oauth2-proxy's "Sign in with X" interstitial and go straight to the provider. Defaults to true, since Almanac only ever configures one provider and the page is just an extra click. Set false to restore it.optional
OAUTH2_PROXY_BACKEND_LOGOUT_URLProvider endpoint to call on sign-out, ending the provider's session as well as Almanac's. Without it, signing out leaves the provider session intact and the next request signs you back in. Google publishes no such endpoint; for others use end_session_endpoint from the issuer's discovery document, with {id_token} as the token placeholder.optional
OAUTH2_PROXY_CLIENT_IDOAuth client ID for browser SSO. Also the default for the MCP OAuth client.production
OAUTH2_PROXY_CLIENT_SECRETOAuth client secret for browser SSOproduction
OAUTH2_PROXY_COOKIE_SECREToauth2-proxy session cookie encryption key. Generate fresh; never reuse one across deployments.production
OAUTH2_PROXY_REDIRECT_URLoauth2-proxy callback URL (https://<domain>/oauth2/callback)production
ALMANAC_MCP_OIDC_ISSUEROIDC issuer the MCP OAuth server delegates sign-in to, e.g. https://accounts.google.com. Discovery supplies the authorization, token and JWKS endpoints.MCP OAuth mode
ALMANAC_MCP_OAUTH_CALLBACK_PATHPath the provider redirects back to, appended to ALMANAC_MCP_PUBLIC_URL. Defaults to /oauth/callback.optional
ALMANAC_MCP_OAUTH_CLIENT_IDClient ID for the MCP OAuth flow. Compose defaults it to ${OAUTH2_PROXY_CLIENT_ID}.MCP OAuth mode
ALMANAC_MCP_OAUTH_CLIENT_SECRETClient secret for the MCP OAuth flow. Compose defaults it to ${OAUTH2_PROXY_CLIENT_SECRET}.MCP OAuth mode
ALMANAC_MCP_PUBLIC_URLPublic origin for the MCP OAuth issuer (https://<domain>), also used to build the provider redirect URIMCP OAuth mode

Leave the four ALMANAC_MCP_OIDC_ISSUER / ALMANAC_MCP_OAUTH_CLIENT_ID / ALMANAC_MCP_OAUTH_CLIENT_SECRET / ALMANAC_MCP_PUBLIC_URL values blank to run the MCP server in PAT-only mode: no OAuth discovery endpoints, manual token required. Blank is treated as unset rather than failing validation, which is what lets Compose pass ${…:-} safely.

Set all four, or none of them

Setting some but not all of those four is a boot error, not a silent fallback to PAT-only. It names the missing variables and refuses to start.

The issuer is validated at boot as a well-formed URL, but not fetched. The discovery document is retrieved lazily on first use and cached, so your identity provider does not have to be reachable when the container starts.

Two redirect URIs must be registered with the provider: one for oauth2-proxy's web SSO (/oauth2/callback) and one for the MCP OAuth flow (/oauth/callback). The walkthrough is in Authentication → Creating the Google OAuth client.

Deploy-only

Read by docker-compose.yml, not by application code.

VariablePurposeDefault
ALMANAC_IMAGE_OWNERGHCR namespace Compose pulls images from. Set this if you fork the repo and publish your own images.corcoran
ALMANAC_TAGImage tag to run. Pin it to a version to roll back to a specific release.latest

Rolling back with ALMANAC_TAG is covered in Operations → If a deploy goes wrong.

Watchtower auto-deploy notifications

Deploy-only and optional. The watchtower Compose service emails on container updates and errors via shoutrrr SMTP. Set WATCHTOWER_EMAIL_TO to turn notifications on; leave it blank and watchtower runs silently.

VariablePurposeDefault
WATCHTOWER_EMAIL_TORecipient. Blank turns notifications off.unset (silent)
WATCHTOWER_EMAIL_FROMSender addresswatchtower@almanac.example.com
WATCHTOWER_EMAIL_SERVERSMTP hostmail.example.com
WATCHTOWER_EMAIL_PORTSMTP port25
WATCHTOWER_EMAIL_HELOHELO/EHLO hostnamealmanac.example.com

WATCHTOWER_EMAIL_HELO needs a real FQDN

A strict postfix rejects shoutrrr's localhost default with 504 5.5.2 … need fully-qualified hostname. The Compose default supplies an FQDN, but it is an example domain: set your own.

Watchtower's own behavior flags (WATCHTOWER_LABEL_ENABLE, WATCHTOWER_CLEANUP, WATCHTOWER_POLL_INTERVAL, and the WATCHTOWER_NOTIFICATION* set) are hardcoded in docker-compose.yml and are not meant to be overridden from .env. See Operations → Auto-deploy (watchtower).

LLM / AI surfaces

Optional, and read by the API only. Both AI surfaces, the AI Meal Assistant and the AI insights coach, are gated behind the same ALMANAC_LLM_ENABLED switch (off by default), so they stay dark until explicitly turned on. The production docker-compose.yml already forwards these from the host .env to the almanac-api service.

The two surfaces use separate models: meal parsing is a cheap extraction task and stays on Haiku, while the coach does harder multi-signal reasoning and defaults to Sonnet.

Anthropic is currently the only supported provider. ALMANAC_LLM_PROVIDER exists as a seam and is validated at boot, but anthropic is the only accepted value: anything else fails fast rather than silently misbehaving. Adding another provider means implementing one branch behind that seam. It's a plausible future change, not something that works today.

VariablePurposeDefault
ALMANAC_LLM_ENABLEDMaster switch for both AI surfaces (meal chat + insights coach)false
ANTHROPIC_API_KEYAnthropic key. Without it both AI surfaces are hidden (llm_available=false).none
ALMANAC_LLM_PROVIDERProvider seam. Only anthropic is supported; any other value fails at boot.anthropic
ALMANAC_LLM_MODELModel for the meal assistant (the cheap parser)claude-haiku-4-5
ALMANAC_LLM_INSIGHTS_MODELModel for the insights coach, which does harder reasoning and gets a stronger defaultclaude-sonnet-4-6
ALMANAC_LLM_DEFAULT_DAILY_TOKEN_LIMITSoft daily token limit that drives the "~N logs left" indicator. Warns but never blocksunset (no soft limit)
ALMANAC_LLM_HARD_DAILY_TOKEN_CAPHard daily token ceiling, a 429 circuit-breakerunset (falls back to 1.5x the soft limit)
ALMANAC_LLM_TOKENS_PER_SEARCHFlat token charge per web search when there's no recent search history to average2500
ALMANAC_LLM_HARD_DAILY_SEARCH_CAPMax web searches per user-local day. At the cap, search is disabled for the turn but meals still log.unset (uncapped)

ALMANAC_LLM_PROVIDER has no effect under Compose

It is read by the application (packages/core/src/llm/config.ts) but docker-compose.yml does not forward it into the almanac-api container, so setting it in .env changes nothing on a Compose deployment. That is harmless today: anthropic is the only accepted value, and it is already the default. The variable becomes live if a second provider is ever implemented behind the seam.

What it costs

This runs on your own API key, so the AI surfaces cost real money per use. Two things keep that small. The cheaper model does the high-volume work, with meal parsing on Haiku and only the coach reaching for Sonnet, and the system prompts are split so the large stable part is served from Anthropic's 1-hour prompt cache instead of being re-billed on every turn.

In practice, dogfooding over a couple of months, an active user costs roughly 5–10¢ on a day they use it, and nothing on days they don't. Your mileage will vary with usage and current model pricing, so treat that as an order of magnitude, not a quote.

The guardrails

There are two token tiers, and they do different jobs:

TierSet byScopeWhat it does
Soft limitALMANAC_LLM_DEFAULT_DAILY_TOKEN_LIMIT, or per user with admin_set_user_soft_limitper userDrives the "~N logs left" counter and its amber warning. Never blocks.
Hard capALMANAC_LLM_HARD_DAILY_TOKEN_CAP, or per user with admin_set_user_hard_capper userReturns 429 once a user's own day passes it. The only thing that stops a chat.

Neither cap is a shared pool: a 75k ceiling gives every account its own 75k, yours included. When both an env cap and a per-user cap are set the effective ceiling is the lower of the two, so a per-user value can only tighten against the operator's. Set only one and that one applies.

Set neither and the ceiling is 1.5x the soft limit, so an install that never configures a cap still has a backstop. Only an account with no soft limit either is genuinely uncapped. An explicitly configured cap always wins over the derived one, including upward: granting somebody 200k against a 30k soft limit is a deliberate act, not something to clamp back to 45k.

The hard cap always sits strictly above the soft limit. Set one at or below it and Almanac lowers the soft limit to the cap divided by 1.5, rather than weakening the cap you asked for. Cap a guest at 20k against the 50k default and their counter reads 13,333. It empties exactly as the cap starts blocking. Otherwise they'd meet a 429 with the counter still promising five logs left, which is the surprise both tiers exist to prevent.

Contradict yourself across the two env variables and the API logs an error naming both values and the soft limit that will actually apply. It boots anyway, since the resolver keeps the tiers coherent either way.

Note what min does to the env var: it stops being a global backstop and becomes your own cap too. To give yourself more headroom than a guest, raise the env value to your number and set an explicit per-user cap on each guest.

Almanac checks the cap before each call but never bounds the call itself, so a turn starting one token under it runs to completion. Treat the number as a threshold, not a total you can't exceed. Web searches are bounded separately by ALMANAC_LLM_HARD_DAILY_SEARCH_CAP, which has no soft tier of its own.

All of these reset on the user-local 4am day boundary.

Turning off every limit turns off the backstop

Clearing the soft limit as well as the hard cap leaves the account genuinely uncapped. Worth knowing before you invite other people.

Each of the numeric limits treats an empty string as unset. That matters in production, where Compose passes "${VAR:-}" and an unset variable arrives as "" rather than absent. Without that coercion a blank cap would parse as 0, fail validation, and crashloop the API at boot.

Turning the AI surfaces on

ALMANAC_LLM_ENABLED=true and an ANTHROPIC_API_KEY are necessary but not sufficient. Both AI surfaces stay hidden until a per-user flag is also set, and there is no toggle for it in the web UI. The dashboard only reads the flag to decide whether to show the chat entry points.

The chat buttons stay hidden until you do this

The web UI shows the meal assistant and insights coach only when whoami reports llm_logging_enabled = 1 and llm_available = true. The env vars give you the second, the per-user flag gives you the first, and setting only the env vars looks like nothing happened.

Pick whichever you have in front of you.

Through an assistant (MCP). The flag is keyed by user id, not email, so list first:

admin_list_users
admin_set_user_llm_access  user_id=<id>  enabled=true

admin_list_users returns every user with their id, email, LLM access flag, daily token limit, and admin flag. Both tools are admin-only and the API enforces it: a non-admin's call is rejected by the route, not just hidden.

Through the API, if you already know the id:

bash
curl -X PATCH https://almanac.example.com/api/v1/admin/users/<id> \
  -H "Authorization: Bearer alm_…" \
  -H "Content-Type: application/json" \
  -d '{"llm_logging_enabled": 1}'

The flag is 1 or 0, not true/false: the body schema is strict and rejects booleans. (The MCP tool above takes enabled=true and converts.)

Directly in SQLite, which is the one path that takes an email:

bash
sqlite3 "$ALMANAC_DIR/data/almanac.sqlite" \
  "UPDATE users SET llm_logging_enabled = 1 WHERE email = 'you@example.com';"

Verify it took, whichever route you used:

bash
sqlite3 "$ALMANAC_DIR/data/almanac.sqlite" \
  "SELECT email, llm_logging_enabled FROM users;"

Reload the dashboard and the chat entry points appear.

Web search needs one more switch

Web search is enabled per-organization in the Anthropic Console under Settings → Privacy. Until it's on there, the AI surfaces still work, but searches just fail.

Searches draw a flat charge from the same daily token budget (ALMANAC_LLM_TOKENS_PER_SEARCH, default 2500). The real token cost is still recorded for accounting; the budget is billed the flat amount.

Next steps