Repository navigation
Expand file tree
/
Copy path.env.example
More file actions
1948 lines (1845 loc) · 104 KB
/
Copy path.env.example
File metadata and controls
1948 lines (1845 loc) · 104 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
444
445
446
447
448
449
450
451
452
453
454
455
456
457
458
459
460
461
462
463
464
465
466
467
468
469
470
471
472
473
474
475
476
477
478
479
480
481
482
483
484
485
486
487
488
489
490
491
492
493
494
495
496
497
498
499
500
501
502
503
504
505
506
507
508
509
510
511
512
513
514
515
516
517
518
519
520
521
522
523
524
525
526
527
528
529
530
531
532
533
534
535
536
537
538
539
540
541
542
543
544
545
546
547
548
549
550
551
552
553
554
555
556
557
558
559
560
561
562
563
564
565
566
567
568
569
570
571
572
573
574
575
576
577
578
579
580
581
582
583
584
585
586
587
588
589
590
591
592
593
594
595
596
597
598
599
600
601
602
603
604
605
606
607
608
609
610
611
612
613
614
615
616
617
618
619
620
621
622
623
624
625
626
627
628
629
630
631
632
633
634
635
636
637
638
639
640
641
642
643
644
645
646
647
648
649
650
651
652
653
654
655
656
657
658
659
660
661
662
663
664
665
666
667
668
669
670
671
672
673
674
675
676
677
678
679
680
681
682
683
684
685
686
687
688
689
690
691
692
693
694
695
696
697
698
699
700
701
702
703
704
705
706
707
708
709
710
711
712
713
714
715
716
717
718
719
720
721
722
723
724
725
726
727
728
729
730
731
732
733
734
735
736
737
738
739
740
741
742
743
744
745
746
747
748
749
750
751
752
753
754
755
756
757
758
759
760
761
762
763
764
765
766
767
768
769
770
771
772
773
774
775
776
777
778
779
780
781
782
783
784
785
786
787
788
789
790
791
792
793
794
795
796
797
798
799
800
801
802
803
804
805
806
807
808
809
810
811
812
813
814
815
816
817
818
819
820
821
822
823
824
825
826
827
828
829
830
831
832
833
834
835
836
837
838
839
840
841
842
843
844
845
846
847
848
849
850
851
852
853
854
855
856
857
858
859
860
861
862
863
864
865
866
867
868
869
870
871
872
873
874
875
876
877
878
879
880
881
882
883
884
885
886
887
888
889
890
891
892
893
894
895
896
897
898
899
900
901
902
903
904
905
906
907
908
909
910
911
912
913
914
915
916
917
918
919
920
921
922
923
924
925
926
927
928
929
930
931
932
933
934
935
936
937
938
939
940
941
942
943
944
945
946
947
948
949
950
951
952
953
954
955
956
957
958
959
960
961
962
963
964
965
966
967
968
969
970
971
972
973
974
975
976
977
978
979
980
981
982
983
984
985
986
987
988
989
990
991
992
993
994
995
996
997
998
999
1000
# ============================================
# Breeze RMM - Environment Configuration
# ============================================
# Copy this file to .env and update values for your environment
# NEVER commit the .env file to version control
# --------------------------------------------
# Database Configuration
# --------------------------------------------
POSTGRES_USER=breeze
POSTGRES_PASSWORD=breeze_secure_password_change_me
POSTGRES_DB=breeze
POSTGRES_PORT=5432
# Administrator/system URL. Required for migrations and role setup; request
# handlers do not use this connection in production.
#
# Under the bundled root docker-compose.yml this value never reaches the API:
# compose rebuilds DATABASE_URL as ...@postgres:5432 inside the api container
# from POSTGRES_USER/POSTGRES_PASSWORD/POSTGRES_DB. It is read only by host-side
# tooling (pnpm db:studio, db:check-drift, scripts/backup.sh) — which also needs
# the Postgres port published, and the bundled compose does NOT publish it. So
# the localhost value below is inert under Compose rather than "correct": leave
# it alone, and note it does not track the POSTGRES_* values you set above.
DATABASE_URL=postgresql://breeze:breeze_secure_password_change_me@localhost:5432/breeze
# Exact unprivileged request-pool URL, for direct (non-Compose) API deployments
# and the production deploy Compose only.
#
# LEAVE THIS EMPTY for the bundled root docker-compose.yml. Unlike DATABASE_URL
# — which compose rebuilds as ...@postgres:5432 inside the api container — this
# value is passed straight through (`${DATABASE_URL_APP:-}`), so a localhost URL
# makes the request pool dial the api container's OWN loopback and the API
# crashes at first-boot seed with ECONNREFUSED 127.0.0.1:5432. Left empty, the
# API derives breeze_app@postgres from the container's own DATABASE_URL (given
# POSTGRES_PASSWORD or BREEZE_APP_DB_PASSWORD, which the bundled compose
# requires anyway).
#
# If you do set it, point it at a host the api container can actually reach
# (never localhost/127.0.0.1/::1 while running under Compose). Every startup
# probes the effective request connection and refuses SUPERUSER or BYPASSRLS
# roles, in every environment.
DATABASE_URL_APP=
# Example for a direct (non-Compose) deployment, pointing at a host the API
# can actually reach:
# DATABASE_URL_APP=postgresql://breeze_app:password@db.example.com:5432/breeze
# Optional: override the breeze_app password. Without DATABASE_URL_APP, Breeze
# derives a breeze_app URL from DATABASE_URL using this value, then
# POSTGRES_PASSWORD as fallback. AUTO_MIGRATE=false skips migrations only; the
# effective request role is still verified at startup.
# BREEZE_APP_DB_PASSWORD=
# --------------------------------------------
# Redis Configuration (BullMQ Job Queue)
# --------------------------------------------
REDIS_PASSWORD=generate-a-random-secret-for-production
REDIS_URL=redis://:${REDIS_PASSWORD}@localhost:6379
REDIS_PORT=6379
# Readiness results are cached and dependency probes are bounded. Values are
# clamped so cache TTL + probe timeout never exceeds the supported 10s
# failure/recovery visibility threshold.
READINESS_CACHE_TTL_MS=5000
READINESS_PROBE_TIMEOUT_MS=3000
# BullMQ concurrency of the offline-detector mark worker (integer 1-20; code
# default 5). Raise only on exact-candidate evidence — the product default
# does not change with this knob.
# OFFLINE_DETECTOR_WORKER_CONCURRENCY=5
# --------------------------------------------
# Authentication & Security
# --------------------------------------------
# CRITICAL: Generate strong secrets for production
# Use: openssl rand -base64 64
JWT_SECRET=your-super-secret-jwt-key-change-in-production-must-be-at-least-32-chars
# Required dedicated HMAC key for opaque partner export cursors (do not reuse JWT_SECRET).
# Generate with: openssl rand -base64 32
# Keep the same protected value on every API instance and include it in secret-store backups.
PARTNER_API_CURSOR_SIGNING_KEY=replace-with-base64-encoded-32-byte-random-key
# Access-token (15m) and refresh-token (7d) lifetimes are fixed in code
# (apps/api/src/services/jwt.ts) — short-lived access tokens backed by
# refresh-token rotation. They are intentionally not env-configurable.
# Durable browser-auth transition enforcement is mandatory. Terminal logout
# preparation remains a separate rollout switch.
AUTH_BROWSER_TERMINAL_PREPARATION_ENABLED=false
# Optional: zero-downtime JWT signing key rotation via `kid` header.
# JSON map of kid → secret string (each ≥32 chars).
# Example: {"2026-05":"<long-random>","2026-08":"<another-long-random>"}
# When set, JWT_SECRET is kept as a verify-only fallback for tokens minted
# before the keyring was deployed (those carry no `kid` header).
JWT_SIGNING_KEYRING=
# Required when JWT_SIGNING_KEYRING is set: which kid signs new tokens.
JWT_ACTIVE_KID=
# Auth/portal cookie policy (optional overrides)
# Valid SameSite values: Lax, Strict, None
# If SameSite=None is used, cookies are automatically marked Secure and require HTTPS.
# If using Lax/Strict in development, ensure browser app and API share the same site
# (for example, both use localhost rather than mixing localhost and an IP/hostname).
# COOKIE_SAME_SITE=Lax
# AUTH_COOKIE_SAME_SITE=Lax
# PORTAL_COOKIE_SAME_SITE=Lax
# Optional force-secure overrides for Lax/Strict cookies:
# COOKIE_FORCE_SECURE=false
# AUTH_COOKIE_FORCE_SECURE=false
# PORTAL_COOKIE_FORCE_SECURE=false
# Refresh-token rotation leeway, in seconds (#1107). Within this window after a
# refresh token is legitimately rotated, replaying the just-rotated token is
# treated as a benign concurrent race (multi-tab / heartbeat / page reload mid-
# refresh) instead of a reuse attack, so the browser is not logged out on a hard
# refresh. The replayed token still cannot mint anything. Set to 0 to restore
# strict, no-leeway reuse-detection. Default: 15.
# REFRESH_ROTATION_GRACE_SECONDS=15
# Agent enrollment secret (for device registration)
# Use: openssl rand -hex 32
# In production, agents must provide this during enrollment:
# breeze-agent enroll <enrollment-key> --server https://your-server --enrollment-secret <AGENT_ENROLLMENT_SECRET>
# Or set: BREEZE_AGENT_ENROLLMENT_SECRET=<AGENT_ENROLLMENT_SECRET>
# Production startup rejects placeholder or short values when this is set.
# Use at least 32 random characters, for example: openssl rand -base64 32
AGENT_ENROLLMENT_SECRET=your-enrollment-secret-change-in-production
# Per-source-IP limit on agent enrollment (POST /api/v1/agents/enroll). Default:
# 10 per 60 seconds. Machines behind one NAT address share a single bucket, so
# raise this before rolling out a site with more than ~10 machines at once.
# AGENT_ENROLL_RATE_LIMIT=10
# AGENT_ENROLL_RATE_WINDOW_SECONDS=60
# Initial admin for a production database with no users. Leave blank after the
# bootstrap admin has logged in and completed setup. The API never logs this
# password. Generate a one-time value when needed:
# BREEZE_BOOTSTRAP_ADMIN_PASSWORD="$(openssl rand -base64 32)"
BREEZE_BOOTSTRAP_ADMIN_EMAIL=
BREEZE_BOOTSTRAP_ADMIN_PASSWORD=
# BREEZE_BOOTSTRAP_ADMIN_NAME=Owner Admin
# --------------------------------------------
# Customer Microsoft 365 Graph-read onboarding (optional, OFF)
# --------------------------------------------
# The private executor is deliberately NOT deployed by this generic Compose
# stack. Run its separately released digest-pinned image in an environment
# that supplies managed/workload identity, private authenticated ingress, and
# controlled HTTPS egress to Key Vault, Microsoft identity, and Graph. Never
# expose it through Caddy or configure a plaintext executor URL.
# The identity-capable runtime must pin the executor image by digest and apply
# equivalents of: --read-only --cap-drop=ALL --security-opt=no-new-privileges
# --tmpfs /tmp:rw,noexec,nosuid,size=64m. Do not publish its container port.
# Image form: ghcr.io/lanternops/breeze/m365-graph-read-executor@sha256:<digest>
M365_CUSTOMER_GRAPH_READ_ONBOARDING_ENABLED=false
# Literal * or comma-separated Breeze organization UUIDs; leave empty while off.
M365_CUSTOMER_GRAPH_READ_ONBOARDING_ORG_IDS=
M365_CUSTOMER_GRAPH_READ_CLIENT_ID=
M365_CUSTOMER_GRAPH_READ_CALLBACK_URL=
M365_CUSTOMER_GRAPH_READ_CREDENTIAL_VERSION=
# akv://vault-host/m365-customer-graph-read/<32-lowercase-hex-version>
M365_CUSTOMER_GRAPH_READ_VAULT_REF=
# Required to be private HTTPS when enabled.
M365_GRAPH_READ_EXECUTOR_URL=
M365_GRAPH_READ_EXECUTOR_AUDIENCE=m365-graph-read-executor
M365_GRAPH_READ_EXECUTOR_SIGNING_KID=
# Host path to the API's Ed25519 private signing JWK. Standalone Compose cannot
# apply secret uid/gid/mode: provision the source with numeric owner 1001:1001 and mode 0400.
# It is mounted read-only at /run/secrets/m365_graph_read_executor_signing_private_jwk.
# The separately deployed executor receives only the matching PUBLIC JWK.
# Example when enabling: /path/to/m365-graph-read-api-signing-private.jwk
M365_GRAPH_READ_EXECUTOR_SIGNING_PRIVATE_JWK_SOURCE_FILE=
# Independent of M365_CUSTOMER_GRAPH_READ_ONBOARDING_ENABLED above; gates the
# six m365_query_* AI tools instead of connection onboarding. Enabling it
# still requires every executor descriptor above (it forces full executor
# config validation at boot).
M365_GRAPH_READ_TOOLS_ENABLED=false
# Literal * or comma-separated Breeze organization UUIDs; required when the
# flag above is enabled, leave empty while off.
M365_GRAPH_READ_TOOLS_ORG_IDS=
# ---- Microsoft 365 tenant sync (posture snapshots) ----------------------
# Gates EVERY sync entry point: the 60s ticker, consent seeding, and the
# on-demand sync route. Default false. Turning it on is the only rollout
# step — the ticker seeds state rows for existing executable connections
# itself, staggered over the first hour.
M365_TENANT_SYNC_ENABLED=false
# Per-API-instance concurrency for the sync-domain job.
M365_SYNC_CONCURRENCY=4
# Ticker backpressure ceiling (waiting+prioritized+delayed+active).
M365_SYNC_MAX_BACKLOG=500
# Rows claimed per 60s tick. This is the capacity dial: BATCH x 1440 slots
# per day should run at <= 50% utilisation (spec §5.9).
M365_SYNC_TICK_BATCH=200
# --------------------------------------------
# Customer Microsoft 365 Graph-actions (write) executor (optional, OFF)
# --------------------------------------------
# Same isolation model as the Graph-read executor above: this private executor
# is deliberately NOT deployed by this generic Compose stack. Run its
# separately released digest-pinned image in an environment that supplies
# managed/workload identity, private authenticated ingress, and controlled
# HTTPS egress to Key Vault, Microsoft identity, and Graph. Never expose it
# through Caddy or configure a plaintext executor URL.
# The identity-capable runtime must pin the executor image by digest and apply
# equivalents of: --read-only --cap-drop=ALL --security-opt=no-new-privileges
# --tmpfs /tmp:rw,noexec,nosuid,size=64m. Do not publish its container port.
# Image form: ghcr.io/lanternops/breeze/m365-graph-actions-executor@sha256:<digest>
# Onboarding the org-facing consent connection ("Customer Graph Actions" card).
# Independent of M365_GRAPH_ACTIONS_TOOLS_ENABLED below (that one gates the AI
# tools/headless execution instead) — enabling EITHER one forces full executor
# descriptor validation at boot, which in production also requires PUBLIC_URL,
# PUBLIC_APP_URL, or PUBLIC_API_URL to be set (used to derive the consent
# callback origin; boot refuses to start without one of them).
M365_CUSTOMER_GRAPH_ACTIONS_ONBOARDING_ENABLED=false
# Literal * or comma-separated Breeze organization UUIDs; required when the
# flag above is enabled, leave empty while off.
M365_CUSTOMER_GRAPH_ACTIONS_ONBOARDING_ORG_IDS=
# Gates the M365 write-action AI tools (mailbox rules, password reset, etc.)
# and headless release-worker execution of approved action_intents.
# Disabled by default. Enabling it also requires APP_ENCRYPTION_KEY_ID (see
# Server Configuration below) — without it, boot refuses to start, because the
# m365_reset_password reveal-secret path seals its temp credential with
# AAD-bound v3 ciphertext.
M365_GRAPH_ACTIONS_TOOLS_ENABLED=false
# Literal * or comma-separated Breeze organization UUIDs; leave empty while off.
M365_GRAPH_ACTIONS_TOOLS_ORG_IDS=
M365_CUSTOMER_GRAPH_ACTIONS_CLIENT_ID=
M365_CUSTOMER_GRAPH_ACTIONS_CREDENTIAL_VERSION=
# akv://vault-host/m365-customer-graph-actions/<32-lowercase-hex-version>
M365_CUSTOMER_GRAPH_ACTIONS_VAULT_REF=
# Required to be private HTTPS when enabled.
M365_GRAPH_ACTIONS_EXECUTOR_URL=
M365_GRAPH_ACTIONS_EXECUTOR_AUDIENCE=m365-graph-actions-executor
M365_GRAPH_ACTIONS_EXECUTOR_SIGNING_KID=
# Host path to the API's Ed25519 private signing JWK. Standalone Compose cannot
# apply secret uid/gid/mode: provision the source with numeric owner 1001:1001 and mode 0400.
# It is mounted read-only at /run/secrets/m365_graph_actions_executor_signing_private_jwk.
# The separately deployed executor receives only the matching PUBLIC JWK, plus
# its OWN M365_CUSTOMER_GRAPH_ACTIONS_CALLBACK_URL, which must byte-match the
# origin above + /api/v1/m365/actions-consent/callback (mismatch fails the
# identity-verification leg with identity_token_invalid). See
# docs/deploy/m365-customer-graph-actions-executor.md for the full executor env.
M365_GRAPH_ACTIONS_EXECUTOR_SIGNING_PRIVATE_JWK_SOURCE_FILE=
# M365 communications-delegated (per-USER axis; dark by default)
M365_COMMS_ONBOARDING_ENABLED=false
M365_COMMS_ONBOARDING_USER_IDS=
M365_COMMS_TOOLS_ENABLED=false
M365_COMMS_TOOLS_USER_IDS=
M365_COMMS_CLIENT_ID=
M365_COMMS_EXECUTOR_URL=https://comms-executor.internal/
M365_COMMS_EXECUTOR_AUDIENCE=m365-communications-executor
M365_COMMS_EXECUTOR_SIGNING_KID=
M365_COMMS_EXECUTOR_SIGNING_PRIVATE_JWK_FILE=/run/secrets/m365-comms-executor-signing.jwk
# --------------------------------------------
# Server Configuration
# --------------------------------------------
NODE_ENV=production
API_PORT=3001
WEB_PORT=4321
# Public URL for the API. Docker defaults this to https://${BREEZE_DOMAIN} for
# generated agent installers and shareable links. Override it only when agents
# must use a different externally reachable API URL.
# For cookie auth with SameSite=Lax/Strict, this must use the same site as the browser page URL.
PUBLIC_API_URL=
# Network Proxy dedicated tunnel origin (optional). Unset keeps path mode.
# Must be a DIFFERENT registrable domain from the app, e.g.
# TUNNEL_ORIGIN_TEMPLATE=https://{id}.us.tunnel.example.net
# TUNNEL_FRAME_ANCESTOR defaults to PUBLIC_APP_URL's origin.
# TUNNEL_ORIGIN_TEMPLATE=
# TUNNEL_FRAME_ANCESTOR=
# Legacy API URL fallback used by installer/share-link generation when PUBLIC_API_URL is unset.
API_URL=
# Customer portal (apps/portal) — served under a base path on the main domain
# (default /portal) via the Caddy carve-out, so it needs no dedicated hostname/cert.
# PORTAL_BASE_PATH is baked into the portal image at build time; keep it in sync
# with the Caddyfile carve-out (docker/Caddyfile.prod) and the build-arg.
PORTAL_BASE_PATH=/portal
# Public origin for portal links in outbound email (e.g. quote accept links).
# Defaults to https://${BREEZE_DOMAIN}/portal; falls back to PUBLIC_APP_URL if unset.
PUBLIC_PORTAL_URL=
# --------------------------------------------
# Production Deploy
# --------------------------------------------
# Domain and ACME config used by Caddy and deploy scripts
# Use "localhost" for local testing (Caddy generates a self-signed TLS cert;
# your browser will show a certificate warning — accept it to proceed).
# For production, set to your actual domain.
BREEZE_DOMAIN=localhost
ACME_EMAIL=admin@yourdomain.com
# Uncomment (value must be the literal `local_certs`) when BREEZE_DOMAIN is a domain
# Let's Encrypt cannot reach — internal-only DNS, or ports 80/443 closed to the
# internet — so Caddy issues from its internal CA instead of failing the ACME order
# and then aborting the TLS handshake (browsers show ERR_SSL_PROTOCOL_ERROR, not a
# clickable cert warning). Trust the root on each client to silence the warning:
# docker compose exec caddy cat /data/caddy/pki/authorities/local/root.crt
# Leave empty for any internet-reachable deployment. Not needed for "localhost".
#CADDY_LOCAL_CERTS=local_certs
# --------------------------------------------
# Object Storage (S3/R2/MinIO)
# --------------------------------------------
# S3 is "soft-enabled" by the presence of S3_BUCKET. When S3_BUCKET is set in
# production, S3_ACCESS_KEY and S3_SECRET_KEY are REQUIRED — the API refuses to
# boot otherwise (Task 26 / audit H-3). Without this gate, the first upload
# request 5xxs at runtime instead of failing fast at startup.
S3_ENDPOINT=http://localhost:9000
S3_ACCESS_KEY=minioadmin
S3_SECRET_KEY=minioadmin
S3_BUCKET=breeze
S3_REGION=us-east-1
# Presigned URL expiration (seconds). Default: 900 (15 minutes)
# S3_PRESIGN_TTL=900
# MinIO Console (when using local MinIO)
MINIO_API_PORT=9000
MINIO_CONSOLE_PORT=9001
# --------------------------------------------
# Email Configuration
# --------------------------------------------
# Required for password reset and notifications.
# EMAIL_PROVIDER:
# - auto (default): use Resend, then SMTP, then Mailgun when configured
# - resend: require RESEND_API_KEY + EMAIL_FROM
# - smtp: require SMTP_HOST + SMTP_FROM (EMAIL_FROM can be used as fallback)
# - mailgun: require MAILGUN_API_KEY + MAILGUN_DOMAIN (MAILGUN_FROM or EMAIL_FROM)
#
# In production (Task 26 / audit H-3): when EMAIL_PROVIDER explicitly selects a
# backend (resend/smtp/mailgun), the API refuses to boot if the corresponding
# secret is missing. 'auto' and unset stay in best-effort mode (no boot gate).
EMAIL_PROVIDER=auto
RESEND_API_KEY=
EMAIL_FROM=noreply@breeze.local
SMTP_HOST=
SMTP_PORT=587
SMTP_USER=
SMTP_PASS=
SMTP_FROM=noreply@breeze.local
SMTP_SECURE=false
MAILGUN_API_KEY=
MAILGUN_DOMAIN=
MAILGUN_BASE_URL=https://api.mailgun.net
MAILGUN_FROM=noreply@breeze.local
# Transport deadlines (#3905). Both are optional; the defaults below apply when
# unset. A malformed value is REJECTED at startup (the API reports email as not
# configured) rather than silently falling back — a transport with no bound is
# how a stalled mail server used to hold a send for ten minutes.
#
# SMTP_TIMEOUT_MS connection + greeting + socket deadline for the SMTP
# transport. This is an INACTIVITY timeout, so it does not cap
# how long a large PDF attachment may take to upload.
# MAILGUN_TIMEOUT_MS whole-request deadline for the Mailgun API call. Larger,
# because it DOES include the multipart attachment upload.
# Range for both: 1000-600000 ms.
SMTP_TIMEOUT_MS=30000
MAILGUN_TIMEOUT_MS=120000
# --------------------------------------------
# Custom sender addresses / partner sending domains (optional)
# --------------------------------------------
# OFF by default. With EMAIL_DOMAINS_PROVIDER unset nothing changes: the
# settings tab is hidden, the routes 404, the worker is not registered, and
# every email is sent exactly as before. Upgrading never requires any of these.
#
# EMAIL_DOMAINS_PROVIDER picks HOW a sending domain is proven:
# - unset : feature off (default).
# - static : you attest that this instance's mail relay may send as the
# domains you list below. Use this on SMTP or Mailgun, where there
# is no domain API for Breeze to check. SPF/DKIM for those domains
# are YOUR mail setup; Breeze cannot verify them. A domain becomes
# usable once a test send from it is accepted by the relay.
# - resend : Breeze creates the domain in your Resend account and shows the
# DKIM/SPF records to publish. Needs a full_access key — a
# sending-only key cannot manage domains.
# - fake : deterministic test double. Refused in production.
#
# NOTE: this is the domain Breeze SENDS AS, which is not TICKETS_INBOUND_DOMAIN
# (what Breeze RECEIVES on) and not MAILGUN_DOMAIN.
EMAIL_DOMAINS_PROVIDER=
# `static` only. Comma-separated `domain` or `domain:partner-slug`. An unbound
# entry may be claimed by any partner on this instance, which is right for a
# single-partner install.
EMAIL_DOMAINS_STATIC_ALLOWED=
# `resend` only. full_access key of the account that holds partner domains. One
# account for both lanes is fine self-hosted; the trade-off is that partner
# domains then share your platform mail's sending reputation.
EMAIL_DOMAINS_RESEND_API_KEY=
# Optional sending_access key of the same account, so the send path never holds
# the management key. Falls back to EMAIL_DOMAINS_RESEND_API_KEY.
EMAIL_DOMAINS_RESEND_SENDING_KEY=
# Resend region for newly created domains: us-east-1, eu-west-1, sa-east-1, ap-northeast-1.
EMAIL_DOMAINS_REGION=us-east-1
# Sending domains one partner may hold.
EMAIL_DOMAINS_MAX_PER_PARTNER=3
# Partner-lane messages per partner per UTC day. 0 = unlimited (the self-hosted
# default). Over the cap a message goes out from EMAIL_FROM as it would today.
EMAIL_DOMAINS_DAILY_SEND_CAP=0
# Comma-separated partner ids. Empty means every eligible partner.
EMAIL_DOMAINS_PARTNER_ALLOWLIST=
# Extra domains nobody on this instance may add, comma-separated.
EMAIL_DOMAINS_DENYLIST=
# Delivery-webhook signing secret. Unset leaves the endpoint inert.
EMAIL_DOMAINS_WEBHOOK_SECRET=
# Automatic suspension of a partner's sending domains on poor deliverability.
# ON by default on hosted (0.08 / 50 / 3). OFF on a self-hosted instance until
# you UNCOMMENT at least one of the three — then the two you leave commented use
# the defaults shown. They ship commented out on purpose: any non-empty value
# reads as "the operator configured this" and turns auto-suspension on, so a
# copied example file must not quietly start suspending your only sending
# domain. Unsuspending is always manual (platform admin).
# 7-day bounce rate above this fraction suspends every domain of the partner.
# EMAIL_DOMAINS_AUTOSUSPEND_BOUNCE_RATE=0.08
# ...but only once the 7-day window holds at least this many messages.
# EMAIL_DOMAINS_AUTOSUSPEND_MIN_MESSAGES=50
# Spam complaints in 7 days that suspend regardless of the rate.
# EMAIL_DOMAINS_AUTOSUSPEND_COMPLAINTS=3
# --------------------------------------------
# Inbound Email-to-Ticket (native inbound address, via Mailgun)
# --------------------------------------------
# Optional. Powers the partner inbound address shown on Settings → Ticketing →
# Inbound email. Leave both unset to run without a native inbound address (the
# card will report that the platform inbound domain isn't configured); the
# Microsoft 365 shared-mailbox path below is independent of these.
#
# TICKETS_INBOUND_DOMAIN is the domain inbound ticket addresses live on — it is
# the domain Breeze RECEIVES on, and is NOT the same setting as MAILGUN_DOMAIN
# (which is what Breeze SENDS from), even when both name the same domain.
#
# MAILGUN_INBOUND_SIGNING_KEY is Mailgun's HTTP webhook SIGNING key (not
# MAILGUN_API_KEY). The inbound webhook is unauthenticated by design and relies
# solely on this HMAC, so while it is unset every delivery is rejected with 401.
#
# Also required, on the Mailgun side: add the domain as a RECEIVING domain,
# publish its MX records, and create a route forwarding to
# https://<PUBLIC_URL>/api/v1/webhooks/tickets/email-inbound
# See https://docs.breezermm.com/deploy/environment/#inbound-email-to-ticket-mailgun
TICKETS_INBOUND_DOMAIN=
MAILGUN_INBOUND_SIGNING_KEY=
#
# INBOUND_QUEUE_MAX_PER_SEC caps how many inbound email jobs are PROCESSED per
# second across all senders (flood backpressure). Over-rate jobs are delayed by
# BullMQ, never dropped. Default 20/s; raise it for a high-volume tenant, lower it
# to slow a burst. Bounds the RATE of ticket creation, not the total.
INBOUND_QUEUE_MAX_PER_SEC=20
# --------------------------------------------
# Twilio Configuration (SMS MFA + SMS Alert Notifications)
# --------------------------------------------
TWILIO_ACCOUNT_SID=
TWILIO_AUTH_TOKEN=
TWILIO_VERIFY_SERVICE_SID=
TWILIO_MESSAGING_SERVICE_SID=
TWILIO_PHONE_NUMBER=
# --------------------------------------------
# AI Brain (BYOK - Bring Your Own Key)
# --------------------------------------------
# Optional: Provide your Anthropic API key to enable the AI assistant
ANTHROPIC_API_KEY=
# Self-hosted only: point the AI Agent at any Anthropic-compatible
# /v1/messages backend (local vLLM 0.23+, a LiteLLM gateway, etc.) while
# keeping full tool-use and the guardrails stack. Must be an http(s) URL.
# Fail-closed: honored ONLY when IS_HOSTED is explicitly false/0/no/off. If
# IS_HOSTED is true, unset, or invalid, the API boot-refuses this var and
# strips it from the AI subprocess (the platform must never route AI traffic to
# a third-party endpoint). Set ANTHROPIC_AUTH_TOKEN to the backend's bearer
# token instead of ANTHROPIC_API_KEY. With a LiteLLM gateway, alias
# claude-sonnet-5-5 → your backend model and leave ANTHROPIC_MODEL unset (the
# gateway must accept or drop adaptive thinking + effort, e.g. LiteLLM
# drop_params); for a raw vLLM endpoint, set ANTHROPIC_MODEL to the served model
# id (an unrecognised id is sent thinking disabled, as before). Note: an
# unrecognized model id is cost-tracked at Opus-tier DEFAULT_PRICING, not $0.
# IS_HOSTED=false
# ANTHROPIC_BASE_URL=http://localhost:8000
# ANTHROPIC_AUTH_TOKEN=
# ANTHROPIC_MODEL=
# AI chat tool loading: auto (default) uses tool search on the Anthropic API and
# sends every tool on a custom ANTHROPIC_BASE_URL / BYO provider; on = also use
# tool search through a gateway that forwards tool_reference blocks; off = always
# send every tool.
# AI_TOOL_SEARCH=auto
# --------------------------------------------
# Hardware Warranty Lookups (optional)
# --------------------------------------------
# The warranty worker looks each physical device's serial number up against its
# manufacturer so the device page and warranty-expiry alerts have real dates.
# Every vendor is OFF until configured; unconfigured vendors simply leave their
# devices at status "unknown". Apple coverage is reported by the macOS agent and
# needs nothing here.
#
# Dell — official TechDirect Warranty API. Register at https://techdirect.dell.com
# (API → Warranty), create an app, and paste its OAuth client credentials.
DELL_CLIENT_ID=
DELL_CLIENT_SECRET=
# Lenovo — either or both:
# LENOVO_API_KEY: ClientID for Lenovo's official Warranty API
# (supportapi.lenovo.com, issued through a Lenovo partner manager).
# LENOVO_WARRANTY_ENABLED=true: no credential; uses the JSON endpoint behind
# Lenovo's public warranty-lookup page. Undocumented by Lenovo, so it may
# stop working without notice — hence opt-in. When both are set the
# official API is authoritative and the public endpoint is only a fallback.
LENOVO_API_KEY=
LENOVO_WARRANTY_ENABLED=
# --------------------------------------------
# Agent control-plane migration
# --------------------------------------------
# Backup control-plane URL pushed to ALL agents via heartbeat (#2288).
# Agents fail over to it automatically (and permanently, keeping the old URL
# as rollback) after ~10 consecutive failed heartbeats to the primary.
# Must be https:// (http:// allowed only for localhost). Malformed values
# refuse to boot. Leave unset to clear any previously-pushed backup.
# Remember: also map this var in the api service `environment:` block of your
# docker-compose.yml — a value in .env alone is not interpolated.
# Migration playbook: docs/deploy/agent-server-url-migration.md
# AGENT_BACKUP_SERVER_URL=https://your-new-domain.example.com
# --------------------------------------------
# Bare-metal recovery media
# --------------------------------------------
# Scratch directory recovery bundle/ISO builds use for their working
# directory (a 45-65MB helper binary plus a tarball or ISO, per build). The
# api and worker containers mount /tmp as a 64MB tmpfs, so leaving this
# unset and building under the OS temp dir fails with ENOSPC (issue: D7).
# docker-compose.yml / deploy/docker-compose.prod.yml already default this
# to a `recovery-work` directory under the persistent api_data volume
# (/data/recovery-work) — override only if you want it somewhere else on
# durable storage. Already mapped in the api/worker `environment:` blocks;
# a value here alone would otherwise be silently inert.
# RECOVERY_MEDIA_WORK_DIR=/data/recovery-work
# Backup GC per-object grace in ms (default 48h). Lab/test knob only - never lower it on a real deployment.
# BACKUP_GC_GRACE_MS=
# Backup storage reclamation (D18). All optional, in ms; production enforces a floor on each.
# Already mapped in the api `environment:` block - a value here alone would otherwise be inert.
# BACKUP_BASE_LEASE_MS=604800000 # 7 d - how long a dispatched job may publish against its pinned base (floor 2 h; the helper stops publishing 1 h before the deadline, so <= 1 h fails every backup)
# BACKUP_PUBLISH_MARGIN_MS=3600000 # 1 h - safety margin before lease expiry at which the helper refuses to publish (floor 5 min)
# BACKUP_RESTORE_PIN_LINGER_MS=604800000 # 7 d - how long a finished restore keeps its snapshot pinned (floor 1 h)
# BACKUP_GC_ORPHAN_MANIFEST_MAX_AGE_MS=777600000 # 9 d - age after which a manifest with no database row is reclaimed
# BACKUP_GC_MANIFESTLESS_PREFIX_MAX_AGE_MS=777600000 # 9 d - age after which an unfinished upload (prefix with no manifest) is reclaimed
# --------------------------------------------
# Breeze AI for Office (Excel add-in)
# --------------------------------------------
# Entra application (client) ID of the multi-tenant add-in app registration.
# Leave empty to disable the /client-ai surface entirely.
CLIENT_AI_ENTRA_CLIENT_ID=
# --------------------------------------------
# Encryption & Security Keys
# --------------------------------------------
# CRITICAL: Generate unique keys for production
# Use: openssl rand -hex 32
APP_ENCRYPTION_KEY=generate-a-random-hex-string-for-production
# Key id enabling AAD-bound v3 ciphertext (vs v1 with no AAD binding).
# Required for integration credential sealing; boot WARNS when unset (it does not
# refuse). Integration provider credentials
# (/integrations/{communication,monitoring,ticketing,psa}) are sealed with v3
# ciphertext bound to the provider, organization and field path and fail closed
# on v1, so saving one returns 503 until this is set. Everything else keeps
# working, so an existing install without it stays up.
# Required outright (boot refuses), in any environment, once
# M365_GRAPH_ACTIONS_TOOLS_ENABLED=true —
# the m365_reset_password reveal-secret path seals its temp credential with v3
# ciphertext and fails closed on v1 — and when BREEZE_ROLE is "api" or "worker".
# Any [A-Za-z0-9._-]+ identifier; changing it does not itself rotate
# APP_ENCRYPTION_KEY. scripts/guided-setup.sh generates key-YYYYMMDD on first
# run and never overwrites an existing value; set it by hand if you install
# without the guided script. To rotate, see docs/operations/SECRET_ROTATION.md.
APP_ENCRYPTION_KEY_ID=
MFA_ENCRYPTION_KEY=generate-a-random-hex-string-for-production
# Production startup rejects placeholder or short values when this is set.
# Leave unset to fall back to APP_ENCRYPTION_KEY, or set at least 32 random characters.
ENROLLMENT_KEY_PEPPER=generate-a-random-hex-string-for-production
MFA_RECOVERY_CODE_PEPPER=generate-a-random-hex-string-for-production
# Required in production for both BINARY_SOURCE=github and BINARY_SOURCE=local.
# - github: verifies installer fallback assets against the signed release manifest.
# - local: anchors per-deployment update manifests; without a trust root, agents
# accept unsigned manifests, defeating the agent-update trust chain.
# Comma-separate multiple raw base64 Ed25519 public keys during release-manifest
# signing key rotation.
# The value below is the trust anchor for the official Breeze GitHub releases.
# It is a PUBLIC key (also embedded in the agent and CI), so it is safe to ship.
# Leave it as-is unless you build and sign your own binaries.
RELEASE_ARTIFACT_MANIFEST_PUBLIC_KEYS=yzx8ftmcls6uBetFC5SYnZhBo+cbur3IX50TbBthTso=
# Default TTL (minutes) for newly created enrollment keys when expiresAt is not provided.
# 43200 = 30 days: downloaded installers are expected to keep working after being
# staged through deployment tooling, not just on the day of download.
ENROLLMENT_KEY_DEFAULT_TTL_MINUTES=43200
# Fresh TTL (minutes) given to a "child" enrollment key minted at installer-download,
# installer-link, or short-code redemption time. Measured from mint time, independent
# of the parent key's remaining lifetime.
CHILD_ENROLLMENT_KEY_TTL_MINUTES=43200
# Base TTL (minutes) for a freshly-issued installer bootstrap token when no explicit
# ttlMinutes is supplied. The token's own expiry -- not the parent enrollment key's --
# governs how long an installer has to complete enrollment.
# 10080 = 7 days (default). Maximum 43200 (30 days); a larger value is clamped to it,
# with a warning at API startup.
# Tokens already issued keep the expiry they were issued with.
INSTALLER_BOOTSTRAP_TOKEN_TTL_MINUTES=10080
# Minimum remaining lifetime (seconds) a parent enrollment key must have to be used as
# an installer source. Guards against building an installer from a parent that expires
# before the download reaches the target machine.
INSTALLER_PARENT_MIN_REMAINING_SECONDS=60
# Set to false to disable the daily sweep that purges expired enrollment keys.
ENROLLMENT_KEY_CLEANUP_ENABLED=true
# Grace period (days past expiry) before an expired enrollment key is purged by the
# daily sweep.
ENROLLMENT_KEY_PURGE_AFTER_DAYS=7
# Maximum TTL (minutes) a Partner API caller may request for an enrollment key.
# Applies to both ttlMinutes and expiresAt. May only NARROW the built-in
# 365-day ceiling, never widen it. Leave unset for that 365-day ceiling; the
# example below is a 7-day operator cap.
PARTNER_API_ENROLLMENT_KEY_MAX_TTL_MINUTES=10080
# Dedicated per-service-principal mint limit per hour. Default: 10.
PARTNER_API_ENROLLMENT_KEY_WRITE_RATE_LIMIT=10
# Partner-wide mint limit per hour, charged alongside the per-principal bucket.
# A partner admin can create service principals, so a per-principal limit alone
# is multiplied by however many they create. Default: 100.
PARTNER_API_ENROLLMENT_KEY_WRITE_PARTNER_RATE_LIMIT=100
# Hourly ceiling for Partner API TICKET writes (POST/PATCH under
# /partner-api/tickets) per service principal + key, in its own bucket so a
# PSA/ITSM mirror never spends the 120/hour provisioning write budget. Always
# capped by the key's own rate limit. Default: 1200.
PARTNER_API_TICKET_WRITE_RATE_LIMIT_PER_HOUR=1200
# Partner-wide ceiling for the same writes, charged after the per-principal
# bucket. A partner admin can create service principals, so a per-principal
# limit alone is multiplied by however many they create. Default: 6000.
PARTNER_API_TICKET_WRITE_PARTNER_RATE_LIMIT_PER_HOUR=6000
# Daily sweep that reaps X-Idempotency-Key claims of Partner API ticket writes
# once they are older than the retry window. Set to false to disable.
PARTNER_API_IDEMPOTENCY_RETENTION_ENABLED=true
# Days a ticket-write idempotency claim is kept; a retry with an old key after
# this window is a fresh request. Default: 7.
PARTNER_API_IDEMPOTENCY_RETENTION_DAYS=7
# Ticket migration windows (#8313): the time box an admin may give one import,
# in days (a longer migration opens a new window). Default: 14.
MIGRATION_WINDOW_MAX_DAYS=14
# Import writes per hour a new window gets when the admin sets none, and the
# most an admin may set. The floor is 600. Defaults: 6000 and 20000.
MIGRATION_WINDOW_DEFAULT_WRITES_PER_HOUR=6000
MIGRATION_WINDOW_MAX_WRITES_PER_HOUR=20000
# --------------------------------------------
# CORS Configuration
# --------------------------------------------
# Comma-separated list of allowed origins for CORS.
# Include scheme + hostname + port (for example, http://localhost:4321).
# In production, default localhost origins are excluded unless CORS_INCLUDE_DEFAULT_ORIGINS=true.
CORS_ALLOWED_ORIGINS=https://app.yourdomain.com
# CORS_INCLUDE_DEFAULT_ORIGINS=true # Uncomment to re-include localhost origins in production
# --------------------------------------------
# Portal State Backend
# --------------------------------------------
# Set to 'redis' for production deployments. Defaults to in-memory for development.
# In production (NODE_ENV=production), Redis is used automatically.
# PORTAL_STATE_BACKEND=redis
# --------------------------------------------
# MCP Server Configuration
# --------------------------------------------
# Rate limits for MCP API endpoints (per API key, per minute)
# MCP_SSE_RATE_LIMIT_PER_MINUTE=30
# MCP_MESSAGE_RATE_LIMIT_PER_MINUTE=120
# Max concurrent SSE sessions per API key (defense-in-depth against leaked keys)
# MCP_MAX_SSE_SESSIONS_PER_KEY=5
# tools/list: 0/unset = single page; set to e.g. 50 once every connected client honours nextCursor.
# MCP_TOOLS_LIST_PAGE_SIZE=0
# Optional hardening: require `ai:execute_admin` scope (in addition to `ai:execute`) for Tier 3 tools in production
# MCP_REQUIRE_EXECUTE_ADMIN=true
# Comma-separated list of tool names allowed for Tier 3+ (destructive/sensitive) operations in production.
# Empty or unset = deny all. Use '*' to allow all (not recommended).
# Tier 3 includes: computer_control, take_screenshot, analyze_screen, execute_command,
# security_scan, s1_isolate_device, network_discovery, apply_cis_remediation, and others.
# Screen-capture tools (take_screenshot, analyze_screen) are Tier 3 because screen
# contents may include credentials, customer data, or other sensitive output.
# MCP_EXECUTE_TOOL_ALLOWLIST=run-script,restart-service
# Operator opt-in (default: nobody). Comma-separated principals allowed to run Tier 3 tools over
# MCP WITHOUT interactive approval: `api_key:<api key id>`, `oauth_client_user:<OAuth client_id>/<user id>`
# or `partner_sp:<partner service principal id>`.
# Only removes the MCP_APPROVAL_REQUIRED deny for those principals; ai:execute, ai:execute_admin,
# the allowlist above, RBAC, the Tier 3 execution ledger and audit logging still apply. Tier 4,
# tenant (BYO MCP) and bootstrap tools stay approval-only. For trusted unattended agents only.
# MCP_UNATTENDED_TIER3_PRINCIPALS=
# --------------------------------------------
# WebRTC / Remote Desktop (TURN Server)
# --------------------------------------------
# Required for remote desktop to work across NATs and firewalls.
# TURN_HOST must be the public IP of the TURN server (not a domain behind Cloudflare).
# If Breeze runs behind Cloudflare Tunnel, deploy TURN on a separate VPS
# and point TURN_HOST to that VPS's public IP. See docs: /deploy/turn-server/
# Use: openssl rand -hex 32 to generate TURN_SECRET
#
# Leave these blank if you are not using remote desktop — the stack starts fine
# without them. Only the opt-in `turn` profile needs TURN_SECRET, and coturn
# refuses to start on an empty one rather than running as an open relay.
TURN_HOST=
TURN_PORT=3478
TURN_SECRET=
TURN_REALM=breeze.local
TURN_CREDENTIAL_TTL_SECONDS=600
TURN_MAX_BPS=5000000
#
# TURN over TLS (turns: on 5349) — optional, off by default.
# Needed for clients on networks that block UDP or allow only a short list of
# TCP ports. Leave both unset to keep plain turn: on 3478 only.
#
# TURN_TLS_DIR: host directory holding `cert.pem` + `privkey.pem`, bind-mounted
# read-only into the bundled coturn at /etc/coturn/tls. coturn drops to uid
# 65534 (`nobody`), so the pair must be readable by that uid — Caddy writes
# its certificate `0600 root`, so copy it to a staging directory at 0640
# rather than mounting Caddy's directory. coturn reads the certificate once at
# startup and never reloads it: restart (or SIGHUP) coturn on every renewal,
# or TURNS breaks silently ~60 days in.
# TURN_TLS_HOST: the hostname ON the certificate. The API advertises
# `turns:${TURN_TLS_HOST}:${TURN_TLS_PORT}?transport=tcp` only when this is
# set — TURN_HOST is a bare IP and would fail certificate validation.
# TURN_TLS_PORT: defaults to 5349.
# TURN_TLS_DIR=/opt/breeze/coturn-tls
# TURN_TLS_HOST=turn.your-domain.example.com
# TURN_TLS_PORT=5349
# Enable the TURN profile so coturn starts with `docker compose up -d`:
# COMPOSE_PROFILES=turn
# --------------------------------------------
# Storage & Remote Sessions
# --------------------------------------------
# Caps to prevent remote session resource exhaustion (set to 0 to disable)
MAX_ACTIVE_REMOTE_SESSIONS_PER_ORG=10
MAX_ACTIVE_REMOTE_SESSIONS_PER_USER=5
PATCH_REPORT_STORAGE_PATH=./data/patch-reports
# Days a generated patch compliance report file is kept before it is removed (minimum 1).
PATCH_REPORT_RETENTION_DAYS=30
# --------------------------------------------
# Monitoring & Metrics
# --------------------------------------------
METRICS_SCRAPE_TOKEN=generate-a-random-token-for-production
# Redact org IDs from Prometheus labels in production by default (set to true to include).
METRICS_INCLUDE_ORG_ID=false
# Optional: restrict /metrics/scrape by trusted client IP(s).
# Note: this needs a trusted client IP — see TRUST_PROXY_HEADERS below (on by
# default for the bundled stack; off means this matches the proxy, not clients).
METRICS_SCRAPE_IP_ALLOWLIST=
# Grafana credentials (used by docker-compose monitoring stack)
GRAFANA_ADMIN_USER=admin
GRAFANA_ADMIN_PASSWORD=changeme
# Event-loop lag monitor. On by default; these are tuning knobs, not requirements.
# It is what makes a stalled main thread visible as a stall instead of as a
# misleading Postgres "write CONNECT_TIMEOUT". Disabling it downgrades every
# CONNECT_TIMEOUT diagnosis to "unknown".
# EVENT_LOOP_MONITOR_INTERVAL_MS=1000
# EVENT_LOOP_STARVATION_WARN_MS=1000
# EVENT_LOOP_MONITOR_DISABLED=
# Agent admission control. When the API's event loop or its database pool falls
# behind, refuse low-priority agent telemetry with 503 + Retry-After before it
# costs anything, and pace agent WebSocket reconnects. Heartbeats and recovery
# traffic are never refused. observe (default) only measures and counts;
# enforce refuses; off disables it.
# BREEZE_ADMISSION_MODE=observe
# BREEZE_ADMISSION_LAG_MS=100,250,600
# BREEZE_ADMISSION_DB_WAIT_MS=75,200,600
# BREEZE_ADMISSION_EMERGENCY_MS=1000
# BREEZE_ADMISSION_BULK_MAX_IN_FLIGHT=15
# BREEZE_ADMISSION_WS_RATE_PER_SEC=10
# BREEZE_ADMISSION_WS_BURST=20
# BREEZE_ADMISSION_WS_MAX_SETUPS=8
# Database pool-health watchdog. On by default; these are tuning knobs.
# postgres.js can leave a pooled connection permanently unable to reconnect once
# its socket dies with a buffered write, producing a storm of "write
# CONNECT_TIMEOUT" errors against a database that is perfectly healthy. The pool
# cannot self-heal — only an API restart recovers it. When the CONNECT_TIMEOUT
# rate breaches the threshold below, the watchdog opens ONE brand-new connection
# to the same database to tell the two cases apart: if that succeeds the pool is
# at fault, if it fails too the database is. Exposed as breeze_db_pool_health and
# breeze_db_connect_timeout_rate_per_min on /metrics.
# DB_POOL_HEALTH_INTERVAL_MS=60000
# DB_POOL_HEALTH_WINDOW_MS=300000
# DB_POOL_HEALTH_MIN_TIMEOUTS=10
# DB_POOL_HEALTH_PROBE_TIMEOUT_MS=5000
# DB_POOL_HEALTH_CAPTURE_THROTTLE_MS=900000
# DB_POOL_HEALTH_DISABLED=
# Redis memory watchdog (#6452). On by default; these are tuning knobs.
# Redis runs `noeviction` by contract — a full instance fails writes loudly
# instead of silently evicting data — but nothing observed memory before this,
# so the first signal was a failed login or a stuck queue. Polls `INFO memory`
# on an interval and warns (console + Sentry, throttled) once used_memory
# crosses the ratio below, so REDIS_MAXMEMORY can be raised (or the instance
# force-recreated) ahead of an incident. Exposed as breeze_redis_used_memory_bytes,
# breeze_redis_maxmemory_bytes and breeze_redis_memory_used_ratio on /metrics.
# REDIS_MEMORY_MONITOR_INTERVAL_MS=60000
# REDIS_MEMORY_WARN_RATIO=0.8
# REDIS_MEMORY_CAPTURE_THROTTLE_MS=900000
# REDIS_MEMORY_MONITOR_DISABLED=
# --- #6048: wedged GUC-prologue connections -------------------------------
# A pooled connection can get stuck mid-way through the RLS GUC prologue with
# the backend in state=active / wait_event=ClientRead. No server-side timeout
# reaches that state (it is not executing, and it is not idle-in-transaction)
# and no postgres.js timer reaches it either (they only recycle connections
# that return to the idle pool), so the slot is lost for the life of the
# process. Both prod regions lost one for three days on 2026-09-13.
#
# Client-side deadline on the single set_config statement (all seven GUCs)
# ONLY — the caller's own work that follows is never bounded by it. 0
# disables; positive values below 1000 are clamped to 1000.
# DB_ACCESS_CONTEXT_PROLOGUE_TIMEOUT_MS=15000
#
# The prologue clock starts once the pool has handed over a connection; the
# wait for a pool slot is bounded separately (#8229). postgres.js has no
# acquire timeout of its own, so without this a request queued behind a
# saturated pool waits forever. Expiry raises DbPoolAcquireTimeoutError and
# requests no reclaim pass (nothing is wedged, every connection is busy).
# 0 disables; positive values below 1000 are clamped to 1000.
# Default 10000 so acquire + prologue (+ two lag graces) = 29 s stays under the
# agent's 30 s HTTP timeout for a request that opens one DB context. A nested
# escalation (withResolvedDbAccessContext / runOutsideDbContext) opens another
# and gets its own acquire + prologue budget on top. A request still waiting
# for a permit when it expires never reaches the database (#8143).
# DB_POOL_ACQUIRE_TIMEOUT_MS=10000
#
# When a pool-acquire or prologue timer fires at least 1 s late (the event loop
# was stalled), it gets this one extra grace period before failing the request,
# so a reply that arrived during the stall is not thrown away (#8143). 0
# disables; capped at 10000.
# DB_TIMER_LAG_GRACE_MS=2000
#
# Detector: periodic pg_stat_activity scan for backends stuck in that state.
# When a wedged backend is an RLS prologue set_config('breeze.…') statement
# at least 5 minutes old, the detector also requests a reclaim pass (below),
# with all of the reclaimer's safety bounds (#6348). Set
# DB_WEDGED_BACKEND_SCANNER_RECLAIM_DISABLED for detector-reports-only.
# DB_WEDGED_BACKEND_SCAN_DISABLED=
# DB_WEDGED_BACKEND_SCAN_INTERVAL_MS=300000
# DB_WEDGED_BACKEND_MIN_AGE_MS=300000
# DB_WEDGED_BACKEND_SCANNER_RECLAIM_DISABLED=
#
# Reclaimer: when a transaction abandoned by its prologue deadline still holds
# its pool permit one more prologue budget plus ~1 s later, terminates the
# wedged backend from a fresh side connection (same-role pg_terminate_backend) so the socket
# closes and the pool slot is reconnected. Only ever signals a backend that two
# consecutive snapshots agree has not moved. Set DB_WEDGED_BACKEND_RECLAIM_DISABLED
# for detection without termination.
# DB_WEDGED_BACKEND_RECLAIM_DISABLED=
# DB_WEDGED_BACKEND_RECLAIM_MIN_INTERVAL_MS=60000
# DB_WEDGED_BACKEND_RECLAIM_MAX_PER_PASS=4
# DB_WEDGED_BACKEND_RECLAIM_TIMEOUT_MS=5000
# DB_WEDGED_BACKEND_CONFIRM_DELAY_MS=250
# Platform-operator signup-abuse detection and its alerts.
#
# NOT all-or-nothing on being unset: ABUSE_SIGNALS_ENABLED defaults to the value
# of IS_HOSTED, so leaving it unset ENABLES the whole detection subsystem on a
# hosted install and disables it on a self-hosted one. Every OTHER variable in
# this block is optional and unset = that piece disabled.
#
# Signup-abuse detection polices UNTRUSTED PUBLIC SIGNUPS on a multi-tenant
# hosted service. On a normal self-hosted install — one IT team, its own
# machines, no signup funnel — most of these heuristics are noise or actively
# wrong, which is why the default follows IS_HOSTED. Set it explicitly only to
# override that: true to opt a self-hosted multi-tenant service in, false to
# switch a hosted deployment off. Must be a boolean (true/false, 1/0, yes/no,
# on/off) — the API refuses to boot on anything else, so a typo cannot silently
# turn detection off.
# ABUSE_SIGNALS_ENABLED=true
# OPS_ALERT_WEBHOOK_URL=https://discord.com/api/webhooks/your-webhook
# OPS_ALERT_EMAIL=ops@your-domain.example.com
# OPS_ALERT_LABEL=US
# ABUSE_SIGNAL_OVERRIDES={"rmm.enrollment_velocity.devices_24h": 25}
# Script-content detector indicator lists (unset = empty lists; the repo
# deliberately ships no indicators).
# ABUSE_SCRIPT_INDICATORS={"tlds": [], "hosts": []}
# Durable event dispatch (wave 3.5c, #4085).
#
# Governs how events (AI agent and automation triggers, DNS threat alerts,
# notifications, policy alerts, webhook deliveries) reach their subscribers:
# off - today's behavior: in-process delivery only (default).
# shadow - mirror routing plans into event_delivery_receipts for
# observability, but still execute nothing via the queue.
# enforce - subscribers listed in EVENT_DISPATCH_QUEUE_SUBSCRIBERS deliver
# via BullMQ ONLY (in-process delivery is skipped for them);
# every other subscriber keeps delivering in-process.
# Must be off/shadow/enforce (or unset) — the API refuses to boot on anything
# else, so a typo can never silently change delivery. Unrecognized values are
# ALSO not silently treated as "on" at runtime; see eventDispatchMode() in
# config/env.ts.
# EVENT_DISPATCH_MODE=off
#
# Comma-separated subscriber ids to route via the queue when
# EVENT_DISPATCH_MODE=enforce (ignored otherwise). Known ids are declared in
# services/eventSubscriberIds.ts: ai-agent-alert-verdict, ai-agent-anomaly,
# ai-agent-ticket-helpdesk, automation-worker, dns-threat-alerts,
# notification-dispatcher, policy-alert-bridge, webhook-delivery. Unknown ids
# are rejected at boot. Naming a subscriber selects durable queue delivery;
# it does not enable or disable the feature behind that subscriber.
# EVENT_DISPATCH_QUEUE_SUBSCRIBERS=webhook-delivery,notification-dispatcher
# Enforcement gate for fail-closed Stripe Checkout session revocation.
# enforce (DEFAULT) - a transition that cannot prove every open Checkout
# session for the invoice is dead is REFUSED (503
# STRIPE_REVOCATION_PENDING). The durable intent survives the
# refusal and the 60s sweep retries, so the refusal is bounded.
# observe - the intent is still written and Stripe is still called, but a
# failure never blocks the transition. This is the incident
# de-escalation lever.
# Rollback is a flip to `observe`, NEVER a migration revert: code that ignores
# revocation_requested re-opens the finding and strands the intent rows.
# An unrecognized value falls back to enforce with a warning — a typo must never
# silently unlock the fail-open path. See stripeSessionRevocationMode() in
# config/env.ts.
# STRIPE_SESSION_REVOCATION_MODE=enforce
# Process role for the 3.5d socket/worker split (wave 3.5b, #4084; compose
# wiring wave 3.5d-b, #4086). Unset (or 'all') = today's all-in-one process.
# 'worker' disables socket-local agent dispatch (agentWs
# sendCommandToAgent/isAgentConnected throw instead of silently reporting
# every agent offline) — do not set this until the 3.5d rollout actually
# splits processes. The API process itself still reads BREEZE_ROLE, but a
# bare BREEZE_ROLE set here is NOT mapped by either compose file's
# x-api-env anchor — it is not settable directly. Use BREEZE_API_ROLE below
# for the api container; the worker container's role is always hardcoded.
# BREEZE_API_ROLE=all — role for the api container (all|api); the worker
# container in docker-compose.yml is always hardcoded to BREEZE_ROLE=worker,
# so this can never accidentally change it. Enable the worker container with:
# docker compose --profile worker-split up -d
# See docs/deploy/worker-split.md for the rollout/rollback runbook.
# BREEZE_API_ROLE=all
# Hostname prefixes attributed to abusive hosting, matched against device
# hostnames (unset = empty list; the repo deliberately ships no indicators,
# since publishing a prefix just tells an operator which string to rename).
# ABUSE_HOSTNAME_INDICATORS={"prefixes": []}
# --------------------------------------------
# Application URLs
# --------------------------------------------
# Used for links in emails and notifications
# For Docker deployment, use your BREEZE_DOMAIN with https
DASHBOARD_URL=https://breeze.yourdomain.com
PUBLIC_APP_URL=https://breeze.yourdomain.com
# Self-hosted documentation origin for the in-app Help panel (the slide-out docs
# iframe). Leave UNSET to use the hosted Breeze docs (which are only embeddable
# on Breeze-owned domains). Set this to the origin of a docs site you control and
# have configured to allow framing from your app domain (i.e. its frame-ancestors
# / X-Frame-Options permits PUBLIC_APP_URL). When set, the app rebases docs links
# onto this origin, widens the CSP frame-src to it, and treats your custom app
# origin as embed-eligible so the Help panel renders inline instead of falling
# back to "open in a new tab". Build-time constant — changing it requires a web
# rebuild.
# PUBLIC_DOCS_URL=https://docs.yourdomain.com
# --------------------------------------------
# Proxy Configuration
# --------------------------------------------
# The API only believes X-Forwarded-For / CF-Connecting-IP when the request's
# immediate TCP peer matches TRUSTED_PROXY_CIDRS. Headers arriving from any
# other peer are ignored, so a CIDR that matches nothing trusts nothing — it
# fails closed, it never opens a hole.
#
# The defaults below match the bundled `docker compose up` stack: it ships a
# Caddy in front of the API and pins it to a known address on the `breeze`
# network, so the API can trust that one hop and see real client IPs out of the
# box. deploy/docker-compose.prod.yml is configured the same way.
#
# Pick ONE of the three setups:
#
# 1) Using the bundled Caddy service (docker-compose.yml) — the shipped
# default; nothing to change. Caddy is pinned via BREEZE_CADDY_IP, and
# TRUSTED_PROXY_CIDRS follows it automatically:
# TRUST_PROXY_HEADERS=true
# TRUSTED_PROXY_CIDRS=172.31.0.10/32
#
# 2) Fronting the API with your OWN reverse proxy (nginx, Traefik, a
# different Caddy, etc.) instead of the bundled one — point the CIDR at the
# address the API sees as the TCP peer (your proxy's container/host IP),
# NOT the original client's IP:
# TRUST_PROXY_HEADERS=true
# TRUSTED_PROXY_CIDRS=<your-proxy-peer-ip>/32
# Leaving the bundled default in place is safe but useless: your proxy's
# peer address won't match, so no headers are trusted and you land in the
# degraded state described in (3).
#
# 3) Running the API with NO proxy in front (direct exposure) — turn trust off
# so the API uses the raw socket peer, which is the real client:
# TRUST_PROXY_HEADERS=false
#
# What "no trusted client IP" costs you (setup 3, or a misconfigured 1/2):