İçeriğe atla
openplate

openplate nasıl çalıştırılır

openplate uygulamasını hiçbir şey çalıştırmadan da kullanabilirsin, her parçasını kendin de çalıştırabilirsin. Bu ikisi arasında dört basamak bulunur. Her basamak yeni bir yetenek ekler ve artık işletmek zorunda olduğun bir şey katar, bu yüzden bunu erken tırmanmayı bırakabileceğin bir merdiven gibi düşün.

  1. Basamak 0Başkasının çalıştırdığı bir örneği kullanÇalıştırılacak bir şey yok
  2. Basamak 1Uygulamayı kendi makinende çalıştırKonteyner sayısı: 1Veri birimleri: 0
  3. Basamak 2Cihazların arasında eşitleme ekleKonteyner sayısı: 3Veri birimleri: 1
  4. Basamak 3Kendi donanımında taraKonteyner sayısı: 2Veri birimleri: 1
  5. Basamak 4Hepsini çalıştırKonteyner sayısı: 4Veri birimleri: 2

Dört basamak

Dört basamak. Her biri yeni bir yetenek ekler ve çalıştırman gereken yeni bir şey getirir. En alttan başla ve ihtiyacın olanı elde eder etmez dur: çoğu kişi basamak 0 veya 1'de durur.

BasamakElde ettiğinÇalıştırdığınCompose dosyası
0Tabak takibi + yapay zeka taramalarıHiçbir şeyyok
1Aynısı, kendi makinendeDurumsuz tek bir konteynerdocker/compose.yml
2İki cihazda günlüğün ve yönetilen bir kurulumda bir hane ya da kuruluş için paylaşılan yapay zeka faturası+ bir veritabanı ve bir gizli anahtardocker/topologies/compose.core.yml
3Kendi donanımında taramalar+ bir model çalışma zamanıdocker/topologies/compose.inference.yml
4HepsiHepsidocker/topologies/compose.full.yml

Her compose dosyası satır satır açıklanmıştır; compose tarafındaki aynı harita için bkz. docker/topologies/README.md.

Her basamakta uygulama sunucusu, kullanan kişiler için LowCarbCheck gıda veri tabanından gıda adlarını da arar. Yalnızca adları gönderir, hiçbir zaman bir fotoğraf veya günlük kaydı göndermez. 1. basamaktan itibaren bunu senin sunucun yapar; birden fazla kişi tarama yapıyorsa sunucuya ücretsiz bir anahtar ver. Bkz. architecture.md.

Aşağıdaki her komut Podman altında podman compose olarak da çalışır. Ubuntu üzerinde bu alt komut, yanında podman-compose paketinin kurulu olmasını gerektirir. Bkz. podman.md.

Compose dosyası nasıl kullanılır

1'den başlayan her basamak bir compose dosyasıdır. Dosya her konteyneri, bağlantı noktasını ve birimi listeler. Docker veya Podman bunları bu dosyadan başlatır. Her basamağın altındaki panel, dosyasının neleri başlattığını gösterir.

  1. ~/openplate gibi kalıcı bir klasör oluştur ve her komutu oradan çalıştır. Compose, compose dosyasının yanındaki .env dosyasını okur.

  2. Bulunduğun basamağın compose dosyasını bu klasöre indir.

  3. Dosyanın istediği satırları .env içine yaz. Basamak 1 hiçbirine ihtiyaç duymaz.

  4. docker compose -f <file> up -d ile başlat. Her basamağın altındaki panel, açılacak adresi gösterir.

Güncellemek için aynı dosyayla önce pull, ardından tekrar up -d çalıştır. down konteynerleri durdurur ve birimleri korur, böylece veriler kalır.

Başkasının çalıştırdığı bir örneği kullan

https://openplate.lowcarbcheck.org gibi mevcut bir örneği aç ve kendi sağlayıcı anahtarını Ayarlar → Yapay Zeka içine yapıştır. Kayıt olma işlemi yoktur. Günlüğün o tarayıcının depolama alanında tutulur ve örneğin sunucusuna asla ulaşmaz; bu nedenle "başkasının örneğini kullanmak", bu ifadenin ima ettiğinden çok daha azını o işletmene verir; bkz. architecture.md. Taradığın veya aradığın gıdaların adları, gıda veri tabanına giderken bu sunucudan geçer.

Bu basamakta senin çalıştıracağın hiçbir şey yoktur. Günlüğü tarayıcı tutar, tarayıcı sağlayıcıyı içine yapıştırdığın anahtarla çağırır ve işleticinin sunucusu yalnızca sayfayı gönderir.

Sıfırıncı basamakta tarayıcı günlüğü tutar ve bir bulut sağlayıcısını doğrudan senin anahtarınla çağırır.
Diyagram kaynağı
flowchart LR
  host["Someone else's instance"] -->|"HTML and JS"| browser["Your browser"]
  browser --- diary["Diary in this browser"]
  browser -->|"photo and your key"| cloud["Cloud AI provider"]

Kendi yapay zeka kullanımının maliyeti karşılığında, bir dakikada ürünün tamamını Kazancın:. Hiçbir şey İşlettiğin bileşenler:.

Açıkça belirtmek gerekirse, herkese açık bir demo örneği için çalışma süresi taahhüdü verilmez ve buradaki hiçbir veri senin için yedeklenmez. Günlüğün o tarayıcıda tutulur ve tarayıcıyı temizlediğinde silinir. Düzenli olarak Profil → Verilerin üzerinden JSON dışa aktarımı al veya basamak 1'e geç.

Uygulamayı kendi makinende çalıştır

Konteyner aracı
mkdir -p ~/openplate && cd ~/openplate
curl -O https://raw.githubusercontent.com/LowCarbCheck/openplate/main/docker/compose.yml
docker compose -f compose.yml up -d
O makinede http://localhost:3000 olarak veya HTTPS üzerinden aç. Başka bir cihazdan http://<its address>:3000 günlüğü gösterir. Uygulama kurulumu, çevrimdışı kullanım ve tek tıkla OpenRouter bağlantısı orada çalışmaz. Bkz. self-hosting.md.

Basamak 1 tek bir sunucuyu değiştirir. Sayfa senin çalıştırdığın bir konteynerden sunulur, fotoğraf yolu ise yukarıdakinin birebir aynısıdır.

Birinci basamakta sayfa kendi durum bilgisi tutmayan konteynerinden sunulur, fotoğraf yolu ise değişmez.
Diyagram kaynağı
flowchart LR
  app["openplate app, your box"] -->|"HTML and JS"| browser["Your browser"]
  browser --- diary["Diary in this browser"]
  browser -->|"photo and your key"| cloud["Cloud AI provider"]

Uygulamayı başkasının örneğine bağımlı kalmadan, kendi denetimindeki bir donanımda, istediğin zaman güncelleyerek Kazancın:. Tek bir konteyner İşlettiğin bileşenler:. Veritabanı yok, .env adımı yok, üretilecek bir gizli anahtar yok, güncelleme sırasında taşınacak hiçbir veri yok. Çökerse hiçbir şey kaybolmaz, çünkü hiçbir veri saklamaz. Compose dosyası: docker/compose.yml.

docker/compose.yml

0.66.0 sürümünde yayınlandı

Neleri başlatır

  • Uygulama app

    ghcr.io/lowcarbcheck/openplate:latest

    Bu makinedeki 3000 numaralı bağlantı noktası · Birim yok

latest, en yeni sürümü izler. Bir sürümü sabitlemek için latest yerine sürüm numarasını yaz.

.env içine ne yazılır

Başlamak için bir .env dosyasına ihtiyaç duymaz.

Bu satırlar ona nasıl erişileceğini belirler:

  • APP_URLVarsayılan: http://localhost:3000

    Şuraya aktarıldı: app → APP_URL

  • TRUST_PROXYVarsayılan: 1

    Şuraya aktarıldı: app → TRUST_PROXY

Bir ters proxy arkasındayken TRUST_PROXY değerini 1 olarak tut. Proxy yoksa 0 olarak ayarla.

.env dosyasından toplam 25 ayar okur. Dosya, her isteğe bağlı ayarın varsayılan değerini listeler. Her ayarın açıklaması

Komutlar

Başlat
docker compose -f compose.yml up -d
Güncelle
docker compose -f compose.yml pull docker compose -f compose.yml up -d
Günlük kayıtlarını izle
docker compose -f compose.yml logs -f
Durdur
docker compose -f compose.yml down

Çalıştıran makinede http://localhost:3000 adresini aç.

Telefonlar ve diğer cihazlar HTTPS gerektirir. Kendi sunucunda barındırma kılavuzu, bir alan adı olmadan bile bunu nasıl kuracağını gösterir.

Podman için, docker compose yerine podman compose çalıştır.

Dosyanın tamamını göster (144 satır)
yaml
# openplate, self-hosted: one stateless container and nothing else.
#
#   mkdir -p ~/openplate && cd ~/openplate
#   curl -O https://raw.githubusercontent.com/LowCarbCheck/openplate/main/docker/compose.yml
#   docker compose -f compose.yml up -d
#
# Keep this file in a folder that lasts, like ~/openplate above, and run every
# command from there. Compose treats the compose file's OWN directory as the
# project directory, so a `.env` beside this file is the one it reads, not one
# somewhere further up. A folder under /tmp can be emptied by a reboot.
#
# Only http://localhost:3000 on this machine counts as a secure page. Opened
# from another device at http://<this machine's address>:3000, the diary and
# plate photos work, but installing the app, offline use and the one-click
# OpenRouter connect do not. See the HTTPS section of apps/app/docs/self-hosting.md.
#
# There is no database and no secret to configure. The server stores nothing:
# your diary lives in your browser's own IndexedDB on the device you use it
# from (see apps/app/.adr/0006-the-app-server-holds-no-accounts.md). Back up with the
# in-app JSON export, not with a database dump.
#
# If you also want the optional core server (accounts and diary sync), use
# docker/topologies/compose.core.yml instead. THAT one needs a database,
# because the core server keeps accounts and ciphertext. The other shapes
# (self-hosted AI, or everything at once) sit beside it; docker/topologies/
# README.md is the one-page map.
# Fixes the project name. Without it Compose names the stack after the
# directory the file sits in -- "docker" for anyone running from a checkout --
# and every container and volume inherits that name.
name: openplate

services:
  # Pulls the published multi-arch image from GHCR by default -- no source
  # checkout required for a self-host. To build from source instead (e.g.
  # developing against this repo), comment out `image:` below and uncomment
  # `build:`, then run:
  #   docker compose --project-directory . -f docker/compose.yml build
  #   docker compose --project-directory . -f docker/compose.yml up -d
  # Run those two from the repo root. The build context below is written
  # relative to THIS file, so it points one level up and into the app's folder
  # of the checkout, apps/app, and `--project-directory .` is what makes
  # Compose read the repo root's `.env` instead of the one it would look for
  # in `docker/`.
  app:
    image: ghcr.io/lowcarbcheck/openplate:latest
    # build:
    #   context: ../apps/app
    #   dockerfile: Dockerfile.pnpm
    restart: unless-stopped
    ports:
      # Published on EVERY network interface of this machine, so anyone on your
      # network can open http://<this machine's address>:3000. Behind a reverse
      # proxy on this machine, write '127.0.0.1:3000:3000' instead: then only
      # the proxy can reach the app, and the plain HTTP port is gone.
      - '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
      # Public URL this instance is reachable at. Set APP_URL in .env if you
      # put a reverse proxy in front (see apps/app/docs/self-hosting.md, HTTPS).
      APP_URL: ${APP_URL:-http://localhost:3000}
      # 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:-}
      # Number of reverse proxies in front of this container. Behind one proxy
      # (Caddy, nginx, Traefik) keep 1: React Router's CSRF check compares the
      # browser Origin against the host it thinks it is serving, and without
      # the proxy's X-Forwarded-* headers form posts fail. With NO proxy, set
      # TRUST_PROXY=0 in .env: pages work either way, but 1 lets any visitor
      # fake their address in X-Forwarded-For and dodge the per-address limit
      # on food lookups. 2 = Cloudflare in front of one proxy.
      TRUST_PROXY: ${TRUST_PROXY:-1}
      # The language a first-time visitor sees: en, de, fr, it, es or tr.
      # Empty means en. Anyone can still switch in Settings.
      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, for example a
      # remote AI endpoint of your own. Empty adds nothing.
      CSP_CONNECT_EXTRA: ${CSP_CONNECT_EXTRA:-}
      # debug, info, warn or error.
      LOG_LEVEL: ${LOG_LEVEL:-info}
      # The address of openplate-core, one a BROWSER can reach.
      # Empty means no sync. docker/topologies/compose.core.yml runs one.
      CORE_URL: ${CORE_URL:-}
      # The old name of CORE_URL, read for one more release. Set CORE_URL.
      SYNC_SERVER_URL: ${SYNC_SERVER_URL:-}
      # open (the default) or managed, which needs CORE_URL. See
      # apps/app/docs/configuration.md, Managed instances.
      INSTANCE_MODE: ${INSTANCE_MODE:-open}
      # An OpenAI-compatible vision endpoint every browser here may use with
      # one tap, at an address a BROWSER can reach. 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: ${DEFAULT_INFERENCE_BASE_URL:-}
      DEFAULT_INFERENCE_API_KEY: ${DEFAULT_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. Docker Compose, podman-compose and Quadlet all pass it through
      # the same way; the old `node -e "fetch(...)"` form reached podman-compose
      # 1.0.6 as a broken shell line and stayed unhealthy forever.
      test: ['CMD-SHELL', 'wget -q -O /dev/null http://127.0.0.1:3000/healthcheck']
      interval: 30s
      timeout: 5s
      start_period: 30s
      retries: 3

Önerilen durma noktası burasıdır. Bundan sonraki her basamak gerçek operasyonel yük getirir.

Kılavuzun tamamı self-hosting.md içindedir. PWA kurulumu, tek tıkla OpenRouter bağlantısı ve 2. basamaktan itibaren giriş yapmak için ihtiyaç duyduğun HTTPS'yi kapsar.

Cihazların arasında eşitleme ekle

Tek bir günlüğü cihazların arasında Kazancın:. Bunu dürüstçe anlatmanın yolu şudur: birbiriyle uyumlu kalan bir telefon ve bir dizüstü bilgisayar, yani bir kişi, iki cihaz. Aileler ikinci kullanım senaryosudur ve daha zayıf kalır: eşitleme hesap bazındadır, bu yüzden aynı hesabı paylaşan iki kişi ayrı birer günlük almak yerine tek bir günlüğü paylaşır. Ayrı günlükler isteyen iki kişinin iki hesaba ya da doğrudan basamak 1 düzeyinde iki cihaza ihtiyacı vardır, eşitlemeye hiç gerek kalmaz.

Basamak 2, arkasında bir veritabanı bulunan ikinci bir sunucu ekler. Her cihaz aynı şifrelenmiş veriyi gönderip diğer cihazınkini çeker, fotoğraf ise sağlayıcıya gitmek üzere cihazdan çıkmaya devam eder.

İkinci basamakta her iki cihaz da çekirdek sunucuya şifrelenmiş tek bir ikili veri gönderir ve fotoğraf yine doğrudan sağlayıcıya gider.
Diyagram kaynağı
flowchart LR
  app["openplate app"] -->|"HTML and JS"| phone["Phone"]
  app -->|"HTML and JS"| laptop["Laptop"]
  phone -->|"ciphertext"| sync["openplate-core"]
  laptop -->|"ciphertext"| sync
  sync --> db[("Postgres")]
  phone -->|"photo and your key"| cloud["Cloud AI provider"]
  laptop -->|"photo and your key"| cloud

Uygulama, bir hesap servisi ve bir Postgres İşlettiğin bileşenler:. Bu ciddi bir adımdır: bir hesap servisinin yedeklenmeye değer bir veritabanı, korunmaya değer bir SERVER_SECRET ve kendi erişimini kaybedebilecek kullanıcıları vardır. İnternete açmadan önce openplate-core'un README dosyası belgesini oku. Compose dosyası: docker/topologies/compose.core.yml.

docker/topologies/compose.core.yml

0.66.0 sürümünde yayınlandı

Neleri başlatır

  • Veritabanı postgres

    docker.io/library/postgres:18-alpine

    Bu makinede bağlantı noktası yok · Birim: pg-data-18

  • Uygulama app

    ghcr.io/lowcarbcheck/openplate:latest

    Bu makinedeki 3000 numaralı bağlantı noktası · Birim yok

  • Çekirdek sunucu core

    ghcr.io/lowcarbcheck/openplate-core:latest

    Eşitleme API'si ve yönetici komutları için bu makinedeki 3001 bağlantı noktası · Birim yok · postgres ardından başlar

latest, en yeni sürümü izler. Bir sürümü sabitlemek için latest yerine sürüm numarasını yaz.

Veriler şu birimlerde tutulur: pg-data-18. down komutu bunları korur, down -v komutu ise siler.

.env içine ne yazılır

Başlığı şu satırları ister:

  • SERVER_SECRETzorunlu

    Şuraya aktarıldı: core → SERVER_SECRET

  • ADMIN_TOKENVarsayılan olarak boş

    Şuraya aktarıldı: core → ADMIN_TOKEN

  • PUBLIC_APP_URLVarsayılan: http://localhost:3000

    Şuraya aktarıldı: app → APP_URL, core → CLIENT_BASE_URL

  • PUBLIC_SYNC_URLVarsayılan: http://localhost:3001

    Şuraya aktarıldı: app → CORE_URL, core → SERVER_PUBLIC_URL

Bu satırlar ona nasıl erişileceğini belirler:

  • PUBLIC_INFERENCE_URLVarsayılan olarak boş

    Şuraya aktarıldı: app → DEFAULT_INFERENCE_BASE_URL

  • TRUST_PROXYVarsayılan: 1

    Şuraya aktarıldı: app → TRUST_PROXY, core → TRUST_PROXY

  • APP_PORTVarsayılan: 3000

    app için yayımlanan bağlantı noktasını değiştirir

  • SYNC_PORTVarsayılan: 3001

    core için yayımlanan bağlantı noktasını değiştirir

Bir ters proxy arkasındayken TRUST_PROXY değerini 1 olarak tut. Proxy yoksa 0 olarak ayarla.

.env dosyasından toplam 98 ayar okur. Dosya, her isteğe bağlı ayarın varsayılan değerini listeler. Her ayarın açıklaması

Komutlar

Başlat
docker compose -f compose.core.yml up -d
Güncelle
docker compose -f compose.core.yml pull docker compose -f compose.core.yml up -d
Günlük kayıtlarını izle
docker compose -f compose.core.yml logs -f
Durdur
docker compose -f compose.core.yml down

Çalıştıran makinede http://localhost:3000 adresini aç.

Telefonlar ve diğer cihazlar HTTPS gerektirir. Kendi sunucunda barındırma kılavuzu, bir alan adı olmadan bile bunu nasıl kuracağını gösterir.

Podman için, docker compose yerine podman compose çalıştır.

Dosyanın tamamını göster (454 satır)
yaml
# 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://<LAN address> 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:-}
      # Which file decides the model, the zero retention routing and the output
      # cap of each kind of request: empty (the default) keeps AI_ADVERTISED_MODEL
      # and the two settings above as the whole answer, `bundled` is the file in
      # the image, or an absolute path to a file you mount.
      AI_TIERS_FILE: ${AI_TIERS_FILE:-}
      # 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. With a
      # tier file it replaces the default tier's model (an emergency override).
      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
Konteyner aracı
mkdir -p ~/openplate && cd ~/openplate
curl -O https://raw.githubusercontent.com/LowCarbCheck/openplate/main/docker/topologies/compose.core.yml
echo "SERVER_SECRET=$(openssl rand -hex 32)" >> .env
echo "ADMIN_TOKEN=$(openssl rand -hex 32)" >> .env
echo "PUBLIC_APP_URL=https://openplate.example.com"  >> .env
echo "PUBLIC_SYNC_URL=https://sync.example.com"      >> .env
echo "TRUST_PROXY=1"                                 >> .env  # 1 behind one reverse proxy, 0 with none
docker compose -f compose.core.yml up -d
Hesaplar güvenli bir sayfaya ihtiyaç duyar. Giriş yapma, kayıt olma ve davet açma işlemleri düz http://<LAN address> üzerinde başarısız olur. Bir alan adıyla veya alan adı olmayan bir ev ağında HTTPS kullan, ya da test için bir ssh tüneli üzerinden localhost adresine bağlan. Kimse kendi başına kayıt olamaz. İlk daveti İlk hesabı oluştur belgesinde gösterildiği gibi sunucuda ADMIN_TOKEN ile üretirsin. Alan adı olmayan bir ev ağı HTTPS bağlantısını Yerel sertifikalı Caddy üzerinden alır.

Servis her girdiyi şifreli metin olarak saklar ve parolanı asla almaz. Her hesabın kurtarma kodunu kendi sırrı altında mühürlü olarak tutar, böylece unutulan bir parola bir bağlantıyla (e-posta yoluyla veya e-posta olmayan bir örnekte senin tarafınsan verilerek) sıfırlanır ve günlük geri gelir. Bu aynı zamanda servis işleticisinin prensipte üzerindeki bir günlüğü açabileceği anlamına da gelir. sync.md bu dengeyi tüm ayrıntılarıyla belirtir, eşitleme kurulumunu bitirmeden önce uygulama da bunu yapar.

Bunu etkinleştirirsen sunucu, paylaşılan bir yapay zeka faturasını da karşılayabilir. INSTANCE_MODE=managed ayarını yaparsan örnek, bir yöneticinin bir hane veya kuruluş için çalıştırdığı bir örneğe dönüşür: yöneticiler kişileri /admin üzerinden (veya yönetici API'si ile) davet eder, her hesap için günlük bir kota belirler ve oturum açılmış her tarama çekirdek sunucunun kendi yapay zeka vekili üzerinden çalışır: ayrı bir hizmet yok, ayrı bir davet linki yok. E-posta isteğe bağlıdır: /admin, e-posta gönderilsin ya da gönderilmesin, daveti her zaman bir yöneticinin kopyalayıp gönderebileceği bir link olarak gösterir. Sağlayıcı alt anahtarları yerine bunu açmanın ne zaman mantıklı olduğu konusunda configuration.md#managed-instances ve family-setup.md bölümlerine bak.

Yönetilen bir örnekte aynı sunucu tarama işlemini de üstlenir. Oturum açmış bir üye fotoğrafı yapay zeka vekiline gönderir, sunucu bunu o hesabın günlük kotasından düşer ve isteği operatörün yönlendirdiği yere iletir.

Yönetilen bir örnekte, oturum açmış üye, isteği günlük bir kotadan düşen çekirdek sunucunun yapay zeka vekili üzerinden tarama yapar.
Diyagram kaynağı
flowchart LR
  browser["Member's browser"] -->|"ciphertext"| sync["openplate-core, managed"]
  browser -->|"photo"| sync
  sync --- quota["Daily allowance per account"]
  sync -->|"photo"| upstream["Cloud provider, or inference"]

Üyelerin birbirini davet etmesine izin ver, ancak sayıyı düşük tut. Yönetilen bir örnekte, çekirdek sunucuda MEMBER_INVITE_DAILY_AI_LIMIT ve MEMBER_INVITE_ALLOWANCE_DAYS ayarlarını yap. Bu, sıradan bir üyenin önce sana sormadan birini davet etmesini sağlar. Sağlayıcı anahtarının parasını sen ödüyorsan, MEMBER_INVITE_LIFETIME_CAP=2 ayarını da yap. Varsayılan değer 5'tir, bu da yapay zeka faturasının paylaşıldığı bir örnek için uygundur. Bu değeri 2 yapmak bir partner ve bir arkadaş için yeterlidir ve büyümeyi izlenebilecek kadar yavaş tutar. configuration.md#member-invites bölümüne bak.

Kendi donanımında tara

3. basamak taramayı kendi donanımında çalıştırır. Tarayıcı fotoğrafları doğrudan çıkarım konteynerine gönderir. Tarayıcıların bu konteynerin adresini çözebilmelidir. Model her yiyeceği tanımlar ve gram cinsinden ağırlığını tahmin eder. openplate-inference, makro değerlerini yapılandırdığın yiyecek kaynağından okur.

Üçüncü basamakta tarayıcı fotoğrafı kendi çıkarım konteynerine gönderir, bu konteyner de makroları yapılandırılmış yiyecek kaynağından arar.
Diyagram kaynağı
flowchart LR
  app["openplate app"] -->|"HTML and JS"| browser["Your browser"]
  browser --- diary["Diary in this browser"]
  browser -->|"photo, browser reachable address"| inf["openplate-inference"]
  inf --- weights["Model runtime and weights"]
  inf --- usda["Configured food data, USDA by default"]

Kazancın: bir bulut yapay zeka hesabı, tarama başına ücret veya dışa giden fotoğraf trafiği olmadan yerel tabak taramaları. Konteyner imajı varsayılan olarak bir USDA FoodData Central özeti içerir, böylece openplate-inference makroları uydurmak yerine oradan arar. İşlettiğin bileşenler: bir model çalışma zamanı ve birkaç gigabaytlık ağırlık, artı uç noktasını tarayıcılarından erişilebilir kılmak için gereken her şey (fotoğraf cihaz → uç nokta şeklinde gider, bu yüzden burada bir compose ana makine adı işe yaramaz). Compose dosyası: docker/topologies/compose.inference.yml.

docker/topologies/compose.inference.yml

0.66.0 sürümünde yayınlandı

Neleri başlatır

  • Çıkarım çalışma zamanı inference

    ghcr.io/lowcarbcheck/openplate-inference:latest

    Bu makinedeki 8300 numaralı bağlantı noktası · Birim: inference-models

  • Uygulama openplate

    ghcr.io/lowcarbcheck/openplate:latest

    Bu makinedeki 3000 numaralı bağlantı noktası · Birim yok · inference ardından başlar

latest, en yeni sürümü izler. Bir sürümü sabitlemek için latest yerine sürüm numarasını yaz.

Veriler şu birimlerde tutulur: inference-models. down komutu bunları korur, down -v komutu ise siler.

.env içine ne yazılır

Başlığı şu satırları ister:

  • INFERENCE_API_KEYVarsayılan: opk_CHANGE_ME

    Şuraya aktarıldı: inference → API_KEYS, openplate → DEFAULT_INFERENCE_API_KEY

  • PUBLIC_APP_URLVarsayılan: http://openplate.example.lan:3000

    Şuraya aktarıldı: openplate → APP_URL

  • PUBLIC_INFERENCE_URLVarsayılan: http://openplate.example.lan:8300/v1

    Şuraya aktarıldı: openplate → DEFAULT_INFERENCE_BASE_URL

Bu satırlar ona nasıl erişileceğini belirler:

  • PUBLIC_SYNC_URLVarsayılan olarak boş

    Şuraya aktarıldı: openplate → CORE_URL

  • TRUST_PROXYVarsayılan: 1

    Şuraya aktarıldı: openplate → TRUST_PROXY

  • INFERENCE_PORTVarsayılan: 8300

    inference için yayımlanan bağlantı noktasını değiştirir

Bir ters proxy arkasındayken TRUST_PROXY değerini 1 olarak tut. Proxy yoksa 0 olarak ayarla.

.env dosyasından toplam 51 ayar okur. Dosya, her isteğe bağlı ayarın varsayılan değerini listeler. Her ayarın açıklaması

Komutlar

Başlat
docker compose -f compose.inference.yml up -d
Güncelle
docker compose -f compose.inference.yml pull docker compose -f compose.inference.yml up -d
Günlük kayıtlarını izle
docker compose -f compose.inference.yml logs -f
Durdur
docker compose -f compose.inference.yml down

Çalıştıran makinede http://localhost:3000 adresini aç.

Telefonlar ve diğer cihazlar HTTPS gerektirir. Kendi sunucunda barındırma kılavuzu, bir alan adı olmadan bile bunu nasıl kuracağını gösterir.

Podman için, docker compose yerine podman compose çalıştır.

Dosyanın tamamını göster (289 satır)
yaml
# openplate + openplate-inference: your own AI, on your own hardware.
#
#   mkdir -p ~/openplate && cd ~/openplate
#   curl -O https://raw.githubusercontent.com/LowCarbCheck/openplate/main/docker/topologies/compose.inference.yml
#
#   # One key, used twice: the inference service accepts it, and the app
#   # hands it to every browser. Generate it here, on this machine.
#   echo "INFERENCE_API_KEY=opk_$(openssl rand -hex 24)" >> .env
#
#   # The two URLs a BROWSER will use. Replace 192.168.1.20 with this
#   # machine's address, or with the names your reverse proxy serves.
#   echo "PUBLIC_APP_URL=http://192.168.1.20:3000" >> .env
#   echo "PUBLIC_INFERENCE_URL=http://192.168.1.20:8300/v1" >> .env
#
#   docker compose -f compose.inference.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 a `.env` beside this file is the one it reads.
#
# The first start downloads about 2 GiB of weights before the first scan can
# run. `logs -f inference` shows the progress; http://<this machine>:8300/readyz
# answers 200 once the model is loaded.
#
# Plate photos work over plain http://. Installing the app needs a secure
# page: https://, or http://localhost on this machine. See the HTTPS section
# of apps/app/docs/self-hosting.md. Once the app is on https://, the inference URL must
# be https:// too, or the browser blocks the call from the secure page.
#
#   docker compose -f compose.inference.yml logs -f inference   # weight download + model load + ready
#   docker compose -f compose.inference.yml logs -f openplate
#
# What you get: an openplate instance whose users tap ONE button to use this
# instance's own AI -- no provider account, no API key of their own, no photo
# leaving your network. Both images are published to GHCR; nothing here builds
# from source. There is still no database and no secret on the app side.
#
# "README" 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 inference-models volume are
# named after the stack rather than after whatever directory the file sits in.
name: openplate-with-inference

services:
  # -- The inference service -------------------------------------------------
  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).
  openplate:
    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.
      - '3000:3000'
    depends_on:
      - inference
    # 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://openplate.example.lan:3000}

      # 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:-}

      # Number of reverse proxies in front of this container. Behind one proxy
      # keep 1: React Router compares the browser Origin against the host it
      # thinks it serves, and without the proxy's X-Forwarded-* headers form
      # posts fail. With NO proxy set TRUST_PROXY=0 in .env: 1 would let any
      # visitor fake their address in X-Forwarded-For and dodge the
      # per-address limit on food lookups. 2 = Cloudflare in front of one.
      TRUST_PROXY: ${TRUST_PROXY:-1}

      # -- The instance preset: this is what turns into openplate's one-tap
      #    "This openplate provides its own AI" card, on the AI settings page
      #    and on the scan screen. Leave these unset and nothing renders --
      #    bring-your-own-key stays the only path.
      #
      # PUBLIC_INFERENCE_URL in .env: a BROWSER-reachable address for the
      # inference container. NOT http://inference:8300. Note the /v1 suffix.
      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}

      # -- Everything else the app reads. Empty means the default.
      #
      # The address of openplate-core, one a BROWSER can reach
      # (the same name compose.core.yml uses). Empty means no sync.
      CORE_URL: ${PUBLIC_SYNC_URL:-}
      SYNC_SERVER_URL: ${SYNC_SERVER_URL:-}
      # open (the default) or managed, which needs a core server. See
      # apps/app/docs/configuration.md, Managed instances.
      INSTANCE_MODE: ${INSTANCE_MODE:-open}
      # 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 inference service as well.
      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

volumes:
  inference-models:
    driver: local
Konteyner aracı
mkdir -p ~/openplate && cd ~/openplate
curl -O https://raw.githubusercontent.com/LowCarbCheck/openplate/main/docker/topologies/compose.inference.yml
echo "INFERENCE_API_KEY=opk_$(openssl rand -hex 24)" >> .env
echo "PUBLIC_APP_URL=http://192.168.1.20:3000" >> .env
echo "PUBLIC_INFERENCE_URL=http://192.168.1.20:8300/v1" >> .env
echo "TRUST_PROXY=0" >> .env  # 0 with no reverse proxy, 1 behind one
docker compose -f compose.inference.yml up -d

192.168.1.20 yerine sunucunun adresini yaz. Uygulama bir ters vekil sunucu arkasında HTTPS üzerinden çalışmaya başladığında, çıkarım adresi de https:// kullanmalıdır ve TRUST_PROXY, 1 olarak ayarlanmalıdır. self-hosting.md süreci adım adım anlatır.

Bu basamak iki tür kişi içindir:

  • Donanıma bizzat sahipsin. Bir GPU makinesi veya makul düzeyde güçlü bir CPU makinesi.
  • Zaten bir model çalışma zamanı çalıştırıyorsun. Halihazırda çalışan bir llama.cpp, Ollama veya GPU üzerinde vLLM kurulumun varsa, MODEL_PROFILE=external ve MODEL_RUNTIME_URL değerlerini ayarla: bu durumda openplate-inference hiçbir şey indirmez, ikinci bir model başlatmaz ve sadece elindekini sarar. Önce destek matrisi içeriğini kontrol et; vLLM'in CPU derlemesi bunu çalıştıramaz.

Donanım gerçekleri. Küçük lite profili 2.0 GiB ağırlığa sahiptir ve yalnızca CPU bulunan bir makinede AVX2 destekli 8+ modern çekirdek ve 4 GB boş RAM ister; daha büyük quality profili ise 5.8 GiB ağırlığa ve 5.8 GiB VRAM tabanına ihtiyaç duyar. CPU taramaları saniyeler ile dakikalar arasında sürer ve eşzamanlılıkla aktarım hızı artmaz: kapasiteyi makine seri çalışıyormuş gibi planla. Profil başına ölçülen sayılar openplate-inference içindeki docs/hardware.md içinde yer alıyor. Bir şey satın almadan önce bunu oku.

Çalışmaya başladığında, her kişiye bir anahtar verebilirsin (Ayarlar → Yapay Zeka → OpenAI uyumlu) veya her ziyaretçinin tek dokunuşla bağlanabilmesi için DEFAULT_INFERENCE_BASE_URL ve benzerlerini ayarlayabilirsin; ancak DEFAULT_INFERENCE_API_KEY değerinin sayfaya gömüleceğini ve uygulamayı açabilen herkes tarafından okunabileceğini unutma. configuration.md bölümüne bak.

Çekirdek sunucu ve çıkarım farklı katmanlardır

Birbirleriyle karıştırılmaları kolaydır ve birlikte çalışırlar.

  • openplate-inference hesaplama katmanıdır. bu tabakta ne var sorusunu yanıtlar. Bir model çalışma zamanı ile ağırlıkları barındırır ve donanıma ihtiyaç duyar.
  • Yönetilen bir kurulumdaki openplate-core, çoklu kiracılık katmanıdır. kimin harcama yapmasına izin var, ne kadar harcayabilir ve bu yetkiyi nasıl geri alırım sorusunu yanıtlar. Model barındırmaz ve her şeyi iletir.

Yönetilen bir örneğin yapay zeka vekilini çıkarım makinene yönlendirirsen (çıkarım hizmetinin API_KEYS değerlerinden biri UPSTREAM_API_KEY olacak şekilde, openplate-core'un UPSTREAM_BASE_URL ayarı) her ikisine de sahip olursun: kendi donanımında, önünde hesap başına kotalar bulunan taramalar. Bunun yerine onu bir bulut sağlayıcısına yönlendirirsen, donanım olmadan paylaşılan harcama elde edersin. Her iki durumda da aynı çekirdek sunucu günlüğü de taşır: senkronizasyon ve yapay zeka vekili artık iki değil, tek bir hizmettir (architecture.md).

Hepsini çalıştır

4. basamak, yukarıdaki iki basamağın bir araya getirilmiş halidir. Üzerinde yeni hiçbir şey yoktur.

Dördüncü basamak, ikinci ve üçüncü basamakların birleşimidir; her cihazda tek bir günlük ve kendi donanımında taramalar.
Diyagram kaynağı
flowchart LR
  app["openplate app"] -->|"HTML and JS"| phone["Phone"]
  app -->|"HTML and JS"| laptop["Laptop"]
  phone -->|"ciphertext"| sync["openplate-core"]
  laptop -->|"ciphertext"| sync
  sync --> db[("Postgres")]
  phone -->|"photo"| inf["openplate-inference"]
  laptop -->|"photo"| inf

Kazancın: basamak 2 ve basamak 3 birlikte (günlüğün her cihazda, kendi donanımında taranır, hiçbir üçüncü tarafa hiçbir şey gitmez). İşlettiğin bileşenler: tamamı. Uygulama, çekirdek sunucu, Postgres, model çalışma zamanı ve bunlardan ikisi için tarayıcıdan erişilebilir adresler. Compose dosyası: docker/topologies/compose.full.yml. Başlığı .env satırlarını listeler: basamak 2 ve basamak 3'ün birlikte olanları.

docker/topologies/compose.full.yml

0.66.0 sürümünde yayınlandı

Neleri başlatır

  • Veritabanı postgres

    docker.io/library/postgres:18-alpine

    Bu makinede bağlantı noktası yok · Birim: pg-data-18

  • Çıkarım çalışma zamanı inference

    ghcr.io/lowcarbcheck/openplate-inference:latest

    Bu makinedeki 8300 numaralı bağlantı noktası · Birim: inference-models

  • Uygulama app

    ghcr.io/lowcarbcheck/openplate:latest

    Bu makinedeki 3000 numaralı bağlantı noktası · Birim yok · inference ardından başlar

  • Çekirdek sunucu core

    ghcr.io/lowcarbcheck/openplate-core:latest

    Eşitleme API'si ve yönetici komutları için bu makinedeki 3001 bağlantı noktası · Birim yok · postgres ardından başlar

latest, en yeni sürümü izler. Bir sürümü sabitlemek için latest yerine sürüm numarasını yaz.

Veriler şu birimlerde tutulur: pg-data-18, inference-models. down komutu bunları korur, down -v komutu ise siler.

.env içine ne yazılır

Başlığı şu satırları ister:

  • SERVER_SECRETzorunlu

    Şuraya aktarıldı: core → SERVER_SECRET

  • ADMIN_TOKENVarsayılan olarak boş

    Şuraya aktarıldı: core → ADMIN_TOKEN

  • INFERENCE_API_KEYVarsayılan: opk_CHANGE_ME

    Şuraya aktarıldı: inference → API_KEYS, app → DEFAULT_INFERENCE_API_KEY

  • PUBLIC_APP_URLVarsayılan: http://localhost:3000

    Şuraya aktarıldı: app → APP_URL, core → CLIENT_BASE_URL

  • PUBLIC_SYNC_URLVarsayılan: http://localhost:3001

    Şuraya aktarıldı: app → CORE_URL, core → SERVER_PUBLIC_URL

  • PUBLIC_INFERENCE_URLVarsayılan: http://openplate.example.lan:8300/v1

    Şuraya aktarıldı: app → DEFAULT_INFERENCE_BASE_URL

  • TRUST_PROXYVarsayılan: 1

    Şuraya aktarıldı: app → TRUST_PROXY, core → TRUST_PROXY

Bu satırlar ona nasıl erişileceğini belirler:

  • INFERENCE_PORTVarsayılan: 8300

    inference için yayımlanan bağlantı noktasını değiştirir

  • APP_PORTVarsayılan: 3000

    app için yayımlanan bağlantı noktasını değiştirir

  • SYNC_PORTVarsayılan: 3001

    core için yayımlanan bağlantı noktasını değiştirir

Bir ters proxy arkasındayken TRUST_PROXY değerini 1 olarak tut. Proxy yoksa 0 olarak ayarla.

.env dosyasından toplam 124 ayar okur. Dosya, her isteğe bağlı ayarın varsayılan değerini listeler. Her ayarın açıklaması

Komutlar

Başlat
docker compose -f compose.full.yml up -d
Güncelle
docker compose -f compose.full.yml pull docker compose -f compose.full.yml up -d
Günlük kayıtlarını izle
docker compose -f compose.full.yml logs -f
Durdur
docker compose -f compose.full.yml down

Çalıştıran makinede http://localhost:3000 adresini aç.

Telefonlar ve diğer cihazlar HTTPS gerektirir. Kendi sunucunda barındırma kılavuzu, bir alan adı olmadan bile bunu nasıl kuracağını gösterir.

Podman için, docker compose yerine podman compose çalıştır.

Dosyanın tamamını göster (600 satır)
yaml
# 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://<LAN address> 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:-}
      # Which file decides the model, the zero retention routing and the output
      # cap of each kind of request: empty (the default) keeps AI_ADVERTISED_MODEL
      # and the two settings above as the whole answer, `bundled` is the file in
      # the image, or an absolute path to a file you mount.
      AI_TIERS_FILE: ${AI_TIERS_FILE:-}
      # 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. With a
      # tier file it replaces the default tier's model (an emergency override).
      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

Podman bunu aynı şekilde çalıştırır: podman compose -f compose.full.yml up -d.

Bu basamakta öğrenilecek yeni bir şey yoktur. Aynı SERVER_SECRET, aynı yedekleme zorunluluğu ve aynı asgari donanım gereksinimi ile yukarıdaki ikisinin birleşimidir.

Tek bir sunucuyu kendi başına çalıştır

Diğer iki compose dosyası, uygulamayı içermeden tek bir sunucu çalıştırır: veritabanıyla birlikte çekirdek sunucu ve çıkarım çalışma zamanı. İlgili bölüm kendi makinesinde çalıştığında bunlardan birini kullan.

Yalnızca çekirdek sunucu

apps/core/docker/compose.yml

0.36.0 sürümünde yayınlandı

Bu dosya, core hizmetlerini deponun bir kopyasından derler. Bu dosyayı indirmek yerine depoyu klonla. Başlık kısmı nasıl çalıştırılacağını gösterir.

Neleri başlatır

  • Veritabanı postgres

    docker.io/library/postgres:18-alpine

    Bu makinede bağlantı noktası yok · Birim: postgres-data-18

  • Çekirdek sunucu core

    Kaynaktan derlendi, bağlam .

    Eşitleme API'si ve yönetici komutları için bu makinedeki 3000 bağlantı noktası · Birim yok · postgres ardından başlar

Veriler şu birimlerde tutulur: postgres-data-18. down komutu bunları korur, down -v komutu ise siler.

.env içine ne yazılır

Bu satırlar olmadan Compose bunu başlatmaz:

  • SERVER_SECRETzorunlu

    Şuraya aktarıldı: core → SERVER_SECRET

Bu satırlar ona nasıl erişileceğini belirler:

  • TRUST_PROXYVarsayılan: 0

    Şuraya aktarıldı: core → TRUST_PROXY

  • SYNC_PORTVarsayılan: 3000

    core için yayımlanan bağlantı noktasını değiştirir

Bir ters proxy arkasındayken TRUST_PROXY değerini 1 olarak tut. Proxy yoksa 0 olarak ayarla.

.env dosyasından toplam 79 ayar okur. Dosya, her isteğe bağlı ayarın varsayılan değerini listeler. Her ayarın açıklaması

Dosyanın tamamını göster (264 satır)
yaml
# Self-hoster quickstart: Postgres + the core server, nothing else.
#
# If you pulled the published image you can copy this one file anywhere and
# run `docker compose up -d` beside it. From a checkout, the file lives in
# `docker/`, so run it from the repository root like this:
#
#   cp .env.example .env
#   # edit .env, SERVER_SECRET is the only mandatory value
#   docker compose --project-directory . -f docker/compose.yml up -d
#
# `--project-directory .` is not decoration. Without it, Compose treats
# `docker/` as the project directory: it looks for `.env` there instead of at
# the repository root, and resolves `build: .` to `docker/` instead of to the
# checkout. With it, both point at the repository root, which is what the
# quickstart above assumes. (The project name is pinned below, so that part
# does not depend on where you run from.)
#
# An account is an EMAIL ADDRESS plus a passphrase, and it is created by
# redeeming an invite you addressed to somebody. Signup is invite-only,
# always. Mail is optional. If none is configured, an invite comes back as a
# link for you to paste instead of an email. SMTP_SECURE and the old
# PIGEON_* names cause a BOOT FAILURE, not a no-op. See .env.example.
#
#   docker compose --project-directory . -f docker/compose.yml logs -f core
#
# NOTE FOR CONTRIBUTORS: this file is for SELF-HOSTERS. Local development and
# the integration suite use the shared workspace Postgres (see
# `tests/integration/db-harness.ts`), never this database. If you are an
# outside contributor with no shared Postgres to point at, bring up
# `docker/compose.dev.yml` instead. It is a test database only, and it is the
# one thing in `docker/` a self-hoster can ignore entirely.

# Pin the Compose project name. Without it Compose names the project after
# this file's parent directory, "docker", and every container and volume
# inherits that.
name: openplate-core

services:
  # Postgres 18. The volume is `postgres-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 `postgres-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: ${POSTGRES_DB:-openplate_sync}
    volumes:
      - postgres-data-18:/var/lib/postgresql
    healthcheck:
      test: ['CMD-SHELL', 'pg_isready -U ${POSTGRES_USER:-openplate} -d ${POSTGRES_DB:-openplate_sync}']
      interval: 5s
      timeout: 5s
      retries: 10
    # Not published by default: nothing outside this compose network has any
    # business reaching the database.
    expose:
      - '5432'

  core:
    build: .
    # Or pull the published image instead of building:
    # image: ghcr.io/lowcarbcheck/openplate-core:latest
    restart: unless-stopped
    depends_on:
      postgres:
        condition: service_healthy
    environment:
      # Fixed: the published mapping below targets container port 3000, so a
      # PORT set in .env would only move the listener away from it. Change the
      # host side with SYNC_PORT instead.
      PORT: 3000
      DATABASE_URL: postgres://${POSTGRES_USER:-openplate}:${POSTGRES_PASSWORD:-openplate}@postgres:5432/${POSTGRES_DB:-openplate_sync}
      SERVER_SECRET: ${SERVER_SECRET:?set SERVER_SECRET in .env, see .env.example}
      # Signup is invite-only unless OPEN_SIGNUP is set below. SIGNUP_MODE and
      # the older SIGNUPS_OPEN are both boot failures, so neither is forwarded
      # here. Mint an invite with `pnpm core-api invites create --email …`.
      #
      # 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.
      INSTANCE_NAME: ${INSTANCE_NAME:-openplate}
      INSTANCE_LANGUAGE: ${INSTANCE_LANGUAGE:-en}
      # Which body's micronutrient reference values it shows: dge | efsa | us.
      # Only the BOOT default: an administrator changes the live setting with
      # `pnpm core-api settings set nutrient-reference-basis efsa`, and the
      # stored row wins from then on.
      NUTRIENT_REFERENCE_BASIS: ${NUTRIENT_REFERENCE_BASIS:-dge}
      # The version of the health-data consent every account must agree to, a
      # short string such as 2026-09-28. Empty, the self-hosted default, asks
      # for no consent. Changing it asks every account again.
      HEALTH_CONSENT_VERSION: ${HEALTH_CONSENT_VERSION:-}
      # Both or neither: they build the link in an invitation and in a
      # password-reset mail. With neither, the admin API returns the raw token.
      SERVER_PUBLIC_URL: ${SERVER_PUBLIC_URL:-}
      CLIENT_BASE_URL: ${CLIENT_BASE_URL:-}
      # Set to the number of reverse proxies in front of this service, or the
      # per-IP throttle collapses into one bucket anyone can lock for everyone.
      TRUST_PROXY: ${TRUST_PROXY:-0}
      # debug, info, warn or error. Empty is refused, so the default stays.
      LOG_LEVEL: ${LOG_LEVEL:-info}
      # The address the listener binds to inside the container. Leave it empty:
      # the published port reaches only a listener on every interface.
      HOST: ${HOST:-}
      # The folder of letter texts, mounted read-only, see .env.example. Set it
      # to the container path of the volume line under `volumes:` below.
      CONTENT_DIR: ${CONTENT_DIR:-}
      # The daily ceilings on declaration receipts, for the instance and per
      # sender network. See .env.example.
      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 volume line below.
      NODE_EXTRA_CA_CERTS: ${NODE_EXTRA_CA_CERTS:-}
      # Every variable this service reads is forwarded here, so a line in
      # `.env` is all it takes. Empty means the default.
      ADMIN_TOKEN: ${ADMIN_TOKEN:-}
      SYNC_SHARING: ${SYNC_SHARING:-false}
      SYNC_RESEARCH: ${SYNC_RESEARCH:-false}
      # Reported estimates. 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.
      SYNC_FEEDBACK: ${SYNC_FEEDBACK:-false}
      FEEDBACK_DAILY_LIMIT: ${FEEDBACK_DAILY_LIMIT:-5}
      FEEDBACK_MAX_REQUEST_BYTES: ${FEEDBACK_MAX_REQUEST_BYTES:-8000000}
      # The operator notice: one short sentence /health publishes and the
      # client shows. Empty means no notice. SYNC_NOTICE_URL without
      # SYNC_NOTICE is a boot failure. See .env.example for the length cap.
      SYNC_NOTICE: ${SYNC_NOTICE:-}
      SYNC_NOTICE_URL: ${SYNC_NOTICE_URL:-}
      # Mail: one transport or none, see .env.example. Unset means invitations
      # and resets come back to you as links instead of being sent. A
      # cancellation or a withdrawal is recorded but mailed to nobody. The
      # HTTP mail API uses the three MAIL_API_* values. SMTP uses the five
      # SMTP_* values. Both at once is a boot failure. MAIL_OPERATOR_EMAIL
      # belongs to both.
      MAIL_API_URL: ${MAIL_API_URL:-}
      MAIL_API_KEY: ${MAIL_API_KEY:-}
      MAIL_API_FROM: ${MAIL_API_FROM:-}
      SMTP_HOST: ${SMTP_HOST:-}
      SMTP_PORT: ${SMTP_PORT:-}
      SMTP_USER: ${SMTP_USER:-}
      SMTP_PASSWORD: ${SMTP_PASSWORD:-}
      SMTP_FROM: ${SMTP_FROM:-}
      MAIL_OPERATOR_EMAIL: ${MAIL_OPERATOR_EMAIL:-}
      # The AI proxy: both or neither. Unset means POST /v1/chat/completions
      # answers the ordinary unknown-path 404 and /health reports no AI at all.
      # The per-account daily allowance is in the database, not here.
      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:-}
      UPSTREAM_TIMEOUT_MS: ${UPSTREAM_TIMEOUT_MS:-120000}
      # Which file decides the model, the zero retention routing and the output
      # cap of each kind of request: empty (the default) keeps AI_ADVERTISED_MODEL
      # and the two settings above as the whole answer, `bundled` is the file in
      # the image, or an absolute path to a file you mount.
      AI_TIERS_FILE: ${AI_TIERS_FILE:-}
      # The model the proxy sends every request to. Empty passes the caller's.
      # With a tier file it replaces the default tier's model (an emergency override).
      AI_ADVERTISED_MODEL: ${AI_ADVERTISED_MODEL:-}
      # The most output tokens one request may ask for, with or without a model.
      AI_MAX_OUTPUT_TOKENS: ${AI_MAX_OUTPUT_TOKENS:-8192}
      AI_RATE_LIMIT_PER_MINUTE: ${AI_RATE_LIMIT_PER_MINUTE:-20}
      # Sized for a camera photograph after base64, NOT for a stored blob.
      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}
      # 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:-}
      # Members inviting people: the first two together or neither, and the
      # cap only with the pair. Empty means only an administrator 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:-}
      # Open sign-up: empty means invite-only. "true" needs the mail block
      # above. The Turnstile pair is both or neither, and only with it.
      OPEN_SIGNUP: ${OPEN_SIGNUP:-}
      TURNSTILE_SECRET_KEY: ${TURNSTILE_SECRET_KEY:-}
      TURNSTILE_SITE_KEY: ${TURNSTILE_SITE_KEY:-}
      # Free AI scans for new accounts: the pair together or neither, and the
      # pepper with them. MEMBER_INVITE_TRIAL=true makes a member invitation
      # grant the scans instead of the day pair above. Empty means no trial.
      # TRIAL_DAYS, only beside the pair, also ends the trial at midnight after
      # that many days, whichever comes first. Empty means no end date.
      # TRIAL_TIME_ZONE, only beside TRIAL_DAYS, is the zone of that midnight
      # (an IANA name such as Europe/Berlin). Empty means UTC.
      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 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. 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: 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: the URL and the secret together or neither, and only with
      # a billing service behind this instance. BILLING_TOKEN is that service's
      # own scoped 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:-}
      # Only for an EXTERNAL database. The bundled Postgres above speaks plain
      # TCP on the compose network.
      DATABASE_SSL: ${DATABASE_SSL:-false}
    healthcheck:
      # The image bakes a similar 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.
      #
      # A shell line with no quotes and no brackets, run by the image's
      # busybox wget. Docker Compose, podman-compose and Quadlet all pass it
      # through the same way; the old `node -e "fetch(...)"` array form
      # reached podman-compose 1.0.6 as a broken shell line and stayed
      # unhealthy forever (the same fix as openplate's compose files).
      test: ['CMD-SHELL', 'wget -q -O /dev/null http://127.0.0.1:3000/health']
      interval: 30s
      timeout: 5s
      start_period: 20s
      retries: 3
    ports:
      - '${SYNC_PORT:-3000}:3000'
    # Uncomment what you use, and set the matching variable in `.env`:
    #   CONTENT_DIR=/srv/openplate/content
    #   NODE_EXTRA_CA_CERTS=/etc/openplate/ca.pem
    # volumes:
    #   - ./content:/srv/openplate/content:ro
    #   - ./ca.pem:/etc/openplate/ca.pem:ro

volumes:
  postgres-data-18:
    driver: local

Yalnızca çıkarım çalışma zamanı

apps/inference/docker/compose.yml

0.3.0 sürümünde yayınlandı

Neleri başlatır

  • Çıkarım çalışma zamanı inference

    ghcr.io/lowcarbcheck/openplate-inference:latest

    Bu makinedeki 8300 numaralı bağlantı noktası · Birim: inference-models

latest, en yeni sürümü izler. Bir sürümü sabitlemek için latest yerine sürüm numarasını yaz.

Veriler şu birimlerde tutulur: inference-models. down komutu bunları korur, down -v komutu ise siler.

.env içine ne yazılır

Başlamak için bir .env dosyasına ihtiyaç duymaz.

.env dosyasından toplam 27 ayar okur. Dosya, her isteğe bağlı ayarın varsayılan değerini listeler. Her ayarın açıklaması

Komutlar

Başlat
docker compose -f compose.yml up -d
Güncelle
docker compose -f compose.yml pull docker compose -f compose.yml up -d
Günlük kayıtlarını izle
docker compose -f compose.yml logs -f
Durdur
docker compose -f compose.yml down

Podman için, docker compose yerine podman compose çalıştır.

Dosyanın tamamını göster (145 satır)
yaml
# openplate-inference, on its own.
#
# A single-service compose file for running the inference endpoint by itself:
# a plate scanner any OpenAI-compatible client can call. Copy it into a folder
# of its own, put a `.env` beside it with at least API_KEYS, and run
# `docker compose up -d` there. Every variable the service reads is forwarded
# below, so any line of `.env.example` works in that `.env`.
#
# From a checkout, run it from the openplate-inference folder, so the `.env`
# there is the one Compose reads:
#
#   docker compose --project-directory . -f docker/compose.yml up -d
#   docker compose --project-directory . -f docker/compose.yml logs -f inference   # weight download + model load + ready
#
# Want to run this TOGETHER with the openplate app? That two-service topology
# lives in the openplate repo, not here:
#   https://github.com/LowCarbCheck/openplate/blob/main/docker/topologies/compose.inference.yml

# Pins the Compose project name. Without it, `-f docker/compose.yml` makes
# Compose derive the project from the containing directory, so the stack and
# the multi-GB models volume both get named "docker".
name: openplate-inference

services:
  inference:
    image: ghcr.io/lowcarbcheck/openplate-inference:latest
    # Or build it yourself:
    #   build: { context: ., args: { BASE_IMAGE: ghcr.io/ggml-org/llama.cpp:server } }
    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.
      - "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}
      # SET THIS in `.env`: a stable key callers must present. The placeholder
      # only keeps a first trial booting. Any random string:
      #   echo "API_KEYS=opk_$(openssl rand -hex 24)" >> .env
      API_KEYS: ${API_KEYS:-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/build to the -cuda base. 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 API_KEYS, 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

volumes:
  inference-models:
    driver: local