From b22fa673750e4d6b4d49045a4ca954803c12bf0f Mon Sep 17 00:00:00 2001 From: rainxchzed Date: Mon, 25 May 2026 16:56:15 +0500 Subject: [PATCH 1/2] ops: define paid-app service in docker-compose.prod.yml (reproducible deploy) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The paid-backend deploy script runs `docker compose ... up -d --build paid-app` against this compose file, but the paid-app service was never defined here — operators were hand-editing the service block on the VPS to make deploys work. This made the production topology untracked by git, which is exactly the kind of drift a perf audit catches. Service block: - Builds from ../komi-paid-backend (sibling deploy dir on the VPS). - Shares the existing postgres container; isolates via komistore_paid DB. - mem_limit 1.5g — tight but defensible (see comment for the 8 GB budget). - read_only rootfs + tmpfs /tmp matches the free-tier app hardening. - Env vars cover Stripe, R2, Cloudflare KV signing keys, and the internal-auth allowlist; sensible defaults so unset values fall back cleanly without breaking the boot of free-tier app. .env.example documents every new key so operator setup is self-serve. --- .env.example | 37 ++++++++++++++++++++++++ docker-compose.prod.yml | 62 +++++++++++++++++++++++++++++++++++++++++ 2 files changed, 99 insertions(+) diff --git a/.env.example b/.env.example index 4685eb8..da12931 100644 --- a/.env.example +++ b/.env.example @@ -215,3 +215,40 @@ BACKUP_GPG_PASSPHRASE= # Optional healthcheck.io ping URL hit on successful backup. Unset → # script does not ping. HEALTHCHECK_URL= + +# ===================================================================== +# Komi Store paid-tier backend (paid-app service in docker-compose.prod.yml) +# ===================================================================== +# At minimum DB_PASSWORD + KOMI_INTERNAL_TOKEN + STRIPE_SECRET_KEY + +# STRIPE_WEBHOOK_SECRET + a key-source path are required for paid-app to boot. + +# Postgres credentials for komistore_paid (separate DB on the same container). +KOMI_PAID_DB_PASSWORD= + +# Internal-auth shared secret with the future website backend. +KOMI_INTERNAL_TOKEN= +# Comma-separated Host header allowlist for the internal-auth path. +INTERNAL_AUTH_ALLOWED_HOSTS=api.komistore.app + +# Signing-key source: "cloudflare_kv" (production default) or "env" (dev). +KOMI_KEY_SOURCE=cloudflare_kv +CF_ACCOUNT_ID= +CF_KV_NAMESPACE_ID= +CF_API_TOKEN= +# env mode fallback only. +ED25519_PRIVATE_KEY= +ED25519_PUBLIC_KEY= + +# Stripe. +STRIPE_SECRET_KEY= +STRIPE_WEBHOOK_SECRET= +STRIPE_PRICE_ID_LIBRARY_SYNC_MONTHLY= +STRIPE_PRICE_ID_LIBRARY_SYNC_YEARLY= +STRIPE_PRICE_ID_DEV_PRO_MONTHLY= +STRIPE_PRICE_ID_DEV_PRO_YEARLY= + +# Cloudflare R2 for encrypted sync bundles. +KOMI_STORAGE_BUCKET=komi-sync +KOMI_STORAGE_ENDPOINT=https://.r2.cloudflarestorage.com +KOMI_STORAGE_ACCESS_KEY_ID= +KOMI_STORAGE_SECRET_ACCESS_KEY= diff --git a/docker-compose.prod.yml b/docker-compose.prod.yml index ac509b1..018f5c3 100644 --- a/docker-compose.prod.yml +++ b/docker-compose.prod.yml @@ -142,6 +142,68 @@ services: max-size: "50m" max-file: "5" + # Komi Store paid-tier backend. Lives at /opt/komi-paid-backend on the VPS, + # synced there by paid-backend/deploy.sh. Shares postgres + caddy from this + # stack so the two backends sit on one VPS without duplicating infra. + # Separate database (komistore_paid) created manually before first deploy. + # + # mem_limit set to 1.5g: VPS is 8 GB, current ceilings are postgres 3g + + # meilisearch 2g + app 2g + caddy 256m + vector 256m = 7.5g committed. + # paid-app at 1.5g brings the sum to 9g (1g overcommit). Mem limits are + # caps not reservations so the kernel handles it unless every container + # peaks simultaneously — operator should reduce free-tier app to 1.5g if + # OOM-kill events start showing up. + paid-app: + build: ../komi-paid-backend + restart: unless-stopped + mem_limit: 1.5g + read_only: true + tmpfs: + - /tmp + environment: + # Database — shared postgres container, isolated paid-tier database. + DATABASE_URL: jdbc:postgresql://postgres:5432/komistore_paid + DATABASE_USER: komistore_paid + DATABASE_PASSWORD: ${KOMI_PAID_DB_PASSWORD} + # Internal-auth shared secret with the website backend (when it exists). + KOMI_INTERNAL_TOKEN: ${KOMI_INTERNAL_TOKEN} + INTERNAL_AUTH_ALLOWED_HOSTS: ${INTERNAL_AUTH_ALLOWED_HOSTS} + # Ed25519 signing keys — pulled from Cloudflare Workers KV at boot. + KEY_SOURCE: ${KOMI_KEY_SOURCE:-cloudflare_kv} + CF_ACCOUNT_ID: ${CF_ACCOUNT_ID:-} + CF_KV_NAMESPACE_ID: ${CF_KV_NAMESPACE_ID:-} + CF_API_TOKEN: ${CF_API_TOKEN:-} + # Env-mode fallback for the signing key (only used when KEY_SOURCE=env). + ED25519_PRIVATE_KEY: ${ED25519_PRIVATE_KEY:-} + ED25519_PUBLIC_KEY: ${ED25519_PUBLIC_KEY:-} + # Stripe billing. + STRIPE_SECRET_KEY: ${STRIPE_SECRET_KEY} + STRIPE_WEBHOOK_SECRET: ${STRIPE_WEBHOOK_SECRET} + STRIPE_PRICE_ID_LIBRARY_SYNC_MONTHLY: ${STRIPE_PRICE_ID_LIBRARY_SYNC_MONTHLY:-} + STRIPE_PRICE_ID_LIBRARY_SYNC_YEARLY: ${STRIPE_PRICE_ID_LIBRARY_SYNC_YEARLY:-} + STRIPE_PRICE_ID_DEV_PRO_MONTHLY: ${STRIPE_PRICE_ID_DEV_PRO_MONTHLY:-} + STRIPE_PRICE_ID_DEV_PRO_YEARLY: ${STRIPE_PRICE_ID_DEV_PRO_YEARLY:-} + # Cloudflare R2 for E2E-encrypted sync bundles. + STORAGE_BUCKET: ${KOMI_STORAGE_BUCKET:-komi-sync} + STORAGE_ENDPOINT: ${KOMI_STORAGE_ENDPOINT} + STORAGE_ACCESS_KEY_ID: ${KOMI_STORAGE_ACCESS_KEY_ID} + STORAGE_SECRET_ACCESS_KEY: ${KOMI_STORAGE_SECRET_ACCESS_KEY} + APP_ENV: production + depends_on: + postgres: + condition: service_healthy + healthcheck: + test: ["CMD", "wget", "-q", "-O-", "http://localhost:8080/health"] + interval: 10s + timeout: 5s + retries: 3 + start_period: 30s + logging: + driver: "json-file" + options: + max-size: "50m" + max-file: "5" + caddy: image: caddy:2-alpine restart: unless-stopped From 2a8d0f8e58e8c98e1ca1e6a5155c4a44353b12a9 Mon Sep 17 00:00:00 2001 From: rainxchzed Date: Tue, 26 May 2026 14:52:28 +0500 Subject: [PATCH 2/2] ops: paid-app KEY_SOURCE compose default 'env' not 'cloudflare_kv' (Greptile) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Greptile flagged the silent-failure path: ${KOMI_KEY_SOURCE:-cloudflare_kv} combined with empty defaults for CF_ACCOUNT_ID / CF_KV_NAMESPACE_ID / CF_API_TOKEN means an operator who follows the minimum env checklist but forgets the Cloudflare vars boots a healthy-looking container that 404s on every signing-key fetch. Switching the compose fallback to KEY_SOURCE=env makes the missing configuration surface immediately as 'env var ED25519_PRIVATE_KEY not set' — a clearly-named failure tied to the actual code path. Production deploys (which use CF KV) just set KOMI_KEY_SOURCE=cloudflare_kv explicitly in /opt/github-store-backend/.env. The paid-backend's SigningConfig.from additionally fail-fasts on empty CF vars when KEY_SOURCE=cloudflare_kv, so a half-configured CF deploy crashes at AppConfig.from() not at signing time. Healthcheck path stands: paid-backend serves bare /health (verified at Application.kt:195), distinct from free-tier /v1/health. --- .env.example | 5 ++++- docker-compose.prod.yml | 11 +++++++++-- 2 files changed, 13 insertions(+), 3 deletions(-) diff --git a/.env.example b/.env.example index da12931..d26bd44 100644 --- a/.env.example +++ b/.env.example @@ -230,7 +230,10 @@ KOMI_INTERNAL_TOKEN= # Comma-separated Host header allowlist for the internal-auth path. INTERNAL_AUTH_ALLOWED_HOSTS=api.komistore.app -# Signing-key source: "cloudflare_kv" (production default) or "env" (dev). +# Signing-key source: "cloudflare_kv" (recommended for production) or "env". +# Compose default is "env" so a missing value fails fast on a clearly-named +# env var rather than booting cleanly and silently 404'ing on the first CF KV +# fetch. Production VPS should explicitly set this to "cloudflare_kv". KOMI_KEY_SOURCE=cloudflare_kv CF_ACCOUNT_ID= CF_KV_NAMESPACE_ID= diff --git a/docker-compose.prod.yml b/docker-compose.prod.yml index 018f5c3..df6d133 100644 --- a/docker-compose.prod.yml +++ b/docker-compose.prod.yml @@ -168,8 +168,15 @@ services: # Internal-auth shared secret with the website backend (when it exists). KOMI_INTERNAL_TOKEN: ${KOMI_INTERNAL_TOKEN} INTERNAL_AUTH_ALLOWED_HOSTS: ${INTERNAL_AUTH_ALLOWED_HOSTS} - # Ed25519 signing keys — pulled from Cloudflare Workers KV at boot. - KEY_SOURCE: ${KOMI_KEY_SOURCE:-cloudflare_kv} + # Ed25519 signing keys. Default is `env` so a missing KOMI_KEY_SOURCE + # surfaces immediately as "env var ED25519_PRIVATE_KEY not set" rather + # than booting cleanly and silently failing at first KV fetch. Operators + # that want CF KV (production setup) must set KOMI_KEY_SOURCE=cloudflare_kv + # in /opt/github-store-backend/.env. The paid-backend SigningConfig + # additionally fail-fasts on empty CF_ACCOUNT_ID / CF_KV_NAMESPACE_ID + # when KEY_SOURCE=cloudflare_kv, so a half-configured CF deploy crashes + # at AppConfig.from(), not at signing time. + KEY_SOURCE: ${KOMI_KEY_SOURCE:-env} CF_ACCOUNT_ID: ${CF_ACCOUNT_ID:-} CF_KV_NAMESPACE_ID: ${CF_KV_NAMESPACE_ID:-} CF_API_TOKEN: ${CF_API_TOKEN:-}