Environment Variables
Copy .env.example to .env and replace every production placeholder. The
production Compose overlay passes an explicit allowlist to each service; it
does not inject the whole file into containers.
Application
| Variable | Required | Local default | Description |
|---|---|---|---|
NODE_ENV | Runtime | development | production is forced in production images |
PORT | No | 8000 | Next.js web port |
WS_PORT | No | 8003 | WebSocket service port |
NEXT_PUBLIC_APP_URL | Yes | https://probeya.com | Browser-visible application origin; embedded at build time |
NEXT_PUBLIC_WS_URL | Yes | wss://ws.probeya.com | Browser-visible WebSocket origin; embedded at build time |
AUTH_URL | Production | https://probeya.com | Canonical Auth.js origin |
AUTH_TRUST_HOST | Production | true | Trust the Nginx-forwarded host |
ITEM_EVENT_OUTBOX_WORKER_ENABLED | Production | false | Enables the durable item-event worker in web; the production overlay forces true |
FORM_EMAIL_OUTBOX_PRODUCER_ENABLED | Production | false | Enables Forms invitation/reminder producers only after the outbox rollout is safe |
There is no separate application API origin or API port. tRPC and HTTP routes
are served by Next.js on PORT.
Database
| Variable | Required | Description |
|---|---|---|
DATABASE_URL | Runtime/local | probeya_app connection used by the application with RLS enforced |
DATABASE_APP_URL | RLS acceptance | Exact alias of DATABASE_URL used to prove the direct application role |
PROBEYA_RLS_ACCEPTANCE_DISPOSABLE | RLS acceptance | Must be 1; confirms the target database may be destructively tested |
DATABASE_ADMIN_URL | Local schema operations | probeya_admin URL for db:migrate, RLS apply/check, and seeds |
DATABASE_URL_DOCKER | Production runtime | Container-network probeya_app URL passed only to web |
DATABASE_ADMIN_URL_DOCKER | Production schema | Container-network probeya_admin URL passed only to schema |
POSTGRES_DB | Production | Database name, normally probeya |
POSTGRES_USER | Production | Compose PostgreSQL superuser used only for cluster/bootstrap operations |
POSTGRES_PASSWORD | Production | Random PostgreSQL superuser password |
PROBEYA_APP_DB_PASSWORD | Production | Random RLS runtime-role password |
PROBEYA_ADMIN_DB_PASSWORD | Production | Different random schema-owner password |
The production URLs must use the Compose hostname and URL-encoded passwords:
DATABASE_URL_DOCKER=postgresql://probeya_app:<encoded-password>@postgres:5432/probeya
DATABASE_ADMIN_URL_DOCKER=postgresql://probeya_admin:<encoded-password>@postgres:5432/probeya
Never pass DATABASE_ADMIN_URL or DATABASE_ADMIN_URL_DOCKER to an application
runtime. probeya_admin can bypass RLS by design. The MVP ws service receives
neither the application nor the admin database URL.
Redis
| Variable | Required | Description |
|---|---|---|
REDIS_URL | Local runtime | Host URL, normally redis://localhost:6380 |
REDIS_PASSWORD | Production | Random Redis password |
REDIS_URL_DOCKER | Production | Password-protected URL using hostname redis and port 6379 |
REDIS_URL_DOCKER=redis://:<encoded-password>@redis:6379
Authentication and Email
| Variable | Required | Description |
|---|---|---|
AUTH_SECRET | Yes | Auth.js signing secret; at least 32 random bytes; fallback Forms-envelope key |
FORM_EMAIL_OUTBOX_ENCRYPTION_SECRET | Optional | Dedicated current Forms-envelope key; at least 32 bytes when set |
FORM_EMAIL_OUTBOX_PREVIOUS_ENCRYPTION_SECRET | Rotation only | Previous Forms-envelope key; remove only after all jobs encrypted with it have drained |
WS_AUTH_SECRET | Yes | Separate WebSocket authentication secret |
CRON_SECRET | Production | Separate secret for protected scheduled endpoints |
RESEND_API_KEY | Production | Resend API key; public-form OTP, Forms delivery, and recovery fail closed without it |
EMAIL_FROM | Production | Verified sender identity |
Generate independent secrets instead of reusing one value:
openssl rand -base64 32
Optional OIDC providers use the corresponding Authentik or Azure client
variables in .env.example. Their NEXT_PUBLIC_*_ENABLED flags are embedded at
image build time.
Stripe Billing
| Variable | Required | Description |
|---|---|---|
STRIPE_SECRET_KEY | Production | Server API key; keep it in the same Stripe mode as the Price IDs |
STRIPE_WEBHOOK_SECRET | Production | Signing secret for /api/webhooks/stripe |
STRIPE_PRICE_STARTER | Production | Exact monthly Starter Price ID used for checkout and entitlements |
STRIPE_PRICE_PRO | Production | Exact monthly Pro Price ID used for checkout and entitlements |
Configure all four values together. The two price_... IDs must be distinct;
unknown Stripe prices never grant a paid plan. Enterprise is contract-only and
is not accepted by self-service Checkout.
S3-Compatible Storage
| Variable | Required | Description |
|---|---|---|
S3_ENDPOINT | Yes | Server-reachable S3 API origin, for example http://minio:9000 in Compose |
S3_PUBLIC_URL | Yes | Browser-reachable HTTPS origin for public assets |
S3_BUCKET | Yes | Bucket name, normally probeya-uploads |
S3_ACCESS_KEY | Yes | Least-privilege application identity |
S3_SECRET_KEY | Yes | Application secret, distinct from the MinIO root secret |
S3_PREVIOUS_ACCESS_KEY | Rotation only | Previous identity removed after a successful rotation |
S3_REGION | No | S3 region; defaults to us-east-1 |
MINIO_ROOT_USER | Bundled MinIO | Root identity used only by minio-init |
MINIO_ROOT_PASSWORD | Bundled MinIO | Root secret used only by minio-init |
S3_PUBLIC_URL must not use the Compose-only minio hostname. The one-shot
bootstrap keeps the bucket private and grants anonymous GetObject only for
the avatar and logo prefixes. Attachments require an ACL-checked signed URL.
Attachment Scanning
| Variable | Required | Production value | Description |
|---|---|---|---|
CLAMAV_HOST | Yes | clamav | clamd hostname |
CLAMAV_PORT | Yes | 3310 | clamd container port |
CLAMAV_TIMEOUT_MS | No | 30000 | Scan timeout |
CLAMAV_REQUIRED | Production | true | Block writes when scanning is unavailable |
The production overlay forces CLAMAV_REQUIRED=true. A false value is only
appropriate for a controlled local development environment.
Production Example
POSTGRES_DB=probeya
POSTGRES_USER=probeya
POSTGRES_PASSWORD=<random-superuser-password>
PROBEYA_APP_DB_PASSWORD=<random-app-password>
PROBEYA_ADMIN_DB_PASSWORD=<random-admin-password>
DATABASE_URL_DOCKER=postgresql://probeya_app:<encoded-app-password>@postgres:5432/probeya
DATABASE_ADMIN_URL_DOCKER=postgresql://probeya_admin:<encoded-admin-password>@postgres:5432/probeya
REDIS_PASSWORD=<random-redis-password>
REDIS_URL_DOCKER=redis://:<encoded-redis-password>@redis:6379
MINIO_ROOT_USER=<random-root-access-key>
MINIO_ROOT_PASSWORD=<random-root-secret-key>
S3_ACCESS_KEY=<different-app-access-key>
S3_SECRET_KEY=<different-app-secret-key>
S3_ENDPOINT=http://minio:9000
S3_PUBLIC_URL=https://files.example.com
S3_BUCKET=probeya-uploads
AUTH_SECRET=<random-secret>
FORM_EMAIL_OUTBOX_PRODUCER_ENABLED=false
FORM_EMAIL_OUTBOX_ENCRYPTION_SECRET=<optional-dedicated-random-secret>
FORM_EMAIL_OUTBOX_PREVIOUS_ENCRYPTION_SECRET=
WS_AUTH_SECRET=<different-random-secret>
CRON_SECRET=<different-random-secret>
RESEND_API_KEY=<production-key>
EMAIL_FROM=ProBeya <[email protected]>
STRIPE_SECRET_KEY=<production-secret-key>
STRIPE_WEBHOOK_SECRET=<endpoint-signing-secret>
STRIPE_PRICE_STARTER=price_<starter-monthly-id>
STRIPE_PRICE_PRO=price_<pro-monthly-id>
NEXT_PUBLIC_APP_URL=https://probeya.example.com
NEXT_PUBLIC_WS_URL=wss://ws.example.com
Run the merged Compose validation after every environment change:
docker compose \
-f docker/docker-compose.yml \
-f docker/docker-compose.prod.yml \
config --quiet