# 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