services: web: build: context: . dockerfile: Dockerfile args: # NEXT_PUBLIC_* values are inlined into the bundle, so they are build args: changing one # needs `docker compose build web`. NEXT_PUBLIC_BACKEND_URL: ${NEXT_PUBLIC_BACKEND_URL:-http://localhost:8080/api/v1} NEXT_PUBLIC_SITE_URL: ${NEXT_PUBLIC_SITE_URL:-https://veriworkly.com} NEXT_PUBLIC_VITALS_URL: ${NEXT_PUBLIC_VITALS_URL:-} NODE_ENV: ${NODE_ENV:-production} environment: NODE_ENV: ${NODE_ENV:-production} PORT: 3000 NEXT_PUBLIC_BACKEND_URL: ${NEXT_PUBLIC_BACKEND_URL:-http://localhost:8080/api/v1} BACKEND_INTERNAL_URL: ${BACKEND_INTERNAL_URL:-http://api:8080/api/v1} # Read at runtime. Production defaults to off; keep them equal to the API's flags. AFFILIATE_PROGRAM_ENABLED: ${AFFILIATE_PROGRAM_ENABLED:-false} AMBASSADOR_PROGRAM_ENABLED: ${AMBASSADOR_PROGRAM_ENABLED:-false} ERROR_REPORT_URL: ${ERROR_REPORT_URL:-} depends_on: api: condition: service_healthy ports: # Loopback only: production is VPS + nginx, and nginx on the host is the only client. - "127.0.0.1:3000:3000" healthcheck: test: [ "CMD", "node", "-e", "fetch('http://127.0.0.1:3000').then((r)=>process.exit(r.ok?0:1)).catch(()=>process.exit(1))", ] interval: 1m timeout: 10s retries: 3 start_period: 40s # Server errors go to stderr when ERROR_REPORT_URL is unset; cap what an outage can write. logging: driver: json-file options: max-size: "10m" max-file: "3" restart: unless-stopped api: build: # apps/server/Dockerfile expects the repository root as its build context so it can # install the npm workspace from the root package-lock. context: . dockerfile: apps/server/Dockerfile # Every variable the server refuses to boot without in production is a `${VAR:?}` here, so # `docker compose up` names the missing ones before the container starts. The server's own # boot validation (src/config/environmentSchema.ts) applies the format rules on top: https # and no localhost for public URLs, TRUST_PROXY as a hop count or CIDR list, non-example # secrets. NOTE: do not pass a boolean as a bare `${VAR}`: an empty string parses as false, # which silently overrides the production default. environment: NODE_ENV: ${NODE_ENV:-production} PORT: 8080 DATABASE_URL: ${DATABASE_URL:?DATABASE_URL is required} DB_POOL_MAX_TOTAL: ${DB_POOL_MAX_TOTAL:-80} # DB_IDLE_IN_TRANSACTION_TIMEOUT_MS is opt-in and deliberately absent: it is sent as a # startup parameter, which a pooler such as Neon's PgBouncer refuses. Add it here only for # a direct Postgres connection; behind a pooler, set it on the role (server README). ALLOWED_ORIGINS: ${ALLOWED_ORIGINS:?ALLOWED_ORIGINS is required (comma-separated https origins)} # Proxy / process model. The API port is bound to 127.0.0.1 below, so the host's nginx is # the only way in: TRUST_PROXY=1 for nginx alone, 2 with Cloudflare in front of nginx. TRUST_PROXY: ${TRUST_PROXY:?TRUST_PROXY is required (proxy hop count, e.g. 1 behind nginx, or a CIDR list)} DIRECT_EXPOSURE: ${DIRECT_EXPOSURE:-} WEB_CONCURRENCY: ${WEB_CONCURRENCY:?WEB_CONCURRENCY is required (number of cluster workers)} # Redis. REDIS_PASSWORD goes verbatim into REDIS_URL, --requirepass and REDISCLI_AUTH, so use # only [A-Za-z0-9] characters (e.g. `openssl rand -hex 32`); URL-encoding would break auth. REDIS_URL: ${REDIS_URL:-redis://:${REDIS_PASSWORD:?REDIS_PASSWORD is required}@redis:6379} # Better Auth Core AUTH_SECRET: ${AUTH_SECRET:?AUTH_SECRET is required (at least 32 characters)} AUTH_BASE_URL: ${AUTH_BASE_URL:?AUTH_BASE_URL is required (public https URL of this API)} # The studio and portfolio reach the API as http://api:8080 (BACKEND_INTERNAL_URL); the auth # routes refuse any other Host than AUTH_BASE_URL's and loopback (V-155). ALLOWED_HOSTS: ${ALLOWED_HOSTS:-api} AUTH_COOKIE_DOMAIN: ${AUTH_COOKIE_DOMAIN} STUDIO_URL: ${STUDIO_URL} STUDIO_LOGIN_URL: ${STUDIO_LOGIN_URL} SITE_URL: ${SITE_URL} # Better Auth Sessions AUTH_SESSION_TTL_SECONDS: ${AUTH_SESSION_TTL_SECONDS:-2592000} AUTH_SESSION_RESET_TTL_ON_USE: ${AUTH_SESSION_RESET_TTL_ON_USE:-86400} AUTH_SESSION_CACHE_MAX_AGE_SECONDS: ${AUTH_SESSION_CACHE_MAX_AGE_SECONDS:-900} # Auth OTP & Admin AUTH_OTP_TTL_SECONDS: ${AUTH_OTP_TTL_SECONDS:-300} AUTH_OTP_ALLOWED_ATTEMPTS: ${AUTH_OTP_ALLOWED_ATTEMPTS:-3} ADMIN_EMAIL: ${ADMIN_EMAIL:?ADMIN_EMAIL is required (the operator account email)} # Email / SMTP. "console" writes OTP codes into the logs and is refused in production. AUTH_EMAIL_PROVIDER: ${AUTH_EMAIL_PROVIDER:-resend} AUTH_EMAIL_FROM: ${AUTH_EMAIL_FROM} RESEND_API_KEY: ${RESEND_API_KEY} AUTH_SMTP_HOST: ${AUTH_SMTP_HOST} AUTH_SMTP_PORT: ${AUTH_SMTP_PORT:-587} AUTH_SMTP_SECURE: ${AUTH_SMTP_SECURE:-false} AUTH_SMTP_USER: ${AUTH_SMTP_USER} AUTH_SMTP_PASS: ${AUTH_SMTP_PASS} # Secrets that must not be the example values API_KEY_HASH_SECRET: ${API_KEY_HASH_SECRET:?API_KEY_HASH_SECRET is required (distinct from AUTH_SECRET)} ANALYTICS_HASH_PEPPER: ${ANALYTICS_HASH_PEPPER:?ANALYTICS_HASH_PEPPER is required} # Rate Limiting RATE_LIMIT_WINDOW_MS: ${RATE_LIMIT_WINDOW_MS:-900000} RATE_LIMIT_MAX_REQUESTS: ${RATE_LIMIT_MAX_REQUESTS:-100} # AI AI_API_KEY: ${AI_API_KEY} AI_BASE_URL: ${AI_BASE_URL} AI_ACTIONS_POLICY_PATH: ${AI_ACTIONS_POLICY_PATH} AI_ACTIONS_POLICY_JSON: ${AI_ACTIONS_POLICY_JSON} ATS_AI_POLICY_PATH: ${ATS_AI_POLICY_PATH} ATS_AI_POLICY_JSON: ${ATS_AI_POLICY_JSON} ATS_ENGINE_POLICY_PATH: ${ATS_ENGINE_POLICY_PATH} ATS_ENGINE_POLICY_JSON: ${ATS_ENGINE_POLICY_JSON} # Portfolio PORTFOLIO_URL: ${PORTFOLIO_URL:?PORTFOLIO_URL is required (public https URL)} PORTFOLIO_REVALIDATE_SECRET: ${PORTFOLIO_REVALIDATE_SECRET:?PORTFOLIO_REVALIDATE_SECRET is required} PORTFOLIO_GRACE_DAYS: ${PORTFOLIO_GRACE_DAYS:-7} # Billing DODO_PAYMENTS_API_KEY: ${DODO_PAYMENTS_API_KEY:?DODO_PAYMENTS_API_KEY is required} DODO_PAYMENTS_WEBHOOK_SECRET: ${DODO_PAYMENTS_WEBHOOK_SECRET:?DODO_PAYMENTS_WEBHOOK_SECRET is required} DODO_PAYMENTS_ENVIRONMENT: ${DODO_PAYMENTS_ENVIRONMENT:-test_mode} DODO_PAYMENTS_CHECKOUT_RETURN_URL: ${DODO_PAYMENTS_CHECKOUT_RETURN_URL:?DODO_PAYMENTS_CHECKOUT_RETURN_URL is required (public https URL)} DODO_PAYMENTS_CHECKOUT_CANCEL_URL: ${DODO_PAYMENTS_CHECKOUT_CANCEL_URL:?DODO_PAYMENTS_CHECKOUT_CANCEL_URL is required (public https URL)} DODO_PAYMENTS_PORTAL_RETURN_URL: ${DODO_PAYMENTS_PORTAL_RETURN_URL:?DODO_PAYMENTS_PORTAL_RETURN_URL is required (public https URL)} DODO_PAYMENTS_PORTFOLIO_PRO_MONTHLY_PRODUCT_ID: ${DODO_PAYMENTS_PORTFOLIO_PRO_MONTHLY_PRODUCT_ID} DODO_PAYMENTS_PORTFOLIO_PRO_ANNUAL_PRODUCT_ID: ${DODO_PAYMENTS_PORTFOLIO_PRO_ANNUAL_PRODUCT_ID} DODO_PAYMENTS_BUNDLE_ONE_DAY_PRODUCT_ID: ${DODO_PAYMENTS_BUNDLE_ONE_DAY_PRODUCT_ID} DODO_PAYMENTS_BUNDLE_SEVEN_DAY_PRODUCT_ID: ${DODO_PAYMENTS_BUNDLE_SEVEN_DAY_PRODUCT_ID} DODO_PAYMENTS_AI_CREDITS_MONTHLY_PRODUCT_ID: ${DODO_PAYMENTS_AI_CREDITS_MONTHLY_PRODUCT_ID} DODO_PAYMENTS_AI_CREDITS_ANNUAL_PRODUCT_ID: ${DODO_PAYMENTS_AI_CREDITS_ANNUAL_PRODUCT_ID} DODO_PAYMENTS_BUNDLE_MONTHLY_PRODUCT_ID: ${DODO_PAYMENTS_BUNDLE_MONTHLY_PRODUCT_ID} DODO_PAYMENTS_BUNDLE_ANNUAL_PRODUCT_ID: ${DODO_PAYMENTS_BUNDLE_ANNUAL_PRODUCT_ID} DODO_PAYMENTS_CREDIT_PACK_250_PRODUCT_ID: ${DODO_PAYMENTS_CREDIT_PACK_250_PRODUCT_ID} DODO_PAYMENTS_CREDIT_PACK_500_PRODUCT_ID: ${DODO_PAYMENTS_CREDIT_PACK_500_PRODUCT_ID} DODO_PAYMENTS_CREDIT_PACK_1200_PRODUCT_ID: ${DODO_PAYMENTS_CREDIT_PACK_1200_PRODUCT_ID} # Portfolio media (Cloudflare R2) R2_ENDPOINT: ${R2_ENDPOINT:?R2_ENDPOINT is required} R2_BUCKET: ${R2_BUCKET:?R2_BUCKET is required} R2_ACCESS_KEY_ID: ${R2_ACCESS_KEY_ID:?R2_ACCESS_KEY_ID is required} R2_SECRET_ACCESS_KEY: ${R2_SECRET_ACCESS_KEY:?R2_SECRET_ACCESS_KEY is required} R2_PUBLIC_BASE_URL: ${R2_PUBLIC_BASE_URL:?R2_PUBLIC_BASE_URL is required} # GitHub Sync GITHUB_SYNC_ENABLED: ${GITHUB_SYNC_ENABLED:-false} GITHUB_TOKEN: ${GITHUB_TOKEN} GITHUB_OWNER: ${GITHUB_OWNER} GITHUB_REPO: ${GITHUB_REPO} GITHUB_PROJECT_URL: ${GITHUB_PROJECT_URL} GITHUB_SYNC_CRON: ${GITHUB_SYNC_CRON} INTERNAL_SYNC_API_KEY: ${INTERNAL_SYNC_API_KEY} # Metrics USAGE_METRICS_FLUSH_CRON: ${USAGE_METRICS_FLUSH_CRON} USAGE_METRICS_FLUSH_TIMEZONE: ${USAGE_METRICS_FLUSH_TIMEZONE:-UTC} # Internal server-to-server keys (x-internal-key); optional, 32+ chars in production STATS_INTERNAL_KEY: ${STATS_INTERNAL_KEY} INTERNAL_API_KEY: ${INTERNAL_API_KEY} # Everything else the server reads (V-347). A bare key passes the variable from the shell # or the root .env when it is set and leaves it unset otherwise, so the server default # applies. tests/config/compose-api-env.test.ts keeps this list complete. # Sign-in providers: without these, Google, GitHub and LinkedIn sign-in are off. GOOGLE_CLIENT_ID: GOOGLE_CLIENT_SECRET: GITHUB_CLIENT_ID: GITHUB_CLIENT_SECRET: LINKEDIN_CLIENT_ID: LINKEDIN_CLIENT_SECRET: # Programs: keep equal to the web service flags above. AFFILIATE_PROGRAM_ENABLED: AMBASSADOR_PROGRAM_ENABLED: AFFILIATE_ATTRIBUTION_DAYS: # Site cache revalidation (changelog and roadmap pages). SITE_REVALIDATE_SECRET: # Process model, logging and Redis/Postgres tuning. CLUSTERING_ENABLED: CRON_ROLE: LOG_LEVEL: MAX_MEMORY_ENTRIES: AUTH_IP_ADDRESS_HEADERS: REDIS_BOOT_TIMEOUT_MS: REDIS_COMMAND_TIMEOUT_MS: DB_POOL_MAX: DB_POOL_IDLE_TIMEOUT_MS: DB_POOL_CONNECTION_TIMEOUT_MS: DB_POOL_STATEMENT_TIMEOUT_MS: # Rate limits. GLOBAL_RATE_LIMIT_WINDOW_MS: GLOBAL_RATE_LIMIT_MAX_REQUESTS: AUTH_RATE_LIMIT_WINDOW_MS: AUTH_RATE_LIMIT_MAX_REQUESTS: AI_RATE_LIMIT_WINDOW_MS: AI_RATE_LIMIT_MAX_REQUESTS: RATE_LIMIT_REDIS_TIMEOUT_MS: # AI and ATS. AI_TIMEOUT_MS: ATS_EXTRACT_MAX_RSS_MB: # API keys. API_KEY_AUTH_CACHE_TTL_SECONDS: API_KEY_DEFAULT_LIFETIME_DAYS: API_KEY_DEFAULT_RATE_LIMIT: API_KEY_MAX_RATE_LIMIT: API_KEY_DEFAULT_SCOPES: API_KEY_LAST_USED_TOUCH_INTERVAL_SECONDS: # Credit pack price overrides (cents). CREDIT_PACK_250_PRICE_CENTS: CREDIT_PACK_500_PRICE_CENTS: CREDIT_PACK_1200_PRICE_CENTS: # Changelog, roadmap and GitHub caches and sync. CHANGELOG_RELEASE_SYNC_ENABLED: CHANGELOG_RELEASE_SYNC_CRON: CHANGELOG_RELEASE_SYNC_TIMEZONE: CHANGELOG_RELEASE_SYNC_MIN_INTERVAL_SECONDS: CHANGELOG_CACHE_TTL_SECONDS: ROADMAP_CACHE_TTL_SECONDS: ROADMAP_STATS_CACHE_TTL_SECONDS: GITHUB_STATS_CACHE_TTL_SECONDS: GITHUB_SYNC_TIMEZONE: USAGE_METRICS_REDIS_RETENTION_DAYS: # Data retention windows in days (0 turns a rule off); defaults are the owner decisions. RETENTION_EXPIRED_SESSION_DAYS: RETENTION_EXPIRED_VERIFICATION_DAYS: RETENTION_WEBHOOK_PAYLOAD_DAYS: RETENTION_UNPROCESSED_WEBHOOK_PAYLOAD_DAYS: RETENTION_AUDIT_LOG_DAYS: RETENTION_ADMIN_AUDIT_DAYS: RETENTION_ADMIN_MONEY_AUDIT_DAYS: RETENTION_CREDIT_RESERVATION_DAYS: RETENTION_CREDIT_REPLAY_DAYS: RETENTION_FLUSH_BATCH_DAYS: RETENTION_AFFILIATE_CLICK_DAYS: RETENTION_PORTFOLIO_VIEW_DAYS: RETENTION_DELETED_DOCUMENT_DAYS: depends_on: redis: condition: service_healthy migrate: condition: service_completed_successfully ports: # Loopback only: production is VPS + nginx, and nginx on the host is the only client. - "127.0.0.1:8080:8080" healthcheck: test: [ "CMD", "node", "-e", "fetch('http://127.0.0.1:8080/api/v1/health').then((r)=>process.exit(r.ok?0:1)).catch(()=>process.exit(1))", ] interval: 1m timeout: 10s retries: 3 start_period: 40s # Graceful shutdown drains HTTP for up to 25 s and running jobs for up to 20 s, and the # cluster master force-kills workers at 35 s; Compose's default 10 s would SIGKILL a run # mid-flush (SHUTDOWN_TIMINGS in apps/server/src/lib/shutdown.ts). stop_grace_period: 40s restart: unless-stopped # One-shot: applies pending migrations before `api` starts (V-106). Runs from the `build` # stage, which still has the Prisma CLI. Migrations need a direct connection: point # DIRECT_DATABASE_URL at the non-pooled endpoint when DATABASE_URL goes through a pooler. migrate: build: context: . dockerfile: apps/server/Dockerfile target: build command: ["npm", "exec", "-w", "@veriworkly/server", "--", "prisma", "migrate", "deploy"] environment: NODE_ENV: ${NODE_ENV:-production} DATABASE_URL: ${DATABASE_URL:?DATABASE_URL is required} DIRECT_DATABASE_URL: ${DIRECT_DATABASE_URL:-} restart: "no" redis: image: redis:7-alpine # V-113: bounded memory. volatile-lru evicts only keys with a TTL (caches, but also view and # usage buffers, rate-limit buckets and locks). Once nothing with a TTL is left, writes still # fail with OOM, so size REDIS_MAXMEMORY for the working set (see apps/server/README.md). command: [ "redis-server", "--appendonly", "yes", "--requirepass", "${REDIS_PASSWORD:?REDIS_PASSWORD is required}", "--maxmemory", "${REDIS_MAXMEMORY:-256mb}", "--maxmemory-policy", "volatile-lru", ] environment: # redis-cli reads REDISCLI_AUTH, so the healthcheck authenticates without putting the # password on its command line. REDISCLI_AUTH: ${REDIS_PASSWORD:?REDIS_PASSWORD is required} volumes: - redis-data:/data healthcheck: # redis-cli exits 0 even on a NOAUTH error reply, so check for PONG explicitly. test: ["CMD-SHELL", "redis-cli ping | grep -q PONG"] interval: 20s timeout: 5s retries: 3 restart: unless-stopped # Development only (`docker compose --profile dev up`). It has unauthenticated read/write access # to the Redis that holds sessions, rate-limit counters and job locks, so it is never started in # production and only listens on the host's loopback (reach it over an SSH tunnel). redisinsight: image: redis/redisinsight:2.70.1 profiles: ["dev"] ports: - "127.0.0.1:5540:5540" depends_on: - redis restart: unless-stopped volumes: redis-data: