compose.yaml
Rama master · réplica de lectura del repositorio original.
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: