# openplate, the full experience, self-hosted end to end. # # Three containers: the stateless app, the optional account + E2EE sync # service (openplate-core), and the Postgres that sync, and only sync, needs. # The app itself keeps no database at all. Both images are open source under # the MIT License and are published to GHCR; nothing here builds from source. # # If you do not want sync, you do not want this file. Use `docker/compose.yml` # instead: the app alone needs no secrets and no accounts. The other shapes are # listed in `README.md` next to this file. # # mkdir -p ~/openplate && cd ~/openplate # curl -O https://raw.githubusercontent.com/LowCarbCheck/openplate/main/docker/topologies/compose.core.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 # # # The two URLs a BROWSER will use to reach each service. Defaults below # # work for a trial on this machine; set these for anything else. # echo "PUBLIC_APP_URL=https://openplate.example.com" >> .env # echo "PUBLIC_SYNC_URL=https://sync.example.com" >> .env # # docker compose -f compose.core.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: a line in # `.env` that no `${...}` here mentions never reaches a container. # # 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 two devices you sign in # on converge. The service stores ciphertext; what its operator holds is # explained in apps/app/docs/sync.md. # # Fixes the project name, so containers and the pg-data-18 volume are named after # the stack rather than after whatever directory the file sits in. # # The name keeps its old spelling on purpose. Compose prefixes the volume with # it (`openplate-with-sync_pg-data-18`), so a new name would start an empty # database next to the one an earlier install already filled. name: openplate-with-sync services: # ── Postgres ────────────────────────────────────────────────────────────── # Belongs to the core server alone: it holds sync's accounts and the # opaque ciphertext blobs. The app never connects to it and 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: both services reach 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 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 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. 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:-} # `open` (the default): anyone can keep a diary on their own device, and # sync is an extra. `managed`: an administrator invites every person, # there is no diary without an account, and scans run through the sync # service's AI proxy (set UPSTREAM_* and AI_ADVERTISED_MODEL below). # See apps/app/docs/configuration.md, Managed instances. INSTANCE_MODE: ${INSTANCE_MODE:-open} # How many reverse proxies stand in front of the app AND the core server. # One value for both, because they sit behind the same proxy or behind # none. With one proxy (Caddy, nginx, Traefik) 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 core server below as well. LOG_LEVEL: ${LOG_LEVEL:-info} # An OpenAI-compatible vision endpoint of your own that every browser # here may use with one tap, at an address a BROWSER can reach (the same # names compose.full.yml uses). The key is PUBLIC: every browser that # loads the app can read it. Empty means each person brings their own # key. The model defaults to openplate-plate-1. DEFAULT_INFERENCE_BASE_URL: ${PUBLIC_INFERENCE_URL:-} DEFAULT_INFERENCE_API_KEY: ${INFERENCE_API_KEY:-} DEFAULT_INFERENCE_MODEL: ${DEFAULT_INFERENCE_MODEL:-} # 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