-
-
Notifications
You must be signed in to change notification settings - Fork 0
Expand file tree
/
Copy path.env.example
More file actions
443 lines (383 loc) · 21.6 KB
/
Copy path.env.example
File metadata and controls
443 lines (383 loc) · 21.6 KB
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
354
355
356
357
358
359
360
361
362
363
364
365
366
367
368
369
370
371
372
373
374
375
376
377
378
379
380
381
382
383
384
385
386
387
388
389
390
391
392
393
394
395
396
397
398
399
400
401
402
403
404
405
406
407
408
409
410
411
412
413
414
415
416
417
418
419
420
421
422
423
424
425
426
427
428
429
430
431
432
433
434
435
436
437
438
439
440
441
442
443
# ============================================================================
# OpenMentor Infrastructure - Environment Variables
# ============================================================================
# Copy this file to .env and fill in your actual values
# NEVER commit .env file to version control!
# ============================================================================
# ============================================================================
# INFRASTRUCTURE CONFIGURATION
# ============================================================================
# Domain Configuration
DOMAIN=openmentor.io
# Email for Let's Encrypt SSL certificate notifications
LETSENCRYPT_EMAIL=admin@openmentor.io
# Cloudflare DNS API Token (for DNS-01 ACME challenge, used by Traefik)
# Create at: https://dash.cloudflare.com/profile/api-tokens ("Edit zone DNS")
CLOUDFLARE_DNS_API_TOKEN=your_cloudflare_dns_api_token
# Environment (production, staging, development)
# Default hosting: Hetzner Cloud VM (EU) — see docs/migration DECISIONS D2.
APP_ENV=production
# Deployment identity for the observability labels (production | staging).
# deploy.sh overwrites this from the deploy target - production and --staging
# share this file, so the backup-freshness alert needs it to tell the two VMs
# apart in the shared Grafana Cloud tenant.
DEPLOYMENT_NAME=production
# Logging Level (debug, info, warn, error)
LOG_LEVEL=info
# ============================================================================
# CONTAINER REGISTRY — AWS ECR (DECISIONS D19)
# ============================================================================
# ECR registry host: <account-id>.dkr.ecr.<region>.amazonaws.com. Compose
# interpolates it into the image names in docker-compose.yml. For local dev
# any placeholder works — deploy-dev.sh / docker-compose.dev.yml override the
# images with locally built dev-<sha> tags and never touch the registry.
ECR_REGISTRY=123456789012.dkr.ecr.eu-central-1.amazonaws.com
AWS_REGION=eu-central-1
# Docker image tag (default: latest, or use git SHA in CI/CD)
IMAGE_TAG=latest
# ============================================================================
# BACKEND (Go API) CONFIGURATION
# ============================================================================
# Server Configuration
PORT=8081
GIN_MODE=release
LOG_DIR=/app/logs
# PostgreSQL Database Configuration (primary data source)
# The database is the `postgres` container in docker-compose.yml (DECISIONS
# D2). POSTGRES_* provision the container; DATABASE_URL is what migrate/
# backend/worker connect with and must match them. There is no compose-level
# default for POSTGRES_PASSWORD - the container refuses to start without one.
# SECURITY (M16): this is a LOCAL-DEV placeholder, not a usable secret. Dev
# ports bind to 127.0.0.1 only. Never reuse this value outside local dev; the
# production template (.env.production.example) uses its own placeholder.
POSTGRES_USER=openmentor
POSTGRES_PASSWORD=changeme_local_only
POSTGRES_DB=openmentor
# sslmode=disable is correct for the in-network container: traffic never
# leaves the internal Docker network. Managed-PG scale path (D2): swap ONLY
# this URL to the managed host with sslmode=verify-full and pass the provider
# CA via sslrootcert=<path> in the DSN (standard pgx/libpq behavior; or rely
# on the system trust store).
DATABASE_URL=postgres://openmentor:changeme_local_only@postgres:5432/openmentor?sslmode=disable
# SECURITY (H8): per-process identities. DATABASE_URL above authenticates as
# POSTGRES_USER, which is the image's bootstrap SUPERUSER. Migration 000012 adds
# om_migrate (owns the schema, runs DDL), om_api and om_worker (DML only) and
# om_backup (read only). Each variable below overrides DATABASE_URL for exactly
# ONE container and falls back to it when unset or empty, so leaving them
# commented out keeps today's behaviour byte for byte.
#
# The roles are created NOLOGIN, so they do nothing until someone sets a
# password. To try the split locally:
# docker exec -it openmentor-postgres-dev psql -U openmentor -d openmentor \
# -c "ALTER ROLE om_api LOGIN PASSWORD 'devpw'"
# Production sequence and per-step rollback:
# ../docs/runbooks/database-identities.md
#MIGRATE_DATABASE_URL=postgres://om_migrate:changeme_local_only@postgres:5432/openmentor?sslmode=disable
#API_DATABASE_URL=postgres://om_api:changeme_local_only@postgres:5432/openmentor?sslmode=disable
#WORKER_DATABASE_URL=postgres://om_worker:changeme_local_only@postgres:5432/openmentor?sslmode=disable
# S3 Object Storage (profile pictures, used by the Go backend)
# Decided: AWS S3 (DECISIONS D15). Example (AWS S3, eu-central-1):
# S3_STORAGE_ENDPOINT=https://s3.eu-central-1.amazonaws.com, S3_STORAGE_REGION=eu-central-1
# Any other S3-compatible provider still works by swapping endpoint/region:
# Cloudflare R2: S3_STORAGE_ENDPOINT=https://<account-id>.r2.cloudflarestorage.com, S3_STORAGE_REGION=auto
# Backblaze B2: S3_STORAGE_ENDPOINT=https://s3.eu-central-003.backblazeb2.com, S3_STORAGE_REGION=eu-central-003
S3_STORAGE_ACCESS_KEY=your_access_key
S3_STORAGE_SECRET_KEY=your_secret_key
S3_STORAGE_BUCKET=mentor-images
S3_STORAGE_ENDPOINT=https://s3.eu-central-1.amazonaws.com
S3_STORAGE_REGION=eu-central-1
# ============================================================================
# POSTGRES BACKUPS (postgres-backup sidecar; disabled in the dev overlay)
# ============================================================================
# Nightly pg_dump (custom format, pg_restore-compatible) of ${POSTGRES_DB},
# named openmentor-YYYYMMDD-HHMM.dump. With BACKUP_S3_BUCKET set, dumps go to
# s3://$BACKUP_S3_BUCKET/$BACKUP_S3_PREFIX/ and are pruned there after
# BACKUP_RETENTION_DAYS. With it unset, dumps stay in the local
# `openmentor-postgres-backups` volume (same retention) and the sidecar logs
# a loud warning - local-only backups die with the VM.
# Restore runbook: ../docs/runbooks/postgres-backup-restore.md
BACKUP_S3_BUCKET=
BACKUP_S3_PREFIX=postgres
# Daily run time, HH:MM in UTC (the container clock)
BACKUP_TIME=03:30
BACKUP_RETENTION_DAYS=30
# Freshness window enforced by the sidecar's container healthcheck and by the
# DatabaseBackupStale Grafana alert: a bit over one daily interval, so one late
# run is tolerated but a skipped night is not.
BACKUP_MAX_AGE_HOURS=26
# S3 credentials for backups. SECURITY (M12): these are REQUIRED when
# BACKUP_S3_BUCKET is set — the sidecar no longer falls back to the app's
# S3_STORAGE_* keys and refuses to start without them. Use a DEDICATED IAM
# identity scoped to the backup bucket, WITHOUT s3:DeleteObject on the app
# (images) bucket, and enable bucket versioning / Object Lock on the backup
# bucket so a leaked app key can't destroy your recovery point.
BACKUP_AWS_ACCESS_KEY_ID=
BACKUP_AWS_SECRET_ACCESS_KEY=
BACKUP_AWS_REGION=
# SECURITY (H8): the database identity the sidecar dumps as. Unset, it uses
# POSTGRES_USER/POSTGRES_PASSWORD (the bootstrap superuser) as before; set, it
# uses om_backup (pg_read_all_data only — enough for pg_dump, no writes).
# The sidecar is production-only, so this is here for parity, not for dev.
#BACKUP_POSTGRES_USER=om_backup
#BACKUP_POSTGRES_PASSWORD=
# ============================================================================
# TRANSACTIONAL EMAIL - AWS SES (DECISIONS D1)
# ============================================================================
# Consumed by the background worker container (email sender uses the SESv2
# API). The worker runs from the backend image (/app/worker) and reads this
# same .env file.
SES_REGION=eu-central-1
SES_ACCESS_KEY_ID=your_ses_access_key_id
SES_SECRET_ACCESS_KEY=your_ses_secret_access_key
# Optional: custom SESv2-compatible endpoint (leave empty for AWS SES)
SES_ENDPOINT=
# Moderator notifications recipient (new mentor / new request emails)
MODERATORS_EMAIL=moderators@openmentor.io
# Private mentors' Discord invite, shown in the approval welcome email.
# REQUIRED: the email template renders this section unconditionally (SES
# templates don't support {{#if}}), so an empty value ships a "join here"
# link with an empty href to every newly approved mentor.
DISCORD_MENTORS_PRIVATE_INVITE_LINK=
# Non-production email reroute: when set (and APP_ENV != production), ALL
# outgoing emails are rerouted to this address instead of real recipients.
# Leave empty in production.
DEV_EMAIL_OVERRIDE=
# ============================================================================
# BACKGROUND WORKER (worker container, /app/worker from the backend image)
# ============================================================================
# The worker serves the API's async event triggers on the internal network
# and runs the daily cron jobs. It replaces the deprecated openmentor-func
# Azure Functions app.
# Shared secret for the X-Worker-Token header: the worker requires it on all
# /jobs/* requests when set, and the API sends it on every trigger call.
# Generate with: openssl rand -hex 32
WORKER_AUTH_TOKEN=your_worker_auth_token
# Master switch for the worker's scheduled (cron) jobs
WORKER_CRON_ENABLED=true
# Optional: comma-separated mentor ids the randomize-sort-order cron job
# pins to the top of the catalog (leave empty for none)
HIGHLIGHTED_MENTORS=
# Profile deletion retention (D70). A deleted profile is kept, restorable by an
# admin, until this many days have passed — then the worker's
# purge-deleted-profiles job erases it along with its requests and reviews.
# THIS IS THE ONLY WINDOW IN WHICH A DELETION CAN BE UNDONE.
#
# LEGAL: this window is PUBLISHED in the privacy policy (web/src/pages/privacy.tsx,
# section 6 — "currently 30 days"). Changing it here makes that statement wrong;
# update the policy and its "Last updated" date in the same change.
#
# PROFILE IMAGES are erased by the S3 BUCKET, not by the purge job (the worker
# holds no S3 credential — a57aec2). Deletion moves them to the bucket's
# "deleted/" prefix; configure a lifecycle rule on that prefix, expiring after
# MORE days than the retention above (e.g. 45 for the 30-day default — the
# copy resets the object's age, so the lifecycle clock starts at deletion).
# A rule that expires SOONER than the retention window means a late restore
# brings the profile back without its photo. No rule at all means trashed
# images accumulate under "deleted/" until one is added.
WORKER_PROFILE_PURGE_RETENTION_DAYS=30
# Schedule for that purge job, in the worker's 6-field cron syntax
# (seconds minutes hours day-of-month month day-of-week). Nightly at 03:15 by
# default; the retention window above, not this, decides how long a deletion
# stays undoable.
WORKER_PROFILE_PURGE_CRON=0 15 3 * * *
# Event trigger URLs the API calls after database writes (fire-and-forget).
# They point at the worker container's internal HTTP server. For the
# CallAsync-style URLs the record id is appended verbatim, so keep the
# trailing "?param=". The JSON POST triggers (mentor-login-email,
# moderator-login-email, mentor-moderation-action) take the bare endpoint
# URL with NO query string.
# NOTE: MENTOR_CREATED_TRIGGER_URL is also the source of four DERIVED
# trigger URLs — mentor-confirmed, mentor-confirm-email, profile-deleted and
# profile-restored — built by substituting the job segment, so those jobs need
# no env vars of their own. Pointing it at a different job silently disables
# all four (the API validates that it contains "new-mentor-watcher").
MENTOR_CREATED_TRIGGER_URL=http://worker:8090/jobs/new-mentor-watcher?mentorId=
MENTOR_REQUEST_CREATED_TRIGGER_URL=http://worker:8090/jobs/new-request-watcher?requestId=
MENTOR_LOGIN_EMAIL_TRIGGER_URL=http://worker:8090/jobs/mentor-login-email
MODERATOR_LOGIN_EMAIL_TRIGGER_URL=http://worker:8090/jobs/moderator-login-email
MENTOR_MODERATION_TRIGGER_URL=http://worker:8090/jobs/mentor-moderation-action
REQUEST_PROCESS_FINISHED_TRIGGER_URL=http://worker:8090/jobs/request-process-finished?requestId=
REVIEW_CREATED_TRIGGER_URL=http://worker:8090/jobs/process-mentee-review?reviewId=
# MENTOR_UPDATED_TRIGGER_URL: leave unset - the worker has no endpoint for it
# (the legacy update-mentor-image thumbnail function was not ported).
MENTOR_UPDATED_TRIGGER_URL=
# ============================================================================
# AUTHENTICATION TOKENS
# ============================================================================
# Public API Tokens (for different consumers)
# These tokens are used to authenticate requests to /api/mentors endpoint
MENTORS_API_LIST_AUTH_TOKEN=your_public_api_token_1
# Internal API Token (REQUIRED - for Next.js to Go API communication)
# Frontend uses this to access /api/internal/mentors endpoint
INTERNAL_MENTORS_API=your_internal_api_token_here
GO_API_INTERNAL_TOKEN=your_internal_api_token_here
# JWT signing secret for mentor/admin passwordless-login sessions
# (backend refuses logins if unset). Generate with: openssl rand -base64 32
JWT_SECRET=your_jwt_secret
# Webhook Secrets
# Next.js Integration (used by backend to trigger revalidation)
# ============================================================================
# CAPTCHA CONFIGURATION (Cloudflare Turnstile)
# ============================================================================
# Cloudflare Turnstile (form spam/bot protection)
# For dev/CI use Cloudflare's official always-pass test keys:
# site key: 1x00000000000000000000AA
# secret: 1x0000000000000000000000000000000AA
TURNSTILE_SECRET_KEY=your_turnstile_secret_key
NEXT_PUBLIC_TURNSTILE_SITE_KEY=your_turnstile_site_key
# Optional replay hardening (H14). Cloudflare attests that a token is genuine and
# unused; only we know which site and which form it was meant for, so siteverify's
# echoed hostname/action are checked against these when they are set.
#
# TURNSTILE_EXPECTED_HOSTNAME: a BARE hostname, no scheme and no path (the API
# refuses a URL here, because it could never match and would reject every
# captcha). Leave unset in dev, where the widget is solved on localhost.
TURNSTILE_EXPECTED_HOSTNAME=
# TURNSTILE_EXPECTED_ACTION: inert until the forms render the widget with an
# `action`, which they do not yet — siteverify then echoes an empty action and any
# value here would reject every submission. Listed so the contract is visible.
TURNSTILE_EXPECTED_ACTION=
# ============================================================================
# REVIEW CAPABILITY (H4)
# ============================================================================
# Keeps the PRE-H4 review links working: GET /api/v1/reviews/<request_id>/check
# and POST /api/v1/reviews/<request_id>, which authorize on the client request's
# primary key itself. New "session complete" emails carry a single-use, hashed,
# expiring token instead, so this only exists for links already sitting in
# mentees' inboxes (the dual-read window).
#
# Setting this to false is the H4 CUTOVER and is irreversible for anyone still
# holding an old link. Do not flip it until
# openmentor_review_legacy_link_uses_total{outcome="accepted"} has been zero for
# longer than the 30-day invitation TTL — see
# docs/runbooks/audit-2026-08/review-capability-cutover.md.
REVIEW_LEGACY_REQUEST_ID_LINKS_ENABLED=true
# ============================================================================
# FRONTEND (Next.js) CONFIGURATION
# ============================================================================
# Public Environment Variables (exposed to browser)
NEXT_PUBLIC_GO_API_URL=http://backend:8081
# Image host for next/image — path-style S3 endpoint host + bucket, matching
# the S3_STORAGE_* config above (AWS S3, DECISIONS D15)
NEXT_PUBLIC_S3_STORAGE_ENDPOINT=s3.eu-central-1.amazonaws.com
NEXT_PUBLIC_S3_STORAGE_BUCKET=mentor-images
# Optional CDN in front of the bucket (takes precedence in the image loader)
NEXT_PUBLIC_CDN_ENDPOINT=
NEXT_PUBLIC_ANALYTICS_PROVIDER=posthog
NEXT_PUBLIC_ANALYTICS_EVENT_VERSION=v1
# PostHog project key/host for the frontend (client + server-side capture);
# the frontend skips analytics init when the key is empty
NEXT_PUBLIC_POSTHOG_KEY=
NEXT_PUBLIC_POSTHOG_HOST=https://eu.i.posthog.com
# NEXT_PUBLIC_TURNSTILE_SITE_KEY is defined above in the captcha section
# ============================================================================
# BACKEND / WORKER ANALYTICS - PostHog (server-side product events)
# ============================================================================
# The Go api and worker emit product analytics (source_system=api/worker).
# Set ANALYTICS_PROVIDER=none to disable server-side analytics entirely.
ANALYTICS_PROVIDER=posthog
ANALYTICS_EVENT_VERSION=v1
POSTHOG_API_KEY=your_posthog_project_api_key
POSTHOG_HOST=https://eu.i.posthog.com
# Optional backend PostHog knobs (defaults shown; ENABLED falls back to
# ANALYTICS_PROVIDER, CAPTURE_ENDPOINT overrides HOST for event ingestion)
# POSTHOG_ENABLED=true
# POSTHOG_CAPTURE_ENDPOINT=
# POSTHOG_DISABLE_GEOIP=true
# Grafana Faro (browser RUM) — leave COLLECTOR_URL empty to disable RUM.
# The browser posts to /faro-collect which next.config.js rewrites here.
NEXT_PUBLIC_FARO_COLLECTOR_URL=
NEXT_PUBLIC_FARO_APP_NAME=openmentor-frontend
NEXT_PUBLIC_FARO_SAMPLE_RATE=1
# Must match O11Y_SERVICE_NAMESPACE so browser + server telemetry share one namespace
NEXT_PUBLIC_O11Y_SERVICE_NAMESPACE=openmentor-io
NEXT_PUBLIC_O11Y_FE_SERVICE_VERSION=1.0.0
# ============================================================================
# OBSERVABILITY - GRAFANA CLOUD
# ============================================================================
# Prometheus Metrics Endpoint
GCLOUD_HOSTED_METRICS_URL=https://prometheus-xxx.grafana.net/api/prom/push
GCLOUD_HOSTED_METRICS_ID=your_metrics_username
# Loki Logs Endpoint
GCLOUD_HOSTED_LOGS_URL=https://logs-xxx.grafana.net/loki/api/v1/push
GCLOUD_HOSTED_LOGS_ID=your_logs_username
# Grafana Cloud API Key (shared for metrics, logs, and traces)
GCLOUD_RW_API_KEY=your_grafana_cloud_api_key
# Metrics Authentication Token (Bearer token for /api/metrics endpoint)
# Alloy uses this to authenticate when scraping metrics from frontend
METRICS_AUTH_TOKEN=your_metrics_auth_token_here
# Tempo Traces Endpoint (for distributed tracing)
GCLOUD_HOSTED_TRACES_URL=https://tempo-xxx.grafana.net
GCLOUD_HOSTED_TRACES_ID=your_traces_username
# Pyroscope Profiles Endpoint (for continuous profiling)
GCLOUD_HOSTED_PROFILES_URL=https://profiles-xxx.grafana.net
GCLOUD_HOSTED_PROFILES_ID=your_profiles_username
# Prometheus Scrape Configuration
PROMETHEUS_SCRAPE_INTERVAL=30s
# Service Naming (used by Grafana Alloy for labeling)
O11Y_SERVICE_NAMESPACE=openmentor-io
O11Y_BE_SERVICE_NAME=openmentor-api
O11Y_FE_SERVICE_NAME=openmentor-frontend
# Worker's observability identity: Alloy scrape/log labels, its trace
# service name AND its continuous-profiling app name (the worker ignores
# O11Y_PROFILING_APP_NAME so its profiles never mix with the API's)
O11Y_WORKER_SERVICE_NAME=openmentor-worker
# OTLP trace exporter endpoint for the Go binaries (api and worker both
# read it; Alloy's otelcol receiver, host:port without scheme).
# Leave empty to disable tracing.
O11Y_EXPORTER_ENDPOINT=alloy:4318
# Service versions stamped on traces/metrics resources (defaults: 1.0.0)
O11Y_BE_SERVICE_VERSION=1.0.0
O11Y_FE_SERVICE_VERSION=1.0.0
# ============================================================================
# DATABASE OBSERVABILITY - PostgreSQL
# ============================================================================
# DSN for the dedicated monitoring user (separate from the app DATABASE_URL).
# deploy.sh extracts this value and writes it to alloy-secrets/ on the VM.
# It points at the `postgres` compose container (sslmode=disable is correct
# on the internal network); if you move to a TLS-verified managed cluster,
# swap the host and set sslmode/sslrootcert in the DSN (mount the CA into
# the Alloy container yourself).
#
# Required PostgreSQL setup (one-time):
# CREATE USER grafana_monitoring WITH PASSWORD 'your_password';
# GRANT pg_monitor TO grafana_monitoring;
# GRANT CONNECT ON DATABASE openmentor TO grafana_monitoring;
#
# Also enable pg_stat_statements via shared_preload_libraries.
POSTGRES_OBS_DSN=postgres://grafana_monitoring:your_password@postgres:5432/openmentor?sslmode=disable
# Backend Continuous Profiling (staging default: enabled)
# Shared by the api and worker containers; O11Y_PROFILING_APP_NAME names
# the API's profile stream only (the worker uses O11Y_WORKER_SERVICE_NAME)
O11Y_PROFILING_ENABLED=true
O11Y_PROFILING_ENDPOINT=http://alloy:4040
O11Y_PROFILING_APP_NAME=openmentor-api
O11Y_PROFILING_SAMPLE_TYPES=cpu,alloc_space,alloc_objects,goroutines,mutex,block
O11Y_PROFILING_UPLOAD_INTERVAL_SECONDS=15
# ============================================================================
# DOCKER COMPOSE SPECIFIC
# ============================================================================
# Volume mount points (uncomment if needed)
# BACKEND_LOGS_PATH=/var/log/openmentor/backend
# ALLOY_DATA_PATH=/var/lib/alloy
# ============================================================================
# DEVELOPMENT OVERRIDES
# ============================================================================
# Uncomment these when running docker-compose.dev.yml
# NODE_ENV=development
# GIN_MODE=debug
# LOG_LEVEL=debug
# ============================================================================
# SECURITY NOTES
# ============================================================================
# 1. Generate strong random tokens for all *_TOKEN and *_SECRET variables
# 2. Use different tokens for different environments (dev, staging, prod)
# 3. Rotate tokens regularly
# 4. Never commit .env file to Git
# 5. Use secrets management in production (HashiCorp Vault, SOPS, etc.)
#
# Generate random tokens with:
# openssl rand -base64 32
# or
# node -e "console.log(require('crypto').randomBytes(32).toString('base64'))"
# ============================================================================