# openplate, everything at once: app + sync + Postgres + your own AI. # # Four containers: the stateless app, the optional core server (openplate-core, # accounts and diary sync), the Postgres that sync, and only sync, needs, and a # self-hosted plate-identification endpoint (openplate-inference). The app # itself still keeps no database at all. Every image is open source under the # MIT License and is published to GHCR; nothing here builds from source. # # This is the largest shape, and you now operate all four. If you only want one # of the two extras, take the smaller file instead: `compose.core.yml` for sync, # `compose.inference.yml` for the AI. `../compose.yml` is the app alone. # # mkdir -p ~/openplate && cd ~/openplate # curl -O https://raw.githubusercontent.com/LowCarbCheck/openplate/main/docker/topologies/compose.full.yml # # # The core server needs exactly one secret. Generate it once and keep it # # with your database backups, see the SERVER_SECRET note below. # echo "SERVER_SECRET=$(openssl rand -hex 32)" >> .env # # # Your key to the admin API, which is how you create the first account. # echo "ADMIN_TOKEN=$(openssl rand -hex 32)" >> .env # # # One key for the inference service, which the app hands to browsers. # echo "INFERENCE_API_KEY=opk_$(openssl rand -hex 24)" >> .env # # # The three URLs a BROWSER will use to reach each service. PUBLIC_APP_URL # # and PUBLIC_SYNC_URL default to localhost, so skip both for a trial on # # this machine. PUBLIC_INFERENCE_URL has no such default: set it even for # # a local trial, for example to http://localhost:8300/v1. # echo "PUBLIC_APP_URL=https://openplate.example.com" >> .env # echo "PUBLIC_SYNC_URL=https://sync.example.com" >> .env # echo "PUBLIC_INFERENCE_URL=https://ai.example.com/v1" >> .env # # # 1 behind one reverse proxy, 0 with none. # echo "TRUST_PROXY=1" >> .env # # docker compose -f compose.full.yml up -d # # Keep this file in a folder that lasts, like ~/openplate above, and run all of # that from there. Compose treats the compose file's OWN directory as the # project directory, so the `.env` you just wrote, sitting beside this file, is # the one it reads. Compose passes on only the variables named below. # # Signing in needs a secure page: https://, or http://localhost on the machine # you are sitting at. Over plain http:// the sign-in, the sign-up # and the invite link all fail. See the HTTPS section of apps/app/docs/self-hosting.md. # # Then create the first account with an invitation to yourself, minted on # THIS machine with ADMIN_TOKEN (apps/app/docs/self-hosting.md has the curl command). # Open the link it returns, choose a password, and the devices you sign in on # converge. What the sync operator holds is explained in apps/app/docs/sync.md. # # "README" in the inference block below always means openplate-inference's # README: https://github.com/LowCarbCheck/openplate/tree/main/apps/inference # # ── THE ONE THING PEOPLE GET WRONG ───────────────────────────────────────── # `DEFAULT_INFERENCE_BASE_URL` must be a URL a BROWSER on the user's phone or # laptop can open. The photo goes from the device straight to the inference # endpoint; openplate's server is never in the loop, which is what keeps the # scan private. So `http://inference:8300/v1` (the container hostname) does NOT # work, even though the two containers can talk to each other that way. Use the # LAN address of this host, or a hostname on your reverse proxy / tailnet. # # Fixes the project name, so containers and the two volumes are named after the # stack rather than after whatever directory the file sits in. Distinct from the # smaller topologies' names, so two stacks never collide on one host. name: openplate-full services: # ── Postgres ────────────────────────────────────────────────────────────── # Belongs to the core server alone: it holds sync's accounts and the # opaque ciphertext blobs. Neither the app nor the inference service connects # to it, and the app has no database of its own # (apps/app/.adr/0006-the-app-server-holds-no-accounts.md). # Postgres 18. The volume is `pg-data-18` on purpose. The 18 image keeps its # cluster in /var/lib/postgresql/18/docker and the volume mounts one level up, # at /var/lib/postgresql. It refuses a volume that holds a 17 cluster, so the # old `pg-data` volume cannot be reused. It stays untouched as your rollback. # An install that ran Postgres 17 follows "Postgres 18 upgrade" in # docker/topologies/README.md: dump, start, restore. postgres: image: docker.io/library/postgres:18-alpine restart: unless-stopped environment: POSTGRES_USER: ${POSTGRES_USER:-openplate} POSTGRES_PASSWORD: ${POSTGRES_PASSWORD:-openplate} POSTGRES_DB: ${SYNC_DB_NAME:-openplate_sync} volumes: - pg-data-18:/var/lib/postgresql healthcheck: test: ['CMD-SHELL', 'pg_isready -U ${POSTGRES_USER:-openplate} -d ${SYNC_DB_NAME:-openplate_sync}'] interval: 5s timeout: 5s retries: 10 # Deliberately not published to the host: the core server reaches Postgres # over the compose network. Add a `ports:` mapping only if you need psql # from outside, and bind it to 127.0.0.1 if you do. expose: - '5432' # ── The inference service ───────────────────────────────────────────────── # A self-hosted, OpenAI-compatible plate-photo endpoint. Users of this # instance tap ONE button to use it: no provider account, no API key of # their own, no photo leaving your network. inference: image: ghcr.io/lowcarbcheck/openplate-inference:latest restart: unless-stopped ports: # Published because the browser calls it directly. Bind to 0.0.0.0 (as # here) for LAN access; use "127.0.0.1:8300:8300" if a reverse proxy on # this host is the only thing that should reach it. - '${INFERENCE_PORT:-8300}:8300' volumes: # Weights land here on first boot (~2.0 GiB for lite, ~5.8 GiB for # quality) and are verified by sha256 on every start. Named, so # `docker compose down` does not throw the download away. - inference-models:/models environment: # lite | lite-apache | quality, see README "Hardware & measured latency". # external runs no model here and uses your runtime, see below. MODEL_PROFILE: ${MODEL_PROFILE:-lite} # The key callers must present. Set INFERENCE_API_KEY in .env (see the # top of this file); the placeholder below only keeps a first trial # booting. It must match DEFAULT_INFERENCE_API_KEY on the app. API_KEYS: ${INFERENCE_API_KEY:-opk_CHANGE_ME} # Self-host has no latency ceiling. 0 = never shed load for being slow. LATENCY_CEILING_MS: ${LATENCY_CEILING_MS:-0} # Scans in flight at once. It also sets llama.cpp's slot count; it does # NOT add CPU threads, the slots share LLAMA_THREADS. CONCURRENCY: ${CONCURRENCY:-2} # CPU threads for the model. Empty means every core but two (nproc - 2), # which leaves room for the service and the OS. LLAMA_THREADS: ${LLAMA_THREADS:-} # Where macros come from. fdc = the bundled offline USDA-derived dataset. # See README "Food data" before switching to `off` (ODbL) or `lcc`. FOOD_SOURCE: ${FOOD_SOURCE:-fdc} # ── Everything else the service reads. Empty means the default, and # openplate-inference's .env.example explains each one. # # Your own runtime, with MODEL_PROFILE=external (see below). No trailing # /v1, and the address must resolve from INSIDE this container. MODEL_RUNTIME_URL: ${MODEL_RUNTIME_URL:-} MODEL_RUNTIME_API_KEY: ${MODEL_RUNTIME_API_KEY:-} # The model id sent to the runtime. Empty means openplate-plate-1. vLLM # needs its exact served model name. MODEL_ID: ${MODEL_ID:-} # The waiting line, the bound on one completion call, the requests per # key per minute, the largest decoded image, and the downscale target. MAX_QUEUE_DEPTH: ${MAX_QUEUE_DEPTH:-8} RUNTIME_COMPLETION_TIMEOUT_MS: ${RUNTIME_COMPLETION_TIMEOUT_MS:-600000} RATE_LIMIT_RPM: ${RATE_LIMIT_RPM:-60} MAX_IMAGE_BYTES: ${MAX_IMAGE_BYTES:-8388608} IMAGE_MAX_LONG_EDGE: ${IMAGE_MAX_LONG_EDGE:-896} # debug, info, warn or error. LOG_LEVEL: ${LOG_LEVEL:-info} # What the service reports as its profile. Empty follows MODEL_PROFILE. PROFILE: ${PROFILE:-} # Each is read only by its own FOOD_SOURCE. Empty FDC_DATASET_PATH is the # bundled extract. Empty URLs are https://lowcarbcheck.org for lcc and # https://world.openfoodfacts.org for off. FDC_DATASET_PATH: ${FDC_DATASET_PATH:-} LCC_API_URL: ${LCC_API_URL:-} LCC_API_KEY: ${LCC_API_KEY:-} OFF_API_URL: ${OFF_API_URL:-} # A second runtime serving /v1/embeddings, for hybrid retrieval. Empty # means lexical retrieval only. EMBEDDING_RUNTIME_URL: ${EMBEDDING_RUNTIME_URL:-} EMBEDDING_RUNTIME_API_KEY: ${EMBEDDING_RUNTIME_API_KEY:-} # The bundled llama-server: its loopback port, the context per slot, the # layers put on the GPU (empty means detect), and extra flags passed on # as written. RUNTIME_PORT: ${RUNTIME_PORT:-8080} CONTEXT_SIZE: ${CONTEXT_SIZE:-8192} GPU_LAYERS: ${GPU_LAYERS:-} LLAMA_EXTRA_ARGS: ${LLAMA_EXTRA_ARGS:-} # A mirror tried before Hugging Face for the first weight download. WEIGHTS_MIRROR_BASE: ${WEIGHTS_MIRROR_BASE:-} # ── GPU (the `quality` profile): uncomment BOTH of these and switch the # image to the -cuda tag. The entrypoint detects the GPU and offloads every # layer automatically; there is no flag to set. # deploy: # resources: # reservations: # devices: # - driver: nvidia # count: all # capabilities: [gpu] # # ── ALREADY RUNNING vLLM / llama.cpp / OLLAMA? ──────────────────────── # Set these in `.env` and this container downloads no weights and starts # no second model; it just turns your runtime into a plate scanner. You can # drop the `volumes:` block and the `inference-models` volume entirely. # # Read README "Bring your own runtime" FIRST: your runtime must enforce # grammar-constrained decoding, and there is a one-line curl there that # tells you whether yours does. vLLM's CPU build does NOT (it crashes). # # MODEL_PROFILE=external # # Must resolve from INSIDE this container, and no trailing /v1. # # `localhost` here means this container, not your host. Use the LAN # # address, or host.docker.internal on Docker Desktop. # MODEL_RUNTIME_URL=http://your-runtime.lan:8000 # # vLLM requires an EXACT match with its served model name. # MODEL_ID=your-served-model-name # # Only if your runtime is behind auth (`vllm serve --api-key ...`). # # Separate from INFERENCE_API_KEY, which callers present to THIS service. # MODEL_RUNTIME_API_KEY=sk_your_runtime_key # # Match your runtime's real slot count: # # llama.cpp --parallel N | vLLM --max-num-seqs N | OLLAMA_NUM_PARALLEL # CONCURRENCY=2 # # REQUIRED with external mode, and the one edit to this file it needs. The # image bakes in a 60-minute health start_period, sized for a first-boot # weight download. External mode has no download, so without this override # a wrong MODEL_RUNTIME_URL stays hidden for an hour instead of surfacing # in seconds. # healthcheck: # start_period: 30s # ── The app ─────────────────────────────────────────────────────────────── # Stateless. No accounts, no personal data, no database: your diary lives in # the browser. There is no secret to configure here; that is the design, # not an omission (see apps/app/.adr/0006-the-app-server-holds-no-accounts.md). app: image: ghcr.io/lowcarbcheck/openplate:latest restart: unless-stopped depends_on: - inference ports: # Published on EVERY network interface. Behind a reverse proxy on this # machine, write '127.0.0.1:3000:3000' so only the proxy can reach it. - '${APP_PORT:-3000}:3000' # To serve legal pages, uncomment this and set CONTENT_DIR in `.env`: # CONTENT_DIR=/srv/openplate/content # volumes: # - ./content:/srv/openplate/content:ro environment: NODE_ENV: production PORT: 3000 # The URL a browser uses to reach THIS app: PUBLIC_APP_URL in .env. # Behind a reverse proxy, that is the public https:// address, not the # container port. APP_URL: ${PUBLIC_APP_URL:-http://localhost:3000} # The URL a BROWSER uses to reach the core server, not `http://core:3000`. # The sync client runs in the page, so this address has to resolve from # your users' devices. Setting it is what makes the sync interface exist # at all; remove this line and the app is a pure local tracker again. # The origin is added to the app's Content-Security-Policy automatically. CORE_URL: ${PUBLIC_SYNC_URL:-http://localhost:3001} SYNC_SERVER_URL: ${SYNC_SERVER_URL:-} # PUBLIC_INFERENCE_URL in .env: a BROWSER-reachable address for the # inference container. NOT http://inference:8300. Note the /v1 suffix. # This is what turns into openplate's one-tap "This openplate provides # its own AI" card. Behind HTTPS it must be https:// too, or the browser # blocks the call from the secure page. DEFAULT_INFERENCE_BASE_URL: ${PUBLIC_INFERENCE_URL:-http://openplate.example.lan:8300/v1} # The same INFERENCE_API_KEY as API_KEYS above. # # ! THIS KEY IS PUBLIC. It is embedded in the HTML every browser loads, so # anyone who can open your openplate can read it with view-source. That # is fine for a household or a tailnet. It is NOT fine on an instance # open to the internet without a VPN or auth proxy in front of it; in # that case leave this unset and let people paste the key themselves. DEFAULT_INFERENCE_API_KEY: ${INFERENCE_API_KEY:-opk_CHANGE_ME} DEFAULT_INFERENCE_MODEL: ${DEFAULT_INFERENCE_MODEL:-openplate-plate-1} # `open` (the default) or `managed`. See apps/app/docs/configuration.md, Managed # instances, and the AI proxy block on the core server below. INSTANCE_MODE: ${INSTANCE_MODE:-open} # How many reverse proxies stand in front of the app AND the core server. # One value for both. With one proxy set TRUST_PROXY=1: the app's CSRF # check needs the proxy's X-Forwarded-* headers or form posts fail. With # NO proxy set TRUST_PROXY=0: 1 would let any visitor fake their address # in X-Forwarded-For and dodge the per-address limits. The app's default # of 1 assumes a proxy. TRUST_PROXY: ${TRUST_PROXY:-1} # ON by default. Queries the public LowCarbCheck food database for # curated nutrition data (food NAMES only, never photos; fails open on # outages). An EMPTY string disables it entirely so no food names ever # leave your machine. FOOD_DB_API_URL: ${FOOD_DB_API_URL-https://lowcarbcheck.org} # Optional free key for that database. Empty is the shared anonymous # allowance; set one if more than one person scans on this instance. FOOD_DB_API_KEY: ${FOOD_DB_API_KEY:-} # "true" passes foods people save from an AI answer on to LowCarbCheck as # proposals. Needs FOOD_DB_API_KEY. Empty means off. FOOD_DB_BACKFILL: ${FOOD_DB_BACKFILL:-} # The most LowCarbCheck calls this server makes in one UTC day. Empty means # the default, 3200. FOOD_DB_DAILY_CALL_LIMIT: ${FOOD_DB_DAILY_CALL_LIMIT:-} # The language a first-time visitor sees: en, de, fr, it, es or tr. # Empty means en. DEFAULT_UI_LANGUAGE: ${DEFAULT_UI_LANGUAGE:-} # "off" disables the six-hourly request to openplate.de for the newest version # and the project's daily count of asks. Empty means on. UPDATE_CHECK: ${UPDATE_CHECK:-} # Closes this instance: an https:// address where its people went. # Every page then names it. Empty means open as usual. MOVED_TO_URL: ${MOVED_TO_URL:-} # Extra origins the browser may call, space separated. Empty adds nothing. CSP_CONNECT_EXTRA: ${CSP_CONNECT_EXTRA:-} # debug, info, warn or error, for the sync and inference services too. LOG_LEVEL: ${LOG_LEVEL:-info} # Which published reference values the Nutrients screen quotes: dge (the # default), efsa or us. NUTRIENT_REFERENCE_BASIS: ${NUTRIENT_REFERENCE_BASIS:-dge} # Matomo analytics, off unless the first two are set together. The level # is pageviews, product (the default when empty) or research, and only # with the pair: a level on its own stops the boot. MATOMO_URL: ${MATOMO_URL:-} MATOMO_SITE_ID: ${MATOMO_SITE_ID:-} MATOMO_EVENT_LEVEL: ${MATOMO_EVENT_LEVEL:-} # A newsletter form on the landing page, off unless both are set. NEWSLETTER_SUBSCRIBE_URL: ${NEWSLETTER_SUBSCRIBE_URL:-} NEWSLETTER_TURNSTILE_SITE_KEY: ${NEWSLETTER_TURNSTILE_SITE_KEY:-} # The folder of legal pages, mounted read-only. Set it to the container # path of the volume line above. Empty means no legal pages. CONTENT_DIR: ${CONTENT_DIR:-} # The address the server binds to inside the container. Leave it empty: # the published port reaches only a server on every interface. HOST: ${HOST:-} healthcheck: # A shell line with no quotes and no brackets, run by the image's busybox # wget, so Docker Compose, podman-compose and Quadlet all run it alike. test: ['CMD-SHELL', 'wget -q -O /dev/null http://127.0.0.1:3000/healthcheck'] interval: 30s timeout: 5s start_period: 30s retries: 3 # ── The core server ────────────────────────────────────────────────────── # An account service that stores an email address and opaque ciphertext, # plus each account's recovery code, sealed under SERVER_SECRET, so that a # password reset brings the diary back. apps/app/docs/sync.md states what that means # for whoever runs this service. core: image: ghcr.io/lowcarbcheck/openplate-core:latest restart: unless-stopped healthcheck: # The image bakes a check in, but Podman drops a HEALTHCHECK when it # pulls an OCI manifest, which is what GHCR serves. Declared here it # holds under both engines and Quadlet turns it into Notify=healthy. test: ['CMD-SHELL', 'wget -q -O /dev/null http://127.0.0.1:3000/health'] interval: 30s timeout: 5s start_period: 20s retries: 3 depends_on: postgres: condition: service_healthy ports: # Published on EVERY network interface, like the app. Behind a reverse # proxy on this machine, write '127.0.0.1:3001:3000'. - '${SYNC_PORT:-3001}:3000' # Uncomment what you use, and set the matching variable in `.env`: # CONTENT_DIR=/srv/openplate/content (mount the same folder on the app) # NODE_EXTRA_CA_CERTS=/etc/openplate/ca.pem # volumes: # - ./content:/srv/openplate/content:ro # - ./ca.pem:/etc/openplate/ca.pem:ro environment: PORT: 3000 DATABASE_URL: postgres://${POSTGRES_USER:-openplate}:${POSTGRES_PASSWORD:-openplate}@postgres:5432/${SYNC_DB_NAME:-openplate_sync} # THE one secret in this file. Three subkeys are derived from it: the # pepper mixed into every stored authentication verifier, the key behind # the anti-enumeration KDF responses, and the key that seals each # account's recovery code. # # Back it up WITH the database. A restored database with a lost secret # is a database nobody can log into, and no password reset works either. # Changing it has the same effect as losing it. SERVER_SECRET: ${SERVER_SECRET:?generate one with `openssl rand -hex 32` and put it in .env} # Your key to the admin API at /v1/admin: minting invitations, handing out # password-reset links, listing and removing accounts. Empty turns that # API off unless an account with the admin role exists. At least 24 # characters: generate it with `openssl rand -hex 32`, never choose it. ADMIN_TOKEN: ${ADMIN_TOKEN:-} # The two halves of every invitation and reset link, taken from the same # PUBLIC_* values the app uses, so you set each address once. A link # reads PUBLIC_APP_URL/join#server=PUBLIC_SYNC_URL&invite=... SERVER_PUBLIC_URL: ${PUBLIC_SYNC_URL:-http://localhost:3001} CLIENT_BASE_URL: ${PUBLIC_APP_URL:-http://localhost:3000} # Signup is invite-only unless OPEN_SIGNUP is set below: an account is # created by redeeming an invitation addressed to one email address. # SIGNUP_MODE and the older SIGNUPS_OPEN are both boot failures in # openplate-core, so neither is forwarded here. # The same TRUST_PROXY as the app above: the number of reverse proxies in # front of this service. Left at 0 behind a proxy, every request looks # like it comes from the proxy and the per-address throttle becomes one # bucket a single attacker can lock for all your users. Set above 0 with # nothing in front, anyone can fake X-Forwarded-For and skip the throttle. TRUST_PROXY: ${TRUST_PROXY:-0} # debug, info, warn or error. Empty is refused, so the default stays. LOG_LEVEL: ${LOG_LEVEL:-info} # What the instance calls itself on the /health handshake and in its # start-up log, and which language its letters are written in when a # request names none (en, de, fr, it, es or tr). Empty means openplate, en. INSTANCE_NAME: ${INSTANCE_NAME:-} INSTANCE_LANGUAGE: ${INSTANCE_LANGUAGE:-} # ── Mail (optional): one transport, or none ── # When set, this service mails the invitation and the password reset # itself. When empty, it sends nothing. The admin API hands the # invitation link and the reset link to you, and you pass them on. # "Forgot password" in the app reaches nobody. See # apps/app/docs/self-hosting.md for what to do instead. With mail on, # PUBLIC_APP_URL and PUBLIC_SYNC_URL must be https addresses, or the # service refuses to start. # # Any Resend-compatible HTTP mail API. It takes a POST of JSON with a # Bearer token. Set all three: MAIL_API_URL: ${MAIL_API_URL:-} MAIL_API_KEY: ${MAIL_API_KEY:-} MAIL_API_FROM: ${MAIL_API_FROM:-} # Or SMTP, never both. SMTP_PORT defaults to 587 when empty. Port 465 # uses TLS from the start. Every other port must upgrade with STARTTLS. # SMTP_USER and SMTP_PASSWORD go together. For Gmail, use an app # password on smtp.gmail.com. SMTP_HOST: ${SMTP_HOST:-} SMTP_PORT: ${SMTP_PORT:-} SMTP_USER: ${SMTP_USER:-} SMTP_PASSWORD: ${SMTP_PASSWORD:-} SMTP_FROM: ${SMTP_FROM:-} # Who receives the operator's copy of a cancellation or a withdrawal. # Either transport requires it. MAIL_OPERATOR_EMAIL: ${MAIL_OPERATOR_EMAIL:-} # ── The AI proxy (optional, for INSTANCE_MODE=managed) ── # The provider every signed-in scan is forwarded to, and its key. Both or # neither. Empty means this instance offers no AI of its own. UPSTREAM_BASE_URL: ${UPSTREAM_BASE_URL:-} UPSTREAM_API_KEY: ${UPSTREAM_API_KEY:-} # OpenRouter only, both optional: zero data retention endpoints, and a pin # to named providers with no fallback. Empty is the proxy's old behaviour. UPSTREAM_ZDR: ${UPSTREAM_ZDR:-} UPSTREAM_PROVIDER_ONLY: ${UPSTREAM_PROVIDER_ONLY:-} # The model every proxied request is sent to. The app scans with the # model this names, so a managed instance with AI needs it set. AI_ADVERTISED_MODEL: ${AI_ADVERTISED_MODEL:-} # The whole instance's AI requests per UTC day. Empty means no ceiling. AI_INSTANCE_DAILY_LIMIT: ${AI_INSTANCE_DAILY_LIMIT:-} # On an OpenRouter key: mail MAIL_OPERATOR_EMAIL once per reset period # when less than this share of the key's limit is left. Empty means 0.2. AI_BUDGET_ALERT_FRACTION: ${AI_BUDGET_ALERT_FRACTION:-} # How long one proxied request may take, the most output tokens it may # ask for, the requests per account per minute, and the largest request # body, sized for a camera photograph after base64. UPSTREAM_TIMEOUT_MS: ${UPSTREAM_TIMEOUT_MS:-120000} AI_MAX_OUTPUT_TOKENS: ${AI_MAX_OUTPUT_TOKENS:-8192} AI_RATE_LIMIT_PER_MINUTE: ${AI_RATE_LIMIT_PER_MINUTE:-20} AI_MAX_REQUEST_BYTES: ${AI_MAX_REQUEST_BYTES:-8000000} # What one request may carry in (image parts, text bytes, messages), # the input tokens one unit of the daily counters covers, and the # tokens one image is counted at. AI_MAX_IMAGE_PARTS: ${AI_MAX_IMAGE_PARTS:-1} AI_MAX_TEXT_BYTES: ${AI_MAX_TEXT_BYTES:-49152} AI_MAX_MESSAGES: ${AI_MAX_MESSAGES:-4} AI_UNIT_INPUT_TOKENS: ${AI_UNIT_INPUT_TOKENS:-8192} AI_IMAGE_INPUT_TOKENS: ${AI_IMAGE_INPUT_TOKENS:-1500} # ── Members inviting people (optional) ── # The first two together or neither; the cap only with them. Empty means # only an administrator invites. See apps/app/docs/configuration.md, Member invites. MEMBER_INVITE_DAILY_AI_LIMIT: ${MEMBER_INVITE_DAILY_AI_LIMIT:-} MEMBER_INVITE_ALLOWANCE_DAYS: ${MEMBER_INVITE_ALLOWANCE_DAYS:-} MEMBER_INVITE_LIFETIME_CAP: ${MEMBER_INVITE_LIFETIME_CAP:-} # "true" lets a person share their diary with a clinician. Off by default. SYNC_SHARING: ${SYNC_SHARING:-false} # "true" opens the research console at /study, and makes this server # hold study data. Read openplate-core's .env.example first. Off by default. SYNC_RESEARCH: ${SYNC_RESEARCH:-false} # ── The operator notice (optional) ── # One short sentence /health publishes and the client shows, for the # things this service can no longer tell anyone: a move, a shutdown, a # maintenance window. Empty means no notice, and SYNC_NOTICE_URL # without SYNC_NOTICE is a boot failure. SYNC_NOTICE: ${SYNC_NOTICE:-} SYNC_NOTICE_URL: ${SYNC_NOTICE_URL:-} # ── Reported estimates (optional) ── # On, this service KEEPS the photograph and the figures a person reports, # readable, for its retention window. The two limits are per account per # UTC day, and per request. Read openplate-core's .env.example first. SYNC_FEEDBACK: ${SYNC_FEEDBACK:-false} FEEDBACK_DAILY_LIMIT: ${FEEDBACK_DAILY_LIMIT:-5} FEEDBACK_MAX_REQUEST_BYTES: ${FEEDBACK_MAX_REQUEST_BYTES:-8000000} # ── Open sign-up (optional) ── # Empty means invite-only. "true" needs the mail block above. The # Turnstile pair is both or neither, and only with OPEN_SIGNUP. OPEN_SIGNUP: ${OPEN_SIGNUP:-} TURNSTILE_SECRET_KEY: ${TURNSTILE_SECRET_KEY:-} TURNSTILE_SITE_KEY: ${TURNSTILE_SITE_KEY:-} # ── Free AI scans for new accounts (optional) ── # TRIAL_SCANS and TRIAL_DAILY_AI_LIMIT together or neither, and the # pepper with them. TRIAL_DAYS also ends the trial at midnight after that # many days, in TRIAL_TIME_ZONE (an IANA name, empty means UTC). # MEMBER_INVITE_TRIAL=true makes a member invitation grant the trial. # Empty means no trial. TRIAL_SCANS: ${TRIAL_SCANS:-} TRIAL_DAILY_AI_LIMIT: ${TRIAL_DAILY_AI_LIMIT:-} TRIAL_DAYS: ${TRIAL_DAYS:-} TRIAL_TIME_ZONE: ${TRIAL_TIME_ZONE:-} TRIAL_ADDRESS_PEPPER: ${TRIAL_ADDRESS_PEPPER:-} TRIAL_HASH_RETENTION_DAYS: ${TRIAL_HASH_RETENTION_DAYS:-} MEMBER_INVITE_TRIAL: ${MEMBER_INVITE_TRIAL:-} AI_TRIAL_INSTANCE_DAILY_LIMIT: ${AI_TRIAL_INSTANCE_DAILY_LIMIT:-} AI_TRIAL_NETWORK_DAILY_LIMIT: ${AI_TRIAL_NETWORK_DAILY_LIMIT:-} # ── A standing free daily AI limit (optional) ── # Requests per UTC day for every account with no free limit of its own, # no end date and no trial. Empty or 0 means off. It cannot stand # beside the trial above: the boot stops naming both. DEFAULT_FREE_DAILY_AI_LIMIT: ${DEFAULT_FREE_DAILY_AI_LIMIT:-} # ── AI feature permissions (optional) ── # DEFAULT_CAPABILITIES is what an account with no record of its own may # use: comma separated labels, or "none" for nothing. Empty means no # check at all. CAPABILITY_SCHEMA_MAP ties a structured-output schema # name to the label its use needs, as schemaName:label pairs. DEFAULT_CAPABILITIES: ${DEFAULT_CAPABILITIES:-} CAPABILITY_SCHEMA_MAP: ${CAPABILITY_SCHEMA_MAP:-} # ── Web push (optional) ── # All three or none. Empty means no notifications. VAPID_PUBLIC_KEY: ${VAPID_PUBLIC_KEY:-} VAPID_PRIVATE_KEY: ${VAPID_PRIVATE_KEY:-} VAPID_SUBJECT: ${VAPID_SUBJECT:-} PUSH_ENDPOINT_HOSTS: ${PUSH_ENDPOINT_HOSTS:-} # ── Paid plans (optional) ── # The URL and the secret together or neither, and only with a billing # service behind this instance. BILLING_TOKEN is that service's own # credential. Empty means no plans. PLANS_UPSTREAM_URL: ${PLANS_UPSTREAM_URL:-} PLANS_UPSTREAM_SECRET: ${PLANS_UPSTREAM_SECRET:-} BILLING_TOKEN: ${BILLING_TOKEN:-} BILLING_MAX_DAILY_AI_LIMIT: ${BILLING_MAX_DAILY_AI_LIMIT:-} # ── Everything else ── # Which body's reference values the Nutrients screen quotes (dge, efsa or # us), for the app above too. Only the boot default: an administrator # changes the live setting, and the stored one wins from then on. NUTRIENT_REFERENCE_BASIS: ${NUTRIENT_REFERENCE_BASIS:-dge} # The health-data consent every account must agree to, a short string # such as 2026-09-28. Empty asks for no consent. HEALTH_CONSENT_VERSION: ${HEALTH_CONSENT_VERSION:-} # The folder of letter texts, the same one the app above reads its legal # pages from. Set it to the container path of the commented volume line # above this environment block. CONTENT_DIR: ${CONTENT_DIR:-} # The daily ceilings on declaration receipts, for the instance and per # sender network. Read openplate-core's .env.example first. LEGAL_DECLARATION_RECEIPTS_PER_DAY: ${LEGAL_DECLARATION_RECEIPTS_PER_DAY:-200} LEGAL_DECLARATION_RECEIPTS_PER_NETWORK_PER_DAY: ${LEGAL_DECLARATION_RECEIPTS_PER_NETWORK_PER_DAY:-10} # A PEM file of extra certificate authorities Node trusts, for an SMTP # server with a private CA. The container path of the commented volume # line above this environment block. NODE_EXTRA_CA_CERTS: ${NODE_EXTRA_CA_CERTS:-} # The address the listener binds to inside the container. Leave it empty: # the published port reaches only a listener on every interface. HOST: ${HOST:-} # Only for an EXTERNAL database. The bundled Postgres speaks plain TCP on # the compose network. DATABASE_SSL: ${DATABASE_SSL:-false} # Every variable openplate-core reads is forwarded above, so a line in # `.env` is all it takes. If you set SIGNUP_MODE, SIGNUPS_OPEN, # EMAIL_FROM, SMTP_SECURE, any PIGEON_*, or REQUIRE_EMAIL_VERIFICATION, # you get a BOOT FAILURE, so none of them is forwarded. volumes: pg-data-18: driver: local inference-models: driver: local