Repository navigation
Expand file tree
/
Copy path.env.example
More file actions
866 lines (817 loc) · 49.1 KB
/
Copy path.env.example
File metadata and controls
866 lines (817 loc) · 49.1 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
# ============================================
# LibreDB Studio - Environment Configuration
# ============================================
#
# LOCAL DEVELOPMENT:
# Copy this file to .env.local and fill in your values
# cp .env.example .env.local
#
# DOCKER / RENDER DEPLOYMENT:
# Set these variables in your deployment environment
#
# ============================================
# ============================================
# BUILD-TIME SUBPATH (Optional)
# ============================================
# For /libredb, /tools/libredb or /~/libredb, build your own image/app with this
# value. It cannot relocate a prebuilt image at runtime. No trailing slash.
# BASE_PATH=/tools/libredb
# Details, reverse-proxy rules and OIDC callback URLs: docs/SUBPATH.md
# ============================================
# SERVER BIND ADDRESS (Optional)
# ============================================
# Which address the standalone server listens on. Next.js binds to it, and the
# app reads it in two places: the boot banner (src/lib/startup-banner.ts) and the
# MCP endpoint, which checks the Host header only when this is a loopback
# address (127.0.0.1, ::1 or localhost). It applies to every channel that starts
# that server, and has no effect when the npm package is embedded.
# Leave it unset and the channel decides. The Docker image and the Helm chart
# resolve it at container startup and prefer "::" - all addresses of both
# families - falling back to 0.0.0.0 where the namespace has no usable IPv6.
# The native channels (npx, .deb/.rpm, Homebrew, Snap) force 127.0.0.1 and
# treat exposure as an explicit opt-in (--host, or LIBREDB_BIND for the
# packaged wrappers); they run no probe and pick nothing for you.
# IPv6 literals are accepted: "::" listens on every IPv6 address and, in a Node
# server, on every IPv4 address through the same socket - libuv clears
# IPV6_V6ONLY, so this holds even where net.ipv6.bindv6only=1 (measured).
# "::1" is IPv6 loopback, the IPv6 form of the local-first default.
# Setting this in a container overrules the resolver, so it is how you pin the
# container back to IPv4: `docker run -e HOSTNAME=0.0.0.0`. Kubernetes: the
# chart's config.bindAddress (or extraEnv, which renders an explicit env entry
# and so overrides the ConfigMap). A dual-stack cluster needs only
# service.ipFamilyPolicy now - do NOT also pin an IPv4 literal here, or the
# Service advertises an IPv6 address the pod does not listen on.
# Per-channel defaults and the reverse-proxy advice: docs/DISTRIBUTION.md
# (Network exposure).
# HOSTNAME=0.0.0.0
# ============================================
# HTTP DATABASE DESTINATION POLICY (Optional)
# ============================================
# Enable for hosted or multi-user deployments where connection creators must not
# reach the server's private network. It blocks loopback, private, link-local,
# unique-local and selected special-use IPs on every HTTP database request, including
# DNS answers, with validation bound to the socket's chosen address.
# Unset/false preserves local connections, Docker service names and SSH tunnels.
# A blocked destination fails without sending a request. Applies to HTTP-based
# database transports only; other database drivers have their own network paths.
# Accepted enabled values: true, on, 1. Disabled values: false, off, 0.
# The flag is off when unset. When enabled, HTTP connections through SSH tunnels
# are refused because the request target is a local tunnel endpoint.
# DB_HTTP_BLOCK_PRIVATE_HOSTS=true
# ============================================
# CUSTOM CONNECTIONS (Optional)
# ============================================
# Whether a signed-in user may open a connection of their own, one that is not a
# seed connection (SEED_CONFIG_PATH below). On when unset. "false", "0", "off" or
# "no" (trimmed, any case) switch it off: every route that builds a database
# provider then refuses a connection the request supplies with 403 "Custom
# connections are disabled on this server", and the editor hides New connection,
# Edit and Duplicate and lists seed connections only. Seeds keep working, the
# editable copy of a managed: false seed included. "true", "1", "on" and "yes"
# keep it on. One pair of surrounding quotes is stripped first; any other value
# fails closed: it switches custom connections off and logs one error naming the
# accepted values. Switch it off when Studio shares a network with services its
# users must not reach.
# See docs/SEED_CONNECTIONS.md (Custom Connections).
# ALLOW_CUSTOM_CONNECTIONS=false
# ============================================
# AUTHENTICATION (Required when AUTH_BOOTSTRAP=off)
# ============================================
# Admin credentials (full access + maintenance tools) — ADMIN_PASSWORD is
# required only when AUTH_BOOTSTRAP=off; otherwise it is auto-generated on
# first start (see ZERO-CONFIG BOOTSTRAP section below).
ADMIN_EMAIL=admin@libredb.org
# Left empty on purpose: a value here is a password anyone reading this file knows, and
# this file is copied to .env and run as it stands. Unset, one is generated on first start
# and printed to the log once. Fill it in only to choose your own.
ADMIN_PASSWORD=
# User credentials (query execution only) — OPTIONAL.
# The lower-privilege user account exists only when USER_PASSWORD is set.
# Leave USER_PASSWORD unset to run admin-only (no default user password is ever assumed).
USER_EMAIL=user@libredb.org
# Empty means the account does not exist at all - it is never generated. That is the safer
# default for anything reachable from outside.
USER_PASSWORD=
# With STORAGE_PROVIDER=sqlite or postgres these four variables are only the bootstrap.
# The first start copies them into the accounts table (passwords stored as scrypt) and
# later sign-ins read that table. Add, disable, or remove accounts under Admin → Accounts.
# Each start logs a warning when ADMIN_PASSWORD no longer matches the stored admin.
# STORAGE_PROVIDER=local keeps using the variables on every login and has no account table.
# NEXT_PUBLIC_AUTH_PROVIDER=oidc does not read the table: the issuer stays the identity.
# Break-glass for the account table: true makes ADMIN_EMAIL an enabled admin again with
# ADMIN_PASSWORD (and ADMIN_TOTP_SECRET, or no second factor) at the next start, and ends
# its older sessions. Use it for a rotated secret or a locked-out admin, then remove it:
# every start applies it again while it is set.
# ADMIN_PASSWORD_RESET=true
# TWO-FACTOR AUTHENTICATION (TOTP) — optional, local provider only
# ============================================
# Base32 secret (RFC 4648: A-Z and 2-7). When set, that account must present a
# 6-digit code from an authenticator app after its password. Opt-in per account:
# set one, both, or neither. Under NEXT_PUBLIC_AUTH_PROVIDER=oidc the login page
# shows no password form, so MFA belongs at the identity provider (docs/OIDC.md)
# — but POST /api/auth/login stays reachable whenever a password is ALSO
# configured, and these secrets guard that route in every mode.
#
# Generate one, then enrol it in your app of choice:
# openssl rand 20 | base32 | tr -d '=' # 160-bit secret, per RFC 4226
# Minimum 26 base32 characters, so the decoded secret carries the 128 bits
# RFC 4226 requires. A value that is not base32, or shorter than that, stops
# login with a clear 503 naming the variable rather than silently disabling or
# silently weakening the second factor. Blank the variable to turn MFA off.
#
# The placeholder below is deliberately not base32, like every other secret in
# this file. A published example secret is the one value that must never be left
# in place: the account would read as protected everywhere while anyone could
# compute its codes. Uncommented as-is, this earns the 503 instead.
# ADMIN_TOTP_SECRET=your_base32_secret_from_the_command_above
# USER_TOTP_SECRET=
# ============================================
# PASSKEYS (Optional) - local accounts in the server store
# ============================================
# PASSKEY_ORIGIN is the exact address people open Studio at: scheme, host and
# port, no path (a BASE_PATH prefix does not belong in it). Passkeys are off
# while it is unset, and nothing derives it from a request.
# People must open exactly that address: with PASSKEY_ORIGIN=http://localhost:3000
# open http://localhost:3000, not the http://127.0.0.1:3000 the startup banner
# prints, or the browser offers no passkey.
# Passkeys also need STORAGE_PROVIDER=sqlite or postgres and local auth (not
# NEXT_PUBLIC_AUTH_PROVIDER=oidc): the server store keeps the passkey records.
# https:// is required, except http://localhost. An IP address, a plain-HTTP LAN
# address and the desktop shell on 127.0.0.1 cannot use passkeys, because
# browsers refuse them there. TLS terminated at an ingress or load balancer
# still means https://, since that is what the browser sees.
# Changing the host name means everyone registers their passkeys again: a
# passkey belongs to the host it was created on. ALLOWED_ORIGINS (behind a proxy
# that rewrites Host) and LIBREDB_MCP_URL name the same public origin, so change
# them together.
# PASSKEY_ORIGIN=https://studio.example.com
# PASSKEY_ORIGIN=http://localhost:3000
# Full guide: docs/PASSKEYS.md
# ============================================
# LAUNCH SIGN-IN (Optional) - a hosting platform signs people in
# ============================================
# A platform that hosts Studio can open /launch#token=<signed token> and Studio
# signs that person in; with STORAGE_PROVIDER=sqlite or postgres it creates
# their account in the server store, bound to their platform identity, with the
# role the token names, and never signs in to an account that has a password.
# Off while LAUNCH_TOKEN_SECRET is unset or empty. Once it is set, the other two
# are required. A missing one, a secret under 32 characters, or a secret equal
# to JWT_SECRET, does not stop the server: POST /api/auth/launch answers 503
# naming the problem, and the log says it once. With
# NEXT_PUBLIC_AUTH_PROVIDER=oidc launch sign-in is not available, and the route
# and /launch answer 503.
# The secret is the HMAC-SHA256 key the platform signs with, used exactly as
# set, and the platform must hold the same value. Generate one with:
# openssl rand -hex 32
# LAUNCH_TOKEN_SECRET=
# LAUNCH_TOKEN_AUDIENCE=my-studio
# LAUNCH_TOKEN_ISSUER=my-platform
# Full guide: docs/LAUNCH.md
# JWT Secret for session management (min 32 characters)
# Generate with: openssl rand -base64 32
# A shorter value stops the server at startup (exit code 1) instead of booting
# into a deployment where the health check is green but every login returns 503.
# Leave it unset to have a strong secret generated on first run - unless you also set
# AUTH_BOOTSTRAP=off, which turns that generation off too: unset in production with
# bootstrap off, the server stops at startup for the same reason, because nothing would
# produce a secret and every login would be 503.
# Empty on purpose. A placeholder long enough to clear the 32-character minimum is a
# working secret published in this file, and with STORAGE_ENCRYPTION_KEY unset it is also
# what saved connection passwords are sealed with. Generate one:
# openssl rand -base64 32
JWT_SECRET=
# ============================================
# ZERO-CONFIG BOOTSTRAP (Optional)
# ============================================
# When JWT_SECRET and/or ADMIN_PASSWORD are NOT set, the server generates the
# missing values on first start, persists them to <data dir>/auth-bootstrap.json
# (file mode 0600), and prints the admin password ONCE to stdout. Explicit env
# vars always win. In OIDC mode (NEXT_PUBLIC_AUTH_PROVIDER=oidc) only the JWT
# secret is generated — a password is never generated. Set to "off" (or
# "false"/"0", case-insensitive) to disable bootstrap and require explicit
# configuration (strict mode: missing vars surface as a clear login error).
# Unrecognized values warn and leave bootstrap on.
# AUTH_BOOTSTRAP=on
# ============================================
# COOKIE SECURITY (Optional)
# ============================================
# The Secure flag on the auth cookies (auth-token, the OIDC state cookie, and the
# passkey-registration and passkey-sign-in ceremony cookies).
# Unset (default): on in production, off otherwise, with one exception - a
# request that arrived on a loopback host over plain http never gets it, so the
# desktop shell can keep a session (issue #232).
# Set to "false" ("off"/"0") when the browser itself reaches the app over plain
# HTTP on a host that is not loopback - a LAN or home-server deployment such as
# umbrelOS. The browser would otherwise reject the cookie and login would
# silently bounce back to /login. The session cookie then travels in cleartext,
# so keep it to trusted networks. TLS terminated at an ingress or load balancer
# does NOT need this: the browser still speaks HTTPS and accepts the flag.
# Set to "true" ("on"/"1") to force the flag on. Unrecognized values warn and
# leave the default in place.
# AUTH_COOKIE_SECURE=false
# ============================================
# AUTHENTICATION PROVIDER
# ============================================
# "local" (default) = email/password login (ADMIN_EMAIL/ADMIN_PASSWORD, USER_EMAIL/USER_PASSWORD)
# "oidc" = OpenID Connect SSO (Auth0, Keycloak, Okta, Azure AD, Zitadel, etc.)
NEXT_PUBLIC_AUTH_PROVIDER=local
# ============================================
# OIDC Configuration (required when NEXT_PUBLIC_AUTH_PROVIDER=oidc)
# ============================================
# Issuer URL — must serve /.well-known/openid-configuration
# OIDC_ISSUER=https://dev-xxx.auth0.com
# OIDC_CLIENT_ID=your_client_id
# OIDC_CLIENT_SECRET=your_client_secret
# Scopes to request (default: openid profile email)
# OIDC_SCOPE=openid profile email
# if using Zitadel, add this scope: urn:zitadel:iam:org:project:roles
# Role mapping (optional) — claim path for determining admin vs user role
# Supports dot-notation for nested claims (e.g. "realm_access.roles")
# OIDC_ROLE_CLAIM=
# Comma-separated values that map to admin role (default: admin)
# OIDC_ADMIN_ROLES=admin
# --- Provider-specific role claim examples ---
# Auth0: OIDC_ROLE_CLAIM=https://myapp.com/roles (via Auth0 Actions)
# Keycloak: OIDC_ROLE_CLAIM=realm_access.roles
# Okta: OIDC_ROLE_CLAIM=groups
# Azure AD: OIDC_ROLE_CLAIM=roles
# Zitadel: OIDC_ROLE_CLAIM=urn:zitadel:iam:org:project:roles
# ============================================
# STORAGE PROVIDER (Optional)
# ============================================
# Controls where application data is persisted.
# "local" (default) = browser localStorage only (zero config, great for dev)
# "sqlite" = SQLite file on server (persistent, single-node)
# "postgres" = PostgreSQL database (persistent, multi-node, enterprise)
#
# Note: NOT prefixed with NEXT_PUBLIC_ — server-side only, discovered at runtime
# via GET /api/storage/config endpoint.
STORAGE_PROVIDER=local
# SQLite storage path (required when STORAGE_PROVIDER=sqlite)
# STORAGE_SQLITE_PATH=./data/libredb-storage.db
# PostgreSQL connection URL (required when STORAGE_PROVIDER=postgres)
# Local PostgreSQL without SSL:
# STORAGE_POSTGRES_URL=postgresql://user:pass@localhost:5432/libredb?sslmode=disable
# Cloud PostgreSQL with SSL:
# STORAGE_POSTGRES_URL=postgresql://user:pass@host:5432/libredb?sslmode=require
# Credential encryption key for the SERVER-SIDE store (sqlite/postgres only) — OPTIONAL.
# When STORAGE_PROVIDER is sqlite or postgres, database passwords, connection strings, TLS
# client keys and SSH keys/passphrases are encrypted before they are written, so a stolen
# database file or dump is useless on its own.
# Leave this unset and the key is derived from JWT_SECRET, so there is nothing to configure.
# Set it (at least 32 characters, generate with: openssl rand -base64 32) when you want the
# storage key separated from the session-signing key — for example so JWT_SECRET can be
# rotated without invalidating every saved connection password.
# IMPORTANT: rotating whichever key is in use makes existing stored credentials unreadable.
# They are omitted from the connection, not deleted; the rest of the connection survives and
# you re-enter the password once. Restore the previous key BEFORE the app writes again if you
# want the old values back.
# Browser localStorage is NOT encrypted; this variable does not change that.
# Short on purpose. A placeholder long enough to clear the 32-character minimum is a
# working key published in this file, and it is what the passwords inside saved
# connections are sealed with. Uncomment the line as it stands and, with server-side
# storage on, the server stops at startup (exit code 1) and says the key is too short -
# which is the point: nothing comes up on a key anyone can read here. Generate your own:
# openssl rand -base64 32
# STORAGE_ENCRYPTION_KEY=too-short-generate-your-own
# ===========================================
# SQLite DB Provider Driver (advanced)
# ===========================================
# The SQLite *target-database* provider picks the runtime's built-in driver
# automatically: bun:sqlite under Bun, node:sqlite under Node (>= 24).
# Set this only to force a specific driver (e.g. deterministic tests).
# Options: bun, node
# LIBREDB_SQLITE_DRIVER=node
# ===========================================
# Oracle Thick-mode Client (advanced)
# ===========================================
# The Oracle DB provider runs in Thin mode (pure JS, no Instant Client) by
# default, which only supports Oracle Database 12.1+. Set this to an installed
# Oracle Instant Client directory to opt into Thick mode - for servers older
# than 12.1 (driver error NJS-138), and for a 12.1+ server that requires
# something Thin mode does not implement: NJS-533 native network encryption,
# NJS-116 10G-only password verifier, NJS-529 an sso-only wallet (converting
# it to ewallet.pem avoids Thick mode), or Kerberos / RADIUS / OS
# authentication and LDAP naming, which Thin mode does not implement at all
# and which surface as a server logon error or a connect-string failure
# rather than as one driver code.
# Version matters: use Instant Client 19c for an Oracle 11.2 (11g) server -
# 21c/23ai cannot reach 11.2.
# ON LINUX THIS VARIABLE IS NOT ENOUGH BY ITSELF. libclntsh has no RUNPATH, so
# the same directory must also be on the system library search path: add it to
# a file under /etc/ld.so.conf.d/ and run ldconfig, or export LD_LIBRARY_PATH
# before Node starts. Without that step the driver fails with DPI-1047 however
# correct the path is. On Debian 13 libaio.so.1 must resolve too.
# The published image is Thin-only and does not bundle the client; see
# docs/providers/oracle.md section 4.4 for the full recipe. Every load failure
# fails fast with a config error that says which of these it is.
# ORACLE_CLIENT_LIB_DIR=/opt/oracle/instantclient_19_28
# ===========================================
# LLM Configuration (Strategy Pattern)
# ===========================================
# Provider options: gemini, openai, ollama, custom
# The system uses Strategy Pattern to automatically select
# the appropriate provider based on this configuration.
LLM_PROVIDER=gemini
# API Key for the selected provider
# Required for: gemini, openai
# Optional for: ollama, custom (depends on endpoint)
#
# Get API keys from:
# - Gemini: https://aistudio.google.com/
# - OpenAI: https://platform.openai.com/
LLM_API_KEY=your_api_key_here
# Model name (optional - auto-defaults based on provider)
# Default models:
# - Gemini: gemini-2.5-flash
# - OpenAI: gpt-4o
# - Ollama: llama3.2
# - Custom: gpt-3.5-turbo
#
# Popular options:
# Gemini: gemini-2.5-flash, gemini-2.0-flash, gemini-1.5-flash, gemini-1.5-pro
# OpenAI: gpt-4o, gpt-4-turbo, gpt-3.5-turbo, gpt-4o-mini
# Ollama: llama3.2, mistral, codellama, deepseek-coder
LLM_MODEL=gemini-2.5-flash
# API URL (optional - required for the custom provider, and read by every kind)
# Default URLs:
# - Gemini: https://generativelanguage.googleapis.com/v1beta
# - Ollama: http://localhost:11434/v1
# - OpenAI: https://api.openai.com/v1
#
# Custom provider examples:
# - LiteLLM: http://localhost:4000/v1
# - LMStudio: http://localhost:1234/v1
# - vLLM: http://localhost:8000/v1
# - LocalAI: http://localhost:8080/v1
#
# Gemini reads it too, so an egress proxy or a regional endpoint is configurable
# (chat surface and agent alike). Give the versioned URL - a bare origin also works,
# /v1beta is appended for you. Example:
# - Gemini behind a proxy: https://gemini-proxy.internal/v1beta
#LLM_API_URL=http://localhost:11434/v1
# ===========================================
# Provider Configuration Examples
# ===========================================
# --- Gemini (Default) ---
# LLM_PROVIDER=gemini
# LLM_API_KEY=AIzaSy...
# LLM_MODEL=gemini-2.5-flash
# LLM_API_URL=https://gemini-proxy.internal/v1beta # optional; only to leave Google's endpoint
# The version segment is optional and a path prefix is kept: `https://gw.internal/api/google`
# works too. One variable feeds two SDKs that spell the endpoint differently - the chat surface
# takes an origin and appends the version itself, the agent adapter takes a base URL that
# carries it - so `/v1beta` is stripped for one and appended for the other as needed
# (`src/lib/llm/utils/gemini-endpoint.ts`). Unset leaves each SDK on its own Google default.
# --- OpenAI ---
# LLM_PROVIDER=openai
# LLM_API_KEY=sk-...
# LLM_MODEL=gpt-4o
# --- Ollama (Local) ---
# LLM_PROVIDER=ollama
# LLM_MODEL=llama3.2
# LLM_API_URL=http://localhost:11434/v1
# AGENT MODE additionally needs a model that CALLS TOOLS, and on Ollama the model
# decides that rather than the endpoint: Ollama documents tool_choice as unsupported,
# so nothing can force the call. Establish it with a probe rather than from a model
# card - docs/AGENT_GUIDE.md has the measurement and how to repeat it.
# PLAN MODE needs no tools and is never probed (src/lib/agent/capability-gate.ts:74),
# so a model refused for Agent mode can still be used in Plan mode - which is what
# the rail offers when it reports the refusal.
# --- LiteLLM Proxy ---
# LLM_PROVIDER=custom
# LLM_API_KEY=your_litellm_key # optional
# LLM_MODEL=gpt-4o
# LLM_API_URL=http://localhost:4000/v1
# --- LMStudio (Local) ---
# LLM_PROVIDER=custom
# LLM_MODEL=local-model
# LLM_API_URL=http://localhost:1234/v1
# ─── Agent Runtime (available when AI is configured) ─────────────────────────
# The agent rail drives a read-only investigation over a connected database:
# it drafts statements, repairs them against the schema, and composes a report
# whose claims carry evidence references. Every database reach goes through the
# AGENT'S OWN audited operation pipeline - a policy decision, an audit event and
# budget accounting before the driver is touched (executeAuditedOperation,
# src/lib/db/operations/execution.ts:129) - so the agent can never exceed what
# that policy allows. It is the agent's pipeline and not one shared with the
# editor: a statement you run yourself calls the provider directly
# (src/app/api/db/query/route.ts:44) and gets neither check.
#
# AGENT MODE EXECUTES STATEMENTS ON POSTGRESQL, SQLITE, DUCKDB AND SQL SERVER
# ONLY. The read-only execution profile is database-native, so it exists only
# where the provider implements it (queryReadOnly: postgres.ts, sqlite.ts,
# duckdb/, mssql.ts). On any other engine an Agent-mode run ends
# "engine-unsupported". Plan mode is toolless, opens on every connection and is
# grounded on every engine before its first turn. It runs no statement of yours
# on any engine, writes nothing, and hands every statement it drafts to you to
# run yourself.
#
# THERE IS NO FLAG TO TURN IT ON. Availability is derived from what is actually
# true on this server: the agent appears when a model is configured through the
# LLM_* settings above - there is no second place to enter an API key - AND the
# durable ledger below has a writable path. Configuring a model IS the opt-in,
# so a deployment that never sets LLM_API_KEY never sees an agent, and a
# deployment that sets one is not offered a Start that must fail.
# It is standalone-only either way: no agent surface appears when the npm
# package is embedded in libredb-platform.
#
# UPGRADING FROM 0.11 OR EARLIER: if LLM_API_KEY is already set - it powered the
# NL2SQL and Autopilot panels, which this release removes - the agent appears
# without you asking for one. The line below is how you decline it.
#
# The variable survives as the explicit OFF-switch. "false"/"off"/"0" mean no
# agent even with AI configured; "true"/"on"/"1" are still accepted and mean the
# default, but cannot conjure a model; unset means derive; an unrecognized value
# warns and is ignored.
# LIBREDB_AGENT_ENABLED=false
#
# Whether a run may be told about the CONVERSATION it belongs to. Default on.
#
# A follow-up question asked on the same connection continues the previous run's
# conversation: the server derives the earlier steps' objectives and the most recent
# step's report from those runs' own ledgers, and hands them to the model fenced, so
# "how many of those?" resolves against what was asked before instead of being
# answered as a fresh question. What travels is the previous steps' objectives (the
# user's own words) and the previous report's claims (a model's) - derived
# server-side, never sent by the browser, and bounded by a character budget.
#
# Set to "false" ("off"/"0") where no question's context may reach another - a policy
# some deployments have. Every run then opens on its own, and the rail SAYS so rather
# than going quiet: a user who asks a follow-up is told the conversation is switched
# off on this server. Unrecognized values warn and leave it on.
#
# The user can already leave a conversation without this: the rail names the run being
# continued and offers "new conversation" beside it. This is the operator's equivalent,
# for when the choice may not be the user's.
# LIBREDB_AGENT_THREAD_CONTEXT=false
#
# How long ONE model call may take before the run stops waiting for it, in
# milliseconds. Default 90000, and leaving it unset is what every published
# measurement was taken at.
#
# Raise it for a LOCAL model. The default was chosen against hosted APIs, where a
# turn lands in seconds and a 90-second wait only ever means a request that is not
# coming back. On a local endpoint it is measurably wrong: across 25 Ollama models
# on six agent surfaces, nine runs ended "model-timeout" with the model still
# working, and one of them - a reasoning model in plan mode, which holds no tools
# at all - was cut 92 seconds into its FIRST turn with nothing recorded. Those runs
# are then scored as having answered nothing, which is a fact about this ceiling
# and not about the model.
#
# A value that is not a positive whole number is ignored and the default stands: a
# mistyped variable must not end every turn instantly. A value is also capped just
# under half the smallest workflow deadline (currently 360s, so just under 180000),
# because a run has to be able to take at least two turns to finish at all.
# AGENT_MODEL_TURN_TIMEOUT_MS=150000
#
# Measured per-model settings to layer over the ones Studio ships with.
#
# Studio carries a document of settings that specific models were measured under —
# how long a turn of theirs may take, how many readings they may take before they
# are asked to report, whether an empty turn is worth asking again. A model that is
# not in that document is driven with the defaults, which is the honest treatment of
# a model nobody has measured.
#
# This is how a model Studio has never measured gets the settings somebody else
# measured: point this at a JSON file in the same shape, mount it, restart. No new
# Studio release and no code change. Entries are merged per model and whole: an
# entry here replaces the shipped entry for that model rather than contributing one
# field to it, because half of one measurement beside half of another is a
# configuration nobody has ever run.
#
# A file that is missing, unreadable or does not match the schema is IGNORED and the
# settings Studio ships with stand. That makes it the one setting here that fails
# OPEN, so it is also the one that reports itself: GET /api/agent/config tells an
# admin session what became of it — applied, ignored (with the reason), or unset.
# Check it rather than assuming, because nothing about a running agent says which
# settings it is using.
#
# It carries numbers and switches only — never the sentences the agent says to a
# model, which stay in Studio so that supplying this file cannot change what Studio
# tells a model.
#
# On Kubernetes the Helm chart mounts the document and sets this for you; see
# agent.modelTuning.* in charts/libredb-studio/README.md.
#
# AGENT_MODEL_TUNING_PATH=/etc/libredb/model-tuning.json
#
# Durable-execution backend for agent runs. Exactly two values are accepted; an
# unrecognized one is refused rather than silently defaulted, because the
# workflow runtime otherwise treats this variable as a module to load.
# local (default) zero-config, keeps run state on disk.
# SINGLE INSTANCE ONLY - it takes file locks, so do
# not point more than one replica at it.
# @workflow/world-postgres opt-in, required for more than one replica. Point
# WORKFLOW_POSTGRES_URL at its own PostgreSQL
# database (not one of your connected databases).
# Leaving it unset selects "local", except on a hosting platform that sets
# VERCEL_DEPLOYMENT_ID: there the workflow runtime would pick its own hosted
# backend instead, so the agent refuses to start until you set this explicitly.
# WORKFLOW_TARGET_WORLD=local
#
# Where the "local" backend keeps run state, and the second half of the
# availability answer above: if this path cannot be created and written, the
# agent reports itself absent and GET /api/agent/config says which condition
# failed. Its own variable, not one of ours: the SDK's fallback is
# ".workflow-data" resolved against the working directory, which is fine for a
# dev checkout and wrong in a container in two different ways. Under a read-only
# root filesystem the write fails outright unless the path is inside a mounted
# volume. In plain Docker /app IS writable, so nothing fails - the ledger simply
# lands outside the mounted volume and every run is lost the next time the
# container is recreated. Neither applies to the published artifacts: the image
# sets this to /app/data/workflow itself (Dockerfile, runtime stage) and
# docker-compose.yml restates it, so a bare `docker run` lands inside the data
# directory too - mount a volume on /app/data if the history should outlive the
# container. The npx launcher needs nothing here either: it defaults this to a
# per-user directory beside its payload cache (~/.libredb-studio/workflow-data),
# so run history follows the user rather than the folder they happened to start
# Studio from. Set it below only to put the ledger somewhere else.
# WORKFLOW_LOCAL_DATA_DIR=/app/data/workflow
#
# How often the resume sweep looks for runs a dead process left behind, in
# milliseconds. Default 60000 (once a minute). A value that is not a positive
# whole number is ignored and the default stands.
# LIBREDB_AGENT_RESUME_SWEEP_INTERVAL_MS=60000
#
# How old a running run's last ledger activity must be before the sweep claims
# it, in milliseconds. Default: the longest run deadline plus the claim grace
# and a margin, so a run still inside its deadline is never swept. A value that
# is not a positive whole number is ignored and the default stands.
# LIBREDB_AGENT_STALE_RUN_AFTER_MS=1020000
#
# Full behaviour, what bounds a run, and the limitations it does NOT hide:
# docs/AGENT.md
# ─── MCP Server (off by default) ─────────────────────────────────────────────
# An AI client of your own (Claude Code, Codex, Cursor, VS Code, Gemini CLI)
# reads schemas and runs bounded, read-only SQL at /api/mcp with a token minted
# on the settings screen, while the database credentials stay on this server.
# "true"/"on"/"1" enable it; "false"/"off"/"0", an empty value or unset leave it
# off; any other value answers every authenticated MCP request with HTTP 500
# naming this variable. Guide, client configuration and limits: docs/MCP.md
# LIBREDB_MCP_ENABLED=true
#
# The address clients use. Every token is bound to it, and its host joins the
# Origin and Host allowlists. An absolute http(s) URL whose path ends in
# /api/mcp, with no user name, password, query or fragment; include your
# BASE_PATH. Unset or invalid, no token can be minted or accepted. The npx
# launcher derives it from --host and --port when it is unset.
# LIBREDB_MCP_URL=https://studio.example.com/api/mcp
#
# The label every token's signing key is derived under, from JWT_SECRET. Any
# non-empty value; there is no default. Changing it revokes every MCP token at
# once, which is the only revocation: nothing about a token is stored. A token
# outlives a deleted or disabled account, so to offboard a person, stop them
# signing in, then change it once ten minutes have passed: minting needs a
# sign-in from the last ten minutes, so no session they still hold can mint again.
# LIBREDB_MCP_TOKEN_LABEL=studio-mcp-1
#
# How many days a minted token stays valid: a whole number from 1 to 365.
# Default 30. A token keeps the role its owner had when it was minted until it
# expires or the label changes.
# LIBREDB_MCP_TOKEN_TTL_DAYS=30
# ─── LibreDB Embedded Sample ─────────────────────────────────────────────────
# LibreDB embedded sample: on first standalone startup, auto-provide an editable
# "Sample (LibreDB)" connection seeded with example data (one per lens). Default on.
# Set to "false" to disable. Has no effect when embedded in libredb-platform.
# LIBREDB_EMBEDDED_SAMPLE=true
# Optional file path override (default: <data dir>/sample.libredb):
# LIBREDB_EMBEDDED_SAMPLE_PATH=/app/data/sample.libredb
# ─── SQLite Embedded Sample ──────────────────────────────────────────────────
# SQLite embedded sample: on first standalone startup, copy the vendored
# employees database (seed-assets/sqlite/employee.db) into the data dir and
# auto-provide an editable "Sample (Employees)" connection. Seeded
# asynchronously (never blocks boot); default on. Set to "false" to disable.
# Has no effect when embedded in libredb-platform.
# SQLITE_EMBEDDED_SAMPLE=true
# Optional runtime file path override (default: <data dir>/sample-employees.db):
# SQLITE_EMBEDDED_SAMPLE_PATH=/app/data/sample-employees.db
# Optional template override (default: <cwd>/seed-assets/sqlite/employee.db):
# SQLITE_EMBEDDED_SAMPLE_TEMPLATE=/app/seed-assets/sqlite/employee.db
# ─── Startup Banner ──────────────────────────────────────────────────────────
# On standalone startup the server prints one short block naming the version,
# the local URL and the project repository. It is plain stdout for whoever reads
# `docker logs` - nothing is sent anywhere. Set to "1" or "true" to print
# nothing. Has no effect when embedded in libredb-platform.
# LIBREDB_NO_BANNER=1
# ─── Logging ─────────────────────────────────────────────────────────────────
# Minimum level written to stdout. One of debug, info, warn or error,
# case-insensitive; anything else is ignored rather than rejected, and the
# default applies. The default is level-dependent on NODE_ENV: `debug` outside
# production, `info` in production - so leaving this unset is the right choice
# for most deployments, and setting it is how you get debug output out of a
# production container without rebuilding it.
# The Helm chart writes this from `config.logLevel`; see docs/HELM_CHART.md.
# LOG_LEVEL=info
# ─── Seed Connections (pre-configured databases) ─────────────────────────────
# SEED_CONFIG_PATH=/app/config/seed-connections.yaml # Path to seed config file
# SEED_CACHE_TTL_MS=60000 # Cache TTL in ms (default: 60s)
# Credential env vars referenced in seed config (e.g., ${MY_DB_PASSWORD}):
# MY_DB_PASSWORD=secret
# Read every seed value as written: no ${ENV} or ${vault:...} reference in the
# seed file is resolved, and the plaintext-password warning is not logged. For
# a seed file a platform writes from data its users control, where a database
# user named ${JWT_SECRET} would otherwise make this process send its own
# secret to that user's server. "true", "1", "on" or "yes" (trimmed, any case)
# turn it on; "false", "0", "off", "no" or empty keep references resolved, and
# any other value keeps them resolved and logs one warning. One info line at
# the first load confirms the mode. See docs/SEED_CONNECTIONS.md.
# SEED_LITERAL_VALUES=true
# Platform discovery (CapRover auto-connect), off while unset: the path of the
# export file the discovery companion app writes. Studio re-reads it at most
# once per SEED_CACHE_TTL_MS, and at most every 5 seconds (or once per
# SEED_CACHE_TTL_MS when that is shorter) while the copy it holds is stale,
# and lists the databases it names for admins only.
# See docs/SEED_CONNECTIONS.md.
# SEED_DISCOVERY_PATH=/app/discovery/services.json
# Age of the export's last successful scan after which the discovered
# connections are withdrawn, in ms (default: 60000). Keep it well above the
# exporter's DISCOVERY_INTERVAL_MS (10000 by default), or a healthy export
# turns stale between two scans.
# SEED_DISCOVERY_MAX_AGE_MS=60000
# ─── HashiCorp Vault (seed credentials) ──────────────────────────────────────
# Optional. Lets a seed connection take a field from Vault instead of an env var,
# so a rotated secret is picked up without a pod restart:
# password: "${vault:secret/data/prod/postgres#password}"
# The part before `#` is the KV v2 path, the part after it the key inside the
# returned data. Nothing here is read unless a seed file uses a `${vault:...}`
# reference, and listing connections never reaches Vault — the secret is read
# when a connection is opened, then cached. See docs/SEED_CONNECTIONS.md.
# Vault address with scheme and port; required for the scheme to work.
# VAULT_ADDR=http://vault.vault.svc:8200
# Static token (Vault Agent, AppRole, or a dev root token).
# VAULT_TOKEN=root
# Or Kubernetes auth: the role bound to the pod's service account. Used only
# when VAULT_TOKEN is unset.
# VAULT_ROLE=libredb-studio
# Projected service account token the Kubernetes login presents.
# VAULT_K8S_TOKEN_PATH=/var/run/secrets/kubernetes.io/serviceaccount/token
# Mount path of the Kubernetes auth method, when it is not `kubernetes` (e.g.
# one mount per cluster on a shared Vault). The login is POST /v1/auth/<path>/login.
# VAULT_K8S_AUTH_PATH=kubernetes
# Vault Enterprise namespace, sent as X-Vault-Namespace when set.
# VAULT_NAMESPACE=admin
# How long a read secret is cached, in ms. A rotated secret becomes visible
# within VAULT_CACHE_TTL_MS + SEED_CACHE_TTL_MS (default 60000 + 60000).
# VAULT_CACHE_TTL_MS=60000
# ─── Platform Discovery Exporter (docker/discover.mjs) ───────────────────────
# Read by the exporter process only, never by the Studio server. The exporter
# is the -discovery companion of the CapRover auto-connect template
# (deploy/caprover/libredb-studio-autoconnect.yml). Run as root with the Docker
# socket mounted, it lists the Swarm services of one overlay network and
# writes their names, hosts, images and ten allow-listed database environment
# keys to a file Studio reads through SEED_DISCOVERY_PATH. It sends GET
# requests to the Docker API only. Start it with the image entrypoint
# replaced, for example in Docker Compose:
# entrypoint: ["node", "/usr/local/lib/libredb-studio/discover.mjs"]
# A Compose `command:` alone goes through docker-entrypoint.sh, which drops
# it to uid 1001, and it then refuses to start, because the directory it
# writes to is owned by root. See docs/SEED_CONNECTIONS.md.
# Export file path. Its directory must exist, be owned by the exporter's uid
# and not be writable by group or others.
# DISCOVERY_OUTPUT=/app/discovery/services.json
# Overlay network whose services are exported (exact name match).
# DISCOVERY_NETWORK=captain-overlay-network
# Time between scans in ms, an integer from 2000 to 2147483647.
# DISCOVERY_INTERVAL_MS=10000
# Comma-separated app names to skip (the service name without srv-captain--).
# Only their names reach the export, and Studio's admin status lists each one
# as skipped with the reason "listed in Apps to skip".
# DISCOVERY_EXCLUDE=wordpress-db,umami-postgres
# Owner given to the export file: the uid and gid the Studio server runs as,
# each an integer from 0 to 4294967294.
# DISCOVERY_FILE_UID=1001
# DISCOVERY_FILE_GID=1001
# Docker Engine API socket.
# DOCKER_SOCKET=/var/run/docker.sock
# Set to 1 to run one scan and exit (used by the exporter's tests).
# DISCOVERY_ONCE=1
# ─── Monaco Editor Assets ────────────────────────────────────────────────────
# The SQL editor is served from this origin: `bun run build` stages the Monaco AMD
# bundle from node_modules into public/monaco/vs, so no CDN is contacted at runtime
# and the app works air-gapped. Override only when the assets live elsewhere —
# a sub-path mount, a CDN of your own, or a host app embedding the npm
# package. When this is an ABSOLUTE
# URL, its origin is added to the Content-Security-Policy's script-src and
# worker-src automatically; a same-origin path (the default) needs no CSP change.
# NEXT_PUBLIC_MONACO_VS_PATH=/monaco/vs
# ─── Security Headers ────────────────────────────────────────────────────────
# Every response that passes through the app's request middleware carries
# X-Content-Type-Options, Referrer-Policy, Permissions-Policy, X-Frame-Options,
# Strict-Transport-Security and a Content-Security-Policy. Nothing needs to be set
# for that to happen. (Static assets and the two load-balancer/bootstrap paths,
# /api/db/health and GET /api/storage/config, are excluded from the middleware and
# carry none of these — see docs/BACKLOG.md. The health exclusion is by PATH, not
# method: POST /api/db/health is also a database-reaching route and also gets no
# Origin check and no security headers from the middleware this way. It still
# requires a session — it checks one itself, the same as every other guarded
# route — so this is a headers/CSRF gap, not an auth gap.)
#
# The CSP is enforced, not merely reported. If a deployment of yours breaks in a
# way you can trace to a blocked resource — most likely something served from an
# origin other than this app's own, such as a custom-hosted Monaco bundle or a CDN
# in front of static assets — set this to "true" to downgrade it to report-only:
# the browser then logs the same violation to its console instead of blocking the
# resource, with no server-side trace (the policy carries no report-uri). No
# rebuild is required; this is a plain runtime environment variable. Please also
# open an issue naming the violated directive - a prebuilt image cannot be
# rebuilt by the person whose channel broke, which is why this hatch exists.
# CSP_REPORT_ONLY=false
#
# HSTS is sent unconditionally with a 180-day max-age and no way to turn it off:
# an escape hatch that merely stops SENDING the header would be useless, because a
# browser that already cached the pin keeps enforcing HTTPS-only for the rest of
# the 180 days regardless of what the server does next, and a server that has
# already dropped TLS may not even be reachable to serve a corrective response.
# Browsers ignore the header entirely when it arrives over plain HTTP (RFC 6797),
# so plain-HTTP deployments are unaffected by any of this.
# HSTS is opt-in only for includeSubDomains: on studio.example.com it would also
# upgrade every unrelated sibling host to HTTPS-only.
# HSTS_INCLUDE_SUBDOMAINS=false
# ─── CSRF: Origin Check ──────────────────────────────────────────────────────
# Every POST, PUT, PATCH and DELETE must carry an Origin (or, failing that, a
# Referer) whose HOST matches this deployment's own host. Schemes are ignored
# deliberately, so a TLS-terminating proxy that forwards plain HTTP does not lock
# you out. This is a second layer behind the session cookie's SameSite=Lax.
#
# SET THIS if a reverse proxy rewrites the Host header to an internal name (a
# Kubernetes service name, a Docker network alias) and does not set
# x-forwarded-host. The symptom is a page that loads correctly and then refuses
# every action, including login, with a 403 whose body names this variable.
# Comma-separated; full origins or bare hosts both work.
# ALLOWED_ORIGINS=https://db.example.com,studio.internal:8443
#
# Non-browser callers (scripts, automation) that do not send a JSON body must
# send Origin: <your public origin> instead. There is no switch to disable the
# check entirely.
# ─── Rate Limiting ───────────────────────────────────────────────────────────
# Counters live in the app process, so the limits are PER REPLICA. The default
# deployment runs one replica. If you run more than one, enforce the same budgets
# at your ingress instead (nginx limit_req, Traefik rateLimit) - see
# charts/libredb-studio/README.md. Setting any *_MAX to 0 disables that bucket.
#
# Failed logins per client address per window. The sixth returns 429.
# RATE_LIMIT_LOGIN_MAX=5
# RATE_LIMIT_LOGIN_WINDOW_SEC=300
#
# Failed logins per submitted account per window. This one is keyed on a hash of
# the submitted email and is unaffected by a forged X-Forwarded-For, which is
# what keeps per-account brute force capped. A successful login clears it, so a
# typo run is not a lockout - but this is inherently a denial-of-login handle on
# a known account: whoever trips it (a stranger needs only the address, and the
# published default admin@libredb.org counts) locks the real owner out for the
# rest of the window, renewable indefinitely afterwards at roughly one wrong
# guess per window. That is the accepted trade for bounding brute force against
# an operator-set password, not an oversight - there is no design that removes
# this residual without also removing the bound. Set this to 0 to disable the
# bucket entirely (verified: every request is then allowed unconditionally, not
# blocked) if that trade is unacceptable for your deployment.
# RATE_LIMIT_LOGIN_ACCOUNT_MAX=20
# RATE_LIMIT_LOGIN_ACCOUNT_WINDOW_SEC=300
#
# Failed passkey sign-ins per client address per window, a budget of its own so
# passkey retries never lock password sign-in.
# RATE_LIMIT_PASSKEY_MAX=10
# RATE_LIMIT_PASSKEY_WINDOW_SEC=300
#
# Shared, per signed-in user, by every route that reaches an LLM provider or
# touches an agent run: the /api/ai/* routes, and every /api/agent/* route apart
# from GET /api/agent/config, which reaches no provider and only reports whether
# the agent exists. They share the bucket so that rotating between them cannot
# multiply the budget.
#
# It bounds how often LLM work can be STARTED, not how much that work costs. One
# agent run takes a single slot and then makes many model calls of its own, so
# this cap alone does not bound the bill on your LLM API key. What bounds a
# single run is the agent's own budget (see docs/AGENT.md).
# RATE_LIMIT_AI_MAX=20
# RATE_LIMIT_AI_WINDOW_SEC=60
#
# Shared, per user, by every database-reaching route, POST /api/mcp included:
# a session and an MCP token of the same user share one budget, so routing the
# same workload through a different endpoint does not multiply it.
# src/lib/api/rate-limit.ts holds the current list.
# RATE_LIMIT_QUERY_MAX=120
# RATE_LIMIT_QUERY_WINDOW_SEC=60
#
# Bounds how many "permission denied" audit lines an unauthenticated scanner can
# produce. It never affects whether a request is refused, only how often the
# refusal is logged.
# RATE_LIMIT_ANON_MAX=5
# RATE_LIMIT_ANON_WINDOW_SEC=300
# ─── Forwarded Headers ───────────────────────────────────────────────────────
# The client address the rate limiter buckets on, and the "ip" field in the
# audit log, are derived from X-Forwarded-For (falling back to X-Real-IP). Both
# headers are attacker-controlled: the derived value is a HINT for bucketing and
# for reading logs, never an identity, and nothing in this product makes an
# authorization decision from it.
#
# 0 (default) takes the leftmost X-Forwarded-For entry. Set it to the number of
# proxies in front of Studio to take the entry that proxy appended instead.
# TRUSTED_PROXY_HOPS=0
#
# Set to "false" to ignore forwarded headers entirely. Be aware of what that
# costs: with no address signal at all, every anonymous caller shares one
# bucket, so a single attacker tripping the login limiter locks everyone out
# until the window closes.
# TRUST_PROXY_HEADERS=true