Repository navigation
Expand file tree
/
Copy pathdocker-compose.cluster-development.yml
More file actions
923 lines (854 loc) · 39.4 KB
/
Copy pathdocker-compose.cluster-development.yml
File metadata and controls
923 lines (854 loc) · 39.4 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
# =============================================================================
# NocoDB - Cluster Development Mode (HAProxy + 2 Instanzen + Redis)
# =============================================================================
# Usage: docker compose -f docker-compose.cluster-development.yml up -d --build
#
# Features:
# - Builds custom NocoDB, init and backup images from src/
# - HAProxy load balancer, direct port binding without reverse proxy
# - Redis backplane (cache, job queue, socket.io)
# - PostgreSQL database with optimized settings
# - MinIO for local S3 testing (optional profile)
#
# Profiles:
# - backup: Enable backup sidecar + MinIO
# - minio: Enable MinIO only (for manual testing)
#
# Build Only:
# docker compose -f docker-compose.cluster-development.yml build
#
# Access:
# - Application: http://localhost:${EXPOSED_APP_PORT}
# - MinIO Console: http://localhost:9001 (with --profile backup or minio)
# - HAProxy Stats: docker exec -it ${STACK_NAME:-nocodb-dev}_LB \
# wget -qO- http://127.0.0.1:8404/stats
#
# Erster Benutzer einer NEUEN Installation: danach beide Instanzen einmal neu
# starten - sonst antwortet die, die ihn nicht angelegt hat, auf
# Bases-Anfragen mit 403 (README, Cluster Mode):
# docker compose -f docker-compose.cluster-development.yml restart \
# nocodb-server-1 nocodb-server-2
#
# -----------------------------------------------------------------------------
# WARUM NUR ZWEI INSTANZEN
# -----------------------------------------------------------------------------
# Zwei Instanzen reichen aus, um alles zu testen, was am Cluster-Betrieb
# ueberhaupt anders ist:
# - Migrations-Gate (Instanz 2 startet erst nach Health von Instanz 1)
# - Cache-Kohaerenz ueber Redis (Schemaaenderung auf 1 -> sichtbar auf 2)
# - socket.io-Fan-out ueber Redis (Live-Update zwischen beiden Instanzen)
# - Sticky Sessions (NCSRVID-Cookie)
# Acht Node-Prozesse plus PostgreSQL plus MinIO wuerden eine Entwickler-
# maschine unnoetig erdruecken - der Erkenntnisgewinn waere null.
#
# Die HAProxy-Konfiguration steht INLINE am Ende dieser Datei (configs-Block).
# Der Stack ist damit allein aus dieser Compose-Datei plus .env deploybar -
# es wird keine zusaetzliche Datei aus dem Repository benoetigt.
# Hier sind entsprechend nur zwei Backend-Server deklariert, in der
# Produktionsvariante acht.
# =============================================================================
# -----------------------------------------------------------------------------
# Gemeinsame Definition aller NocoDB-Instanzen
# -----------------------------------------------------------------------------
# Das depends_on hier gilt fuer Instanz 2: sie wartet auf die Health von
# nocodb-server-1. nocodb-server-1 ueberschreibt den Eintrag.
x-nocodb: &nocodb
build:
context: ./src/nocodb
args:
BASE_NOCODB_IMAGE: ${BASE_NOCODB_IMAGE:-nocodb/nocodb}
BASE_NOCODB_VERSION: ${BASE_NOCODB_VERSION:-latest}
restart: unless-stopped
healthcheck:
# /api/v1/health ist PFLICHT - NICHT "/".
# NocoDB bindet den Port BEVOR die Migrationen laufen (run/timely.ts:
# server.listen(...) und erst im Callback Noco.init()). "/" liefert deshalb
# sofort 200 aus den statischen Assets und waere falsch-positiv; die Route
# /api/v1/health existiert erst nach vollstaendigem Init inklusive
# Migrationen und NcUpgrader. Nur so traegt das depends_on-Gate unten.
test: ["CMD", "curl", "-sf", "http://localhost:8080/api/v1/health"]
interval: 10s
timeout: 5s
retries: 30
start_period: 120s
logging:
driver: json-file
options:
max-size: "50m"
max-file: "3"
deploy:
resources:
limits:
# Bewusst dieselben Limits wie in der Produktion, damit sich die
# Entwicklungsumgebung gleich verhaelt. Ceilings, keine Reservierungen -
# der Container belegt nur, was er tatsaechlich braucht (gemessen im
# Leerlauf: rund 715 MB je Instanz).
#
# Wichtig: Node liest das cgroup-Limit aus und leitet daraus seine
# Heap-Groesse ab (bei 3G rund 1728 MB). Ohne Limit dimensioniert Node
# gegen den Host-RAM - acht Instanzen koennten dann gemeinsam deutlich
# mehr anfordern, als der Host hat.
cpus: "${NOCODB_CPU_LIMIT:-2.0}"
memory: ${NOCODB_MEMORY_LIMIT:-3G}
environment:
- TZ=${TIME_ZONE:-Etc/UTC}
- NC_DB=pg://database-server:5432?u=nocodb&p=${DATABASE_PASSWORD}&d=nocodb
- NC_APP_DATA_DIR=/usr/app/data
# NC_SITE_URL bestimmt auch den socket.io-Namespace und MUSS deshalb auf
# allen Instanzen identisch sein. Zugleich Basis aller Attachment- und
# Mail-Links: Default passt nur fuer Zugriff vom selben Rechner - sonst
# in .env die Browser-URL setzen.
- NC_SITE_URL=${NC_SITE_URL:-http://localhost:${EXPOSED_APP_PORT:-8080}}
# Proxy-Vertrauen (seit NocoDB 2026.08.1 per Default AUS). 1 = ein Hop:
# HAProxy haengt bewusst KEINEN eigenen X-Forwarded-For-Eintrag an (siehe
# fe_nocodb), der rechteste Eintrag ist damit der Client, den der Edge sah.
# Dev: HAProxy ist hier auf dem Host veroeffentlicht - Clients aus privaten
# Netzen gelten als trusted_edge und koennen X-Forwarded-For setzen.
- NC_TRUST_PROXY=${NC_TRUST_PROXY:-1}
# Redis: Cache + Bull-Job-Queue + PubSub + socket.io-Adapter.
# ZWINGEND fuer den Mehr-Instanz-Betrieb.
- NC_REDIS_URL=redis://redis-server:6379
# Telemetry & Error-Reporting (zwei getrennte Schalter!)
- NC_DISABLE_TELE=true
- NC_DISABLE_ERR_REPORTS=true
- NC_DISABLE_SUPPORT_CHAT=true
- NC_DISABLE_AUDIT=true
# Auth - beide Werte MUESSEN auf allen Instanzen identisch sein.
- NC_AUTH_JWT_SECRET=${NC_AUTH_JWT_SECRET:-dev-jwt-secret-not-for-production}
- NC_JWT_EXPIRES_IN=${NC_JWT_EXPIRES_IN:-12h}
# Verschluesselung der Credentials externer Datenquellen (sources-Tabelle).
# BEWUSST NICHT GESETZT - Opt-in, siehe .env.example.
#
# Ohne Key bleiben die Credentials im Klartext in der Datenbank (und damit
# in jedem pg_dump). Das ist der Zustand seit jeher und eine bewusste
# Abwaegung: das Aktivieren ist IRREVERSIBEL und loest beim Start eine
# Re-Encrypt-Migration aus, die bei mehreren gleichzeitig startenden
# Instanzen die Credentials durch Doppelverschluesselung zerstoert.
#
# Ein leerer Wert ist sicher: initDataSourceEncryption.ts prueft
# if (!process.env.NC_CONNECTION_ENCRYPT_KEY) return;
# - leerer String ist falsy, die Migration wird also uebersprungen.
#
# Aktivieren nur bewusst und nach der Anleitung in .env.example.
- NC_CONNECTION_ENCRYPT_KEY=${NC_CONNECTION_ENCRYPT_KEY:-}
# Knex/tarn Connection Pool - PRO INSTANZ.
# Wird auch an den HAProxy durchgereicht und dort als "maxconn" je
# Backend-Server verwendet; beide koennen nicht auseinanderlaufen.
- NC_DB_POOL_MAX=${NC_DB_POOL_MAX_CLUSTER:-10}
# Thread-Begrenzung fuer die Bildverarbeitung (Thumbnails, sharp/libvips).
# WICHTIG: libvips startet per Default so viele Worker-Threads, wie CPU-Cores
# SICHTBAR sind - unabhaengig vom cgroup-CPU-Limit. Auf einem 24-Core-Host
# waeren das 24 Threads JE INSTANZ, bei 8 Instanzen also bis zu 192 Threads
# fuer 24 Cores. Das cpus-Limit drosselt die zwar per CFS-Quota, verhindert
# die Thread-Explosion aber nicht - es tauscht Ueberlast gegen
# Context-Switching.
# Deshalb hier passend zum cpus-Limit setzen (Default 2 zu cpus 2.0).
- VIPS_CONCURRENCY=${VIPS_CONCURRENCY:-2}
# Query & Bulk Operation Limits
- DB_QUERY_LIMIT_DEFAULT=${DB_QUERY_LIMIT_DEFAULT:-25}
- DB_QUERY_LIMIT_MAX=${DB_QUERY_LIMIT_MAX:-1000}
- DB_QUERY_LIMIT_MIN=${DB_QUERY_LIMIT_MIN:-1}
- DB_QUERY_LIMIT_GROUP_BY_GROUP=${DB_QUERY_LIMIT_GROUP_BY_GROUP:-10}
- DB_QUERY_LIMIT_GROUP_BY_RECORD=${DB_QUERY_LIMIT_GROUP_BY_RECORD:-10}
- NC_DATA_PAYLOAD_LIMIT=${NC_DATA_PAYLOAD_LIMIT:-100000}
- NC_REQUEST_BODY_SIZE=${NC_REQUEST_BODY_SIZE:-268435456}
# Rate Limiting: bewusst KEINES. Begruendung im configs-Block am Ende
# dieser Datei (ein Per-IP-Limit trifft bei Automatisierungen wie n8n
# immer den eigenen Traffic, nie einen Angreifer).
# SMTP Configuration
# ACHTUNG Semantik: NC_SMTP_IGNORE_TLS steuert STARTTLS, NICHT die
# Zertifikatspruefung. Der Schalter dafuer ist NC_SMTP_REJECT_UNAUTHORIZED.
- NC_SMTP_HOST=${SMTP_HOST:-}
- NC_SMTP_PORT=${SMTP_PORT:-587}
- NC_SMTP_USERNAME=${SMTP_USER:-}
- NC_SMTP_PASSWORD=${SMTP_PASSWORD:-}
- NC_SMTP_SECURE=${SMTP_TLS:-true}
- NC_SMTP_IGNORE_TLS=${SMTP_IGNORE_TLS:-false}
- NC_SMTP_REJECT_UNAUTHORIZED=${SMTP_CERTCHECK:-true}
- NC_SMTP_FROM=${SMTP_FROM_NAME:-NocoDB} <${SMTP_FROM:-}>
# Attachment Settings
- NC_ATTACHMENT_FIELD_SIZE=${NC_ATTACHMENT_FIELD_SIZE:-268435456}
- NC_NON_ATTACHMENT_FIELD_SIZE=${NC_NON_ATTACHMENT_FIELD_SIZE:-268435456}
- NC_MAX_ATTACHMENTS_ALLOWED=${NC_MAX_ATTACHMENTS_ALLOWED:-50}
- NC_SECURE_ATTACHMENTS=${NC_SECURE_ATTACHMENTS:-false}
- NC_ATTACHMENT_EXPIRE_SECONDS=${NC_ATTACHMENT_EXPIRE_SECONDS:-7200}
# S3 Storage (optional - for attachments)
- NC_S3_BUCKET_NAME=${NC_S3_BUCKET_NAME:-}
- NC_S3_REGION=${NC_S3_REGION:-}
- NC_S3_ENDPOINT=${NC_S3_ENDPOINT:-}
- NC_S3_ACCESS_KEY=${NC_S3_ACCESS_KEY:-}
- NC_S3_ACCESS_SECRET=${NC_S3_ACCESS_SECRET:-}
- NC_S3_FORCE_PATH_STYLE=${NC_S3_FORCE_PATH_STYLE:-true}
- NC_S3_ACL=${NC_S3_ACL:-private}
volumes:
- nocodb-data:/usr/app/data
# Kein Port-Binding an den Instanzen - der Zugriff laeuft ueber den
# loadbalancer. Sonst waere die Stickiness umgehbar.
networks:
local:
depends_on:
nocodb-server-1:
condition: service_healthy
services:
### NocoDB Instanz 1 - Migrations-Leader ###
# -----------------------------------------------------------------------------
# Instanz 2 wartet ueber depends_on auf die Health dieses Containers. Das ist
# von Docker bei jedem up, Neustart und Image-Rebuild erzwungen.
#
# Warum noetig - paralleler Boot ist nachweislich unsicher:
# - Der Knex-Migrations-Lock selbst funktioniert.
# - ABER NcUpgrader (version-upgrader/NcUpgrader.ts) ist ungesichert; der
# Quelltext traegt dort woertlich "// Todo: transaction". Ohne FOR UPDATE
# lesen alle Instanzen dieselbe alte Version und fuehren saemtliche
# Data-Upgrader parallel aus - genau beim Versions-Upgrade.
# - Auf frischer Datenbank laeuft Knex' ensureTable() ausserhalb der
# Transaktion; mehrere Prozesse koennen mehrere Lock-Zeilen einfuegen und
# die Migrations-Sperre dauerhaft blockieren.
#
# In der Entwicklung ist das der Normalfall, nicht die Ausnahme: eine frische
# Datenbank entsteht hier bei jedem "down -v".
# -----------------------------------------------------------------------------
nocodb-server-1:
<<: *nocodb
container_name: ${STACK_NAME:-nocodb-dev}_SERVER_1
# Ueberschreibt das depends_on aus dem Anchor (kein Selbstbezug).
depends_on:
database-server:
condition: service_healthy
redis-server:
condition: service_healthy
nocodb-init:
condition: service_completed_successfully
### NocoDB Instanz 2 ###
# Erbt depends_on (nocodb-server-1 healthy) aus dem Anchor.
nocodb-server-2:
<<: *nocodb
container_name: ${STACK_NAME:-nocodb-dev}_SERVER_2
### HAProxy Load Balancer ###
# Konfiguration inline im configs-Block am Ende dieser Datei.
loadbalancer:
image: haproxy:${HAPROXY_VERSION:-3.4}-alpine
restart: unless-stopped
container_name: ${STACK_NAME:-nocodb-dev}_LB
healthcheck:
test: ["CMD-SHELL", "wget -qO- http://127.0.0.1:8405/healthz || exit 1"]
interval: 10s
timeout: 3s
retries: 3
start_period: 20s
deploy:
resources:
limits:
# HAProxy ist zustandslos und sehr sparsam (gemessen: ~22 MB).
cpus: "${HAPROXY_CPU_LIMIT:-1.0}"
memory: ${HAPROXY_MEMORY_LIMIT:-512M}
logging:
driver: json-file
options:
max-size: "50m"
max-file: "3"
configs:
- source: haproxy_cfg
target: /usr/local/etc/haproxy/haproxy.cfg
ports:
# Direct port binding without reverse proxy.
# Examples: 8080, 192.168.1.100:8080, 127.0.0.1:8080
- "${EXPOSED_APP_PORT:-8080}:8080"
depends_on:
nocodb-server-1:
condition: service_healthy
networks:
local:
### Redis - Cache, Job-Queue, PubSub, socket.io-Backplane ###
# ZWINGEND fuer den Mehr-Instanz-Betrieb. Ohne Redis sehen die Instanzen die
# Schemaaenderungen der jeweils anderen nicht und socket.io-Events bleiben
# auf der emittierenden Instanz. Beides faellt STILL aus - der Stack sieht
# dabei gesund aus. Genau das soll hier lokal reproduzierbar sein.
redis-server:
image: redis:${REDIS_VERSION:-8}-alpine
restart: unless-stopped
container_name: ${STACK_NAME:-nocodb-dev}_REDIS
# maxmemory-policy: BEWUSST noeviction. Bull legt Jobs als Hash plus
# Referenzen in wait/active-Listen ab; ein evictierter Job-Hash hinterlaesst
# tote Referenzen - Jobs verschwinden lautlos, die Queue kann blockieren.
# allkeys-lru waere hier fatal.
command: >
redis-server
--port 6379
--loglevel warning
--protected-mode no
--maxmemory ${REDIS_MEMORYLIMIT:-512mb}
--maxmemory-policy ${REDIS_MEMORYPOLICY:-noeviction}
--appendonly yes
--appendfsync everysec
--save 300 100
healthcheck:
test: ["CMD", "redis-cli", "ping"]
interval: 15s
timeout: 5s
retries: 4
start_period: 10s
deploy:
resources:
limits:
# Muss ueber REDIS_MEMORYLIMIT liegen: maxmemory begrenzt nur die
# Nutzdaten, hinzu kommen Fragmentierung, AOF-Puffer und Copy-on-Write
# beim Snapshot-Fork. Faustregel: Container-Limit ~1,5x maxmemory.
cpus: "${REDIS_CPU_LIMIT:-1.0}"
memory: ${REDIS_MEMORY_LIMIT:-1G}
logging:
driver: json-file
options:
max-size: "10m"
max-file: "3"
environment:
- TZ=${TIME_ZONE:-Etc/UTC}
expose:
- 6379/tcp
volumes:
- redis-data:/data
networks:
local:
### NocoDB Init Container ###
# One-shot container that initializes database settings (collation checks)
nocodb-init:
build:
context: ./src/nocodb-init
container_name: ${STACK_NAME:-nocodb-dev}_INIT
restart: "no"
deploy:
resources:
limits:
cpus: "1.0"
memory: 512M
logging:
driver: json-file
options:
max-size: "10m"
max-file: "3"
environment:
- TZ=${TIME_ZONE:-Etc/UTC}
- DB_HOST=database-server
- DB_PORT=5432
- DB_NAME=nocodb
- DB_USER=nocodb
- DB_PASSWORD=${DATABASE_PASSWORD}
- INIT_COLLATION_CHECK=${INIT_COLLATION_CHECK:-true}
- INIT_COLLATION_AUTO_FIX=${INIT_COLLATION_AUTO_FIX:-false}
- INIT_AUDIT_CLEANUP=${INIT_AUDIT_CLEANUP:-true}
depends_on:
database-server:
condition: service_healthy
networks:
local:
### PostgreSQL Database ###
database-server:
image: postgres:${POSTGRES_VERSION:-18}
restart: unless-stopped
container_name: ${STACK_NAME:-nocodb-dev}_DATABASE
# max_connections deutlich niedriger als in der Produktion: hier laufen nur
# zwei Instanzen. Formel siehe docker-compose.cluster.yml.
command: >
-c max_connections=${DATABASE_POOLMAXSIZE_CLUSTER_DEV:-100}
-c shared_buffers=${PG_SHARED_BUFFERS_CLUSTER:-512MB}
-c work_mem=${PG_WORK_MEM_CLUSTER:-4MB}
-c effective_cache_size=${PG_EFFECTIVE_CACHE_SIZE_CLUSTER:-1GB}
-c maintenance_work_mem=${PG_MAINTENANCE_WORK_MEM_CLUSTER:-128MB}
-c max_wal_size=${PG_MAX_WAL_SIZE:-1GB}
-c min_wal_size=${PG_MIN_WAL_SIZE:-256MB}
-c random_page_cost=${PG_RANDOM_PAGE_COST:-1.1}
-c seq_page_cost=1.0
-c effective_io_concurrency=${PG_EFFECTIVE_IO_CONCURRENCY:-200}
-c max_parallel_workers_per_gather=2
-c max_parallel_workers=4
-c max_parallel_maintenance_workers=2
-c checkpoint_completion_target=0.9
-c wal_buffers=16MB
-c default_statistics_target=100
healthcheck:
test: ["CMD-SHELL", "pg_isready -U nocodb -d nocodb"]
interval: 10s
timeout: 5s
retries: 5
start_period: 20s
deploy:
resources:
limits:
# ACHTUNG Wechselwirkung: shared_buffers UND work_mem x aktive
# Verbindungen muessen INNERHALB dieses Limits passen. work_mem gilt
# je Sortier-/Hash-Operation und Verbindung - bei 100 Verbindungen
# und 16MB sind das im schlechtesten Fall mehrere GB ZUSAETZLICH zu
# shared_buffers. Wer dieses Limit senkt, muss PG_SHARED_BUFFERS und
# PG_WORK_MEM mitziehen, sonst wird der Container OOM-gekillt.
cpus: "${POSTGRES_CPU_LIMIT:-2.0}"
memory: ${POSTGRES_MEMORY_LIMIT:-3G}
logging:
driver: json-file
options:
max-size: "50m"
max-file: "3"
environment:
- TZ=${TIME_ZONE:-Etc/UTC}
- PG_TZ=${TIME_ZONE:-Etc/UTC}
- POSTGRES_DB=nocodb
- POSTGRES_USER=nocodb
- POSTGRES_PASSWORD=${DATABASE_PASSWORD}
expose:
- 5432/tcp
volumes:
- database-server:/var/lib/postgresql
networks:
local:
### NocoDB Backup Sidecar (Optional) ###
# Activate with:
# docker compose -f docker-compose.cluster-development.yml --profile backup up -d
#
# NOCODB_API_URL zeigt auf den loadbalancer, nicht auf eine feste Instanz.
nocodb-backup:
build:
context: ./src/nocodb-backup
args:
BACKUPHELPER_VERSION: ${BACKUPHELPER_VERSION:-latest}
container_name: ${STACK_NAME:-nocodb-dev}_BACKUP
profiles: ["backup"]
# Root instead of the engine's uid 1000: nocodb-server runs as root and
# writes the data volume as root, so uid 1000 can back it up but cannot
# restore into it ("Permission denied"). Capabilities are cut down to what
# backup and restore need: DAC_OVERRIDE for the uid-1000 /data volume and
# files of other owners, FOWNER to set mode and times on restored files.
user: "0:0"
cap_drop: [ALL]
cap_add: [DAC_OVERRIDE, FOWNER]
restart: unless-stopped
logging:
driver: json-file
options:
max-size: "10m"
max-file: "3"
environment:
TZ: ${TIME_ZONE:-Etc/UTC}
BACKUP_LOG_LEVEL: ${NOCODB_BACKUP_LOG_LEVEL:-INFO}
BACKUP_LOG_FORMAT: ${NOCODB_BACKUP_LOG_FORMAT:-console}
DB_PASSWORD: ${DATABASE_PASSWORD}
NOCODB_API_TOKEN: ${NOCODB_API_TOKEN:-}
BACKUP_S3_SECRET_KEY: ${NOCODB_BACKUP_S3_SECRET_KEY:-nocodbbackup}
SMTP_PASSWORD: ${SMTP_PASSWORD:-}
WEBHOOK_SECRET: ${NOCODB_BACKUP_WEBHOOK_SECRET:-}
NOCODB_API_URL: ${NOCODB_API_URL:-http://loadbalancer:8080}
# Redis of the NocoDB instances (their NC_REDIS_URL). After `backuphelper
# restore` of a database dump the sidecar empties it, so instances that
# keep running do not serve the metadata cache of the old database.
NOCODB_REDIS_URL: redis://redis-server:6379
# dump_format "custom": restored with pg_restore --clean --if-exists, also
# over a live database. Over one that changed since the snapshot (tables
# added, a newer NocoDB) the engine first drops the base schemas and what
# the snapshot lacks, in one transaction (docs/BACKUP.md). A "plain" dump
# is replayed with psql and stops at the first table that already exists.
# Alert mail: the engine speaks STARTTLS ("tls"), not implicit TLS, so it
# uses NOCODB_BACKUP_SMTP_PORT/_STARTTLS - not NocoDB's SMTP_PORT/SMTP_TLS.
BACKUP_CONFIG_JSON: |
{
"instance_name": "${NOCODB_BACKUP_INSTANCE_NAME:-${STACK_NAME:-nocodb-dev}}",
"jobs": [{
"name": "main",
"keep_local": ${NOCODB_BACKUP_KEEP_LOCAL_ARCHIVE:-true},
"sources": [
{"type": "postgres", "name": "database", "enabled": ${NOCODB_BACKUP_DATABASE_DUMP:-true},
"host": "database-server", "port": 5432, "database": "nocodb", "user": "nocodb",
"password": "$${DB_PASSWORD}", "ssl_mode": "disable", "dump_format": "custom",
"timeout": ${NOCODB_BACKUP_DATABASE_DUMP_TIMEOUT:-1800}},
{"type": "filesystem", "name": "nocodb-data", "enabled": ${NOCODB_BACKUP_INCLUDE_FILES:-true},
"path": "/nocodb-data"},
{"type": "nocodb-rest", "name": "nocodb", "enabled": ${NOCODB_BACKUP_API_EXPORT:-true},
"api_url": "${NOCODB_API_URL:-http://loadbalancer:8080}", "token": "$${NOCODB_API_TOKEN}",
"include_records": ${NOCODB_BACKUP_INCLUDE_RECORDS:-true},
"include_attachments": ${NOCODB_BACKUP_INCLUDE_ATTACHMENTS:-true}}
],
"destinations": [
{"type": "local"},
{"type": "s3", "endpoint": "${NOCODB_BACKUP_S3_ENDPOINT_URL:-http://minio:9000}", "bucket": "${NOCODB_BACKUP_S3_BUCKET:-nocodb-backups}",
"access_key": "${NOCODB_BACKUP_S3_ACCESS_KEY:-nocodbbackup}", "secret_key": "$${BACKUP_S3_SECRET_KEY}",
"region": "${NOCODB_BACKUP_S3_REGION:-eu-north-1}", "prefix": "${NOCODB_BACKUP_S3_PREFIX:-nocodb/}"}
],
"schedule": {"mode": "${NOCODB_BACKUP_SCHEDULE_MODE:-cron}",
"hour": "${NOCODB_BACKUP_SCHEDULE_HOUR:-5}", "minute": "${NOCODB_BACKUP_SCHEDULE_MINUTE:-15}",
"day_of_week": "${NOCODB_BACKUP_SCHEDULE_DAY_OF_WEEK:-*}",
"interval_hours": ${NOCODB_BACKUP_SCHEDULE_INTERVAL_HOURS:-24},
"on_startup": ${NOCODB_BACKUP_ON_STARTUP:-false}},
"retention": {"count": ${NOCODB_BACKUP_RETENTION_COUNT:-30}},
"notifications": {
"channels": "${NOCODB_BACKUP_ALERT_CHANNELS:-}",
"level": "${NOCODB_BACKUP_ALERT_LEVEL:-warnings}",
"email": {"host": "${SMTP_HOST:-}", "port": ${NOCODB_BACKUP_SMTP_PORT:-587}, "tls": ${NOCODB_BACKUP_SMTP_STARTTLS:-true},
"username": "${SMTP_USER:-}", "password": "$${SMTP_PASSWORD}",
"sender": "${SMTP_FROM:-}", "recipients": ["${NOCODB_BACKUP_ALERT_EMAIL:-}"]},
"webhook": {"url": "${NOCODB_BACKUP_WEBHOOK_URL:-}", "secret": "$${WEBHOOK_SECRET}"},
"teams": {"url": "${NOCODB_BACKUP_TEAMS_WEBHOOK:-}"}
}
}]
}
volumes:
- nocodb-backup-data:/data
- nocodb-data:/nocodb-data
depends_on:
database-server:
condition: service_healthy
loadbalancer:
condition: service_healthy
minio-init:
condition: service_completed_successfully
networks:
local:
#############################################################################
# MinIO (Local S3 for Development)
#############################################################################
#
# Console: http://localhost:9001
# API: http://localhost:9000
#
# Activate with: docker compose -f docker-compose.development.yml --profile minio up -d
# Or with backup: docker compose -f docker-compose.development.yml --profile backup up -d
#
# For external S3, don't start this profile - just configure NOCODB_BACKUP_S3_* vars.
#
#############################################################################
minio:
image: minio/minio:latest
container_name: ${STACK_NAME:-nocodb-dev}_MINIO
profiles: ["minio", "backup"]
command: server /data --console-address ":9001"
environment:
MINIO_ROOT_USER: ${MINIO_ROOT_USER:-minioadmin}
MINIO_ROOT_PASSWORD: ${MINIO_ROOT_PASSWORD:-minioadmin}
MINIO_REGION: ${NOCODB_BACKUP_S3_REGION:-eu-north-1}
volumes:
- minio-data:/data
ports:
- "${MINIO_API_PORT:-9000}:9000"
- "${MINIO_CONSOLE_PORT:-9001}:9001"
healthcheck:
test: ["CMD", "mc", "ready", "local"]
interval: 30s
timeout: 20s
retries: 3
restart: unless-stopped
logging:
driver: json-file
options:
max-size: "10m"
max-file: "3"
networks:
local:
#############################################################################
# MinIO Init (Creates bucket and service user for NocoDB backup)
#############################################################################
minio-init:
image: minio/mc:latest
container_name: ${STACK_NAME:-nocodb-dev}_MINIO_INIT
profiles: ["minio", "backup"]
depends_on:
minio:
condition: service_healthy
environment:
# MinIO Admin Credentials
MINIO_ROOT_USER: ${MINIO_ROOT_USER:-minioadmin}
MINIO_ROOT_PASSWORD: ${MINIO_ROOT_PASSWORD:-minioadmin}
# Backup Service User (created for nocodb-backup)
S3_ACCESS_KEY: ${NOCODB_BACKUP_S3_ACCESS_KEY:-nocodbbackup}
S3_SECRET_KEY: ${NOCODB_BACKUP_S3_SECRET_KEY:-nocodbbackup}
S3_BUCKET: ${NOCODB_BACKUP_S3_BUCKET:-nocodb-backups}
S3_REGION: ${NOCODB_BACKUP_S3_REGION:-eu-north-1}
S3_PREFIX: ${NOCODB_BACKUP_S3_PREFIX:-nocodb-backup}
networks:
local:
restart: "no"
entrypoint: ["/bin/sh", "-c"]
command:
- |
set -e
echo ""
echo "================================================================"
echo " MinIO Initialization for NocoDB Backup"
echo "================================================================"
echo ""
# Configure MinIO client
mc alias set storage http://minio:9000 $${MINIO_ROOT_USER} $${MINIO_ROOT_PASSWORD}
echo "Bucket: $${S3_BUCKET}"
echo "Region: $${S3_REGION}"
echo "Prefix: $${S3_PREFIX}"
echo "Service User: $${S3_ACCESS_KEY}"
echo ""
# Create bucket if not exists (with region)
if mc ls storage/$${S3_BUCKET} >/dev/null 2>&1; then
echo "+ Bucket '$${S3_BUCKET}' already exists"
else
echo "Creating bucket '$${S3_BUCKET}' in region '$${S3_REGION}'..."
mc mb storage/$${S3_BUCKET} --region $${S3_REGION}
echo "+ Bucket created"
fi
# Set bucket to private
mc anonymous set none storage/$${S3_BUCKET}
# Create/recreate service user for nocodb-backup (ensures password is current)
if mc admin user info storage $${S3_ACCESS_KEY} >/dev/null 2>&1; then
echo "Recreating service user '$${S3_ACCESS_KEY}' with current credentials..."
mc admin user remove storage $${S3_ACCESS_KEY}
fi
mc admin user add storage $${S3_ACCESS_KEY} $${S3_SECRET_KEY}
echo "+ Service user '$${S3_ACCESS_KEY}' ready"
# Create policy for bucket access
POLICY_NAME="nocodb-backup-policy"
POLICY_FILE="/tmp/policy.json"
cat > $${POLICY_FILE} << EOF
{
"Version": "2012-10-17",
"Statement": [
{
"Effect": "Allow",
"Action": [
"s3:GetObject",
"s3:PutObject",
"s3:DeleteObject",
"s3:ListBucket",
"s3:GetBucketLocation",
"s3:AbortMultipartUpload",
"s3:ListMultipartUploadParts",
"s3:ListBucketMultipartUploads"
],
"Resource": [
"arn:aws:s3:::$${S3_BUCKET}",
"arn:aws:s3:::$${S3_BUCKET}/*"
]
}
]
}
EOF
# Apply policy
mc admin policy create storage $${POLICY_NAME} $${POLICY_FILE} 2>/dev/null || \
mc admin policy info storage $${POLICY_NAME} >/dev/null 2>&1
mc admin policy attach storage $${POLICY_NAME} --user $${S3_ACCESS_KEY} 2>/dev/null || true
echo "+ Policy '$${POLICY_NAME}' attached to user"
echo ""
echo "================================================================"
echo " MinIO Ready"
echo "================================================================"
echo ""
echo " Console: http://localhost:9001"
echo " API: http://minio:9000 (internal)"
echo ""
echo " nocodb-backup config:"
echo " S3_ENDPOINT_URL=http://minio:9000"
echo " S3_BUCKET=$${S3_BUCKET}"
echo " S3_ACCESS_KEY=$${S3_ACCESS_KEY}"
echo " S3_SECRET_KEY=***"
echo " S3_PREFIX=$${S3_PREFIX}"
echo ""
### Configs ###
# HAProxy-Konfiguration inline - der Stack ist damit allein aus
# Compose-Datei + .env deploybar, ohne zusaetzliche Dateien aus dem Repo.
#
# ACHTUNG: ${...} wird hier von COMPOSE aufgeloest, nicht von HAProxy.
# In der gerenderten Datei stehen also literale Werte. Wer eine Variable
# an HAProxy selbst durchreichen will, muss $${...} schreiben - und dort
# gelten dann HAProxys eigene Regeln (Anfuehrungszeichen zwingend, sonst
# wird das Argument STILL ignoriert).
configs:
haproxy_cfg:
content: |
global
log stdout format raw local0 info
maxconn 8000
# Kein "user"/"group": Das Image setzt bereits USER haproxy (uid 99); ein
# setuid-Versuch als Nicht-Root erzeugt eine Startup-Alert.
stats socket /var/lib/haproxy/haproxy.sock mode 660 level admin
stats timeout 30s
ssl-default-bind-options ssl-min-ver TLSv1.2 no-tls-tickets
defaults
mode http
log global
option httplog
option dontlognull
option http-server-close
option redispatch
retries 3
# Fail-Fast: NocoDB setzt intern acquireConnectionTimeout auf 600000 ms
# (10 Minuten). Ist der Knex-Pool erschoepft, lehnt die App NICHT ab - sie
# haelt den Request offen. "timeout server" schneidet das auf 60s ab.
# "timeout tunnel" ueberschreibt client+server nach einem WebSocket-Upgrade,
# deshalb duerfen die 60s aggressiv sein, ohne socket.io zu zerstoeren.
timeout connect 5s
timeout client 60s
timeout server 60s
timeout http-request 10s
timeout http-keep-alive 10s
timeout queue 30s
timeout tunnel 24h
timeout client-fin 30s
timeout check 5s
# Dockers eingebetteter Resolver. ZWINGEND: statische server-Zeilen loest
# HAProxy einmal beim Start via libc auf und cacht das fuer die Prozesslaufzeit.
# Nach "docker compose up -d" bekommt ein neu erstellter Container haeufig eine
# neue IP - HAProxy wuerde sonst dauerhaft auf die tote Adresse verbinden.
resolvers docker
nameserver dns1 127.0.0.11:53
resolve_retries 3
timeout resolve 1s
timeout retry 1s
hold valid 10s
hold other 10s
hold refused 10s
hold nx 10s
hold timeout 10s
hold obsolete 10s
accepted_payload_size 8192
frontend fe_nocodb
bind :8080
maxconn 4000
# --- Client-IP-Normalisierung -------------------------------------------
# Vertrauensgrenze: wer darf per X-Forwarded-For eine Client-Identitaet
# behaupten? Bei Cloudflare mit proxied DNS DIREKT auf HAProxy (ohne Tunnel)
# muessen hier Cloudflares IP-Ranges stehen, sonst wird ein gefaelschter XFF
# eines Direct-to-Origin-Angreifers vertraut.
acl trusted_edge src 10.0.0.0/8 172.16.0.0/12 192.168.0.0/16 127.0.0.0/8 ::1 fd00::/8
# HAProxy ist selbst Edge -> Gegenstelle untrusted -> alles ueberschreiben.
# set-header entfernt vorhandene Vorkommen zuerst; das macht es
# spoofing-sicher.
http-request set-header X-Forwarded-For %[src] unless trusted_edge
http-request set-header X-Real-IP %[src] unless trusted_edge
http-request set-header X-Forwarded-Proto %[ssl_fc,iif(https,http)] unless trusted_edge
http-request set-header X-Forwarded-Port %[dst_port] unless trusted_edge
# Hinter vertrauenswuerdigem Edge: dessen XFF unveraendert lassen und KEINEN
# Hop addieren, damit NocoDB in beiden Topologien dieselbe Hop-Form sieht.
# Bewusst NICHT "option forwardfor" - das haengt laut Handbuch immer einen
# Eintrag an. NocoDB liest mit NC_TRUST_PROXY=1 den RECHTESTEN Eintrag;
# ein zusaetzlicher HAProxy-Hop machte daraus die Traefik-IP.
http-request set-header X-Forwarded-For %[src] if trusted_edge !{ req.hdr(X-Forwarded-For) -m found }
http-request set-header X-Real-IP %[req.hdr_ip(X-Forwarded-For,1)] if trusted_edge !{ req.hdr(X-Real-IP) -m found }
# Alternative Client-IP-Header entfernen. Seit NocoDB 2026.08.1 gilt
# req.clientIp = req.ip (trust-proxy-aware); aeltere Images nutzten die
# Middleware "request-ip", die X-Client-IP VOR X-Forwarded-For prueft.
# Bleibt als Defense-in-Depth fuer Image-Rollbacks und Downstream-Tools.
http-request del-header X-Client-IP
http-request del-header CF-Connecting-IP
http-request del-header True-Client-IP
http-request del-header Fastly-Client-IP
http-request del-header DO-Connecting-IP
http-request del-header X-Forwarded-Server
http-request del-header Proxy
# --- KEIN Rate Limiting -------------------------------------------------
# Bewusste Entscheidung, nicht vergessen.
#
# Ein Per-IP-Ratelimit passt nicht zu diesem Lastprofil: Automatisierungen
# wie n8n kommen mit vielen parallelen Workern von EINER Quell-IP. Ein
# solcher Zaehler trifft damit nie einen Angreifer, sondern immer zuerst den
# eigenen, legitimen Traffic. Zusaetzlich laedt die NocoDB-Oberflaeche rund
# 150 Asset-Chunks pro Seitenaufruf - schon zwei Seitenaufrufe sprengen jede
# sinnvolle Schwelle und erzeugen 429.
#
# Der Schutz dieses Stacks ist "maxconn" + Queue weiter unten: er begrenzt
# GLEICHZEITIGKEIT statt Rate und lehnt nichts ab, sondern stellt an. Bei
# Massen-Inserts ist genau das gewuenscht.
#
# Falls doch ein Missbrauchsschutz gebraucht wird, dann gezielt und mit
# Ausnahme fuer die eigenen Automatisierungs-Quellen:
# acl is_automation src 10.1.2.3
# http-request track-sc0 req.hdr_ip(X-Forwarded-For,1) table st_abuse if !is_automation
# http-request deny deny_status 429 if !is_automation { sc_http_req_rate(0) gt 2000 }
# Der Schwellwert MUSS literal stehen - HAProxy expandiert in numerischen
# ACL-Operanden keine Variablen.
# backend st_abuse
# stick-table type ipv6 size 200k expire 60s store http_req_rate(10s)
acl is_socketio path_beg /socket.io/
use_backend be_nocodb_ws if is_socketio
default_backend be_nocodb_api
# Gleicher Cookie-Name und gleiche sN-Labels in beiden Backends: ein von einem
# Backend gesetztes Cookie wird vom anderen honoriert. Ein Client landet damit
# fuer REST und WebSocket auf demselben Container, waehrend die maxconn-Konten
# getrennt bleiben.
backend be_nocodb_api
# DB-gebundener Pfad -> hart begrenzt.
balance leastconn
cookie NCSRVID insert indirect nocache httponly secure attr SameSite=Lax
# /api/v1/health ist PFLICHT - NICHT "/". NocoDB bindet den Port BEVOR die
# Migrationen laufen; "/" liefert sofort 200 aus den statischen Assets und
# waere falsch-positiv. Bei HTTP/1.1 fuegt http-check send KEINEN Host-Header
# hinzu - ohne "hdr Host" waere der Check ein malformter Request.
option httpchk
http-check send meth GET uri /api/v1/health ver HTTP/1.1 hdr Host nocodb
http-check expect status 200
# maxconn: Request N+1 wird GEQUEUET, nicht abgelehnt; 503 erst nach
# "timeout queue". Der Wert MUSS NC_DB_POOL_MAX entsprechen - mehr
# gleichzeitige Requests als Pool-Slots verschiebt die Queue nur von
# HAProxy (sichtbar, mit Timeout) nach Knex (unsichtbar, 10 Minuten).
# Deshalb speist dieselbe Compose-Variable beide Seiten.
# maxqueue: bewusst NICHT gesetzt - eine volle Server-Queue wuerde
# redispatchen und dabei die Cookie-Persistenz brechen.
# init-addr last,none: HAProxy startet auch, wenn noch kein NocoDB-Container
# existiert (Server dann DOWN statt Boot-Abbruch).
default-server check inter 10s fastinter 2s downinter 15s fall 3 rise 2 maxconn ${NC_DB_POOL_MAX_CLUSTER:-10} resolvers docker init-addr last,none observe layer7 error-limit 10 on-error mark-down
server nocodb1 nocodb-server-1:8080 cookie s1
server nocodb2 nocodb-server-2:8080 cookie s2
backend be_nocodb_ws
# /socket.io/ ist nicht DB-gebunden und ueberwiegend idle -> eigenes,
# grosszuegiges Budget. In HTTP-Mode begrenzt maxconn gleichzeitige
# REQUESTS, und ein aufgestiegener WebSocket haelt seinen Slot ueber die
# gesamte Verbindungsdauer: in einem gemeinsamen Backend haetten wenige
# Dutzend offene Browser-Tabs den Cluster belegt, ohne einen einzigen
# REST-Request. Fuer den Upgrade selbst ist nichts weiter noetig als
# mode http und "timeout tunnel".
balance leastconn
cookie NCSRVID insert indirect nocache httponly secure attr SameSite=Lax
option httpchk
http-check send meth GET uri /api/v1/health ver HTTP/1.1 hdr Host nocodb
http-check expect status 200
default-server check inter 10s fastinter 2s downinter 15s fall 3 rise 2 maxconn ${HAPROXY_WS_MAXCONN:-500} resolvers docker init-addr last,none
server nocodb1 nocodb-server-1:8080 cookie s1
server nocodb2 nocodb-server-2:8080 cookie s2
# Stats nur ueber Loopback INNERHALB des Containers:
# docker exec -it <STACK>_LB wget -qO- http://127.0.0.1:8404/stats
# Niemals oeffentlich veroeffentlichen - die Seite legt Backend-Namen, IPs und
# Live-Verbindungszahlen offen. "stats admin" bewusst NICHT aktiviert.
listen stats
bind 127.0.0.1:8404
mode http
stats enable
stats uri /stats
stats refresh 10s
stats hide-version
stats realm HAProxy\ Statistics
stats auth ${HAPROXY_STATS_USER:-admin}:${HAPROXY_STATS_PASSWORD:-changeme}
http-request deny unless { src 127.0.0.0/8 } || { src ::1 }
# Health + Metriken fuer Docker-Healthcheck und Monitoring.
frontend fe_monitoring
bind :8405
mode http
# monitor-uri wird sehr frueh ausgewertet, vor jeder http-request-Regel -
# das abschliessende "deny" betrifft es nicht.
monitor-uri /healthz
# Ohne diese Zeile lieferte /healthz auch dann 200, wenn ALLE Backends DOWN
# sind - solange HAProxy selbst laeuft.
monitor fail if { nbsrv(be_nocodb_api) lt 1 }
http-request use-service prometheus-exporter if { path /metrics }
http-request deny
### Volumes ###
volumes:
nocodb-data:
driver: local
name: ${STACK_NAME:-nocodb-dev}-nocodb
database-server:
driver: local
name: ${STACK_NAME:-nocodb-dev}-postgres
nocodb-backup-data:
driver: local
name: ${STACK_NAME:-nocodb-dev}-nocodb-backup
redis-data:
driver: local
name: ${STACK_NAME:-nocodb-dev}-redis
minio-data:
driver: local
name: ${STACK_NAME:-nocodb-dev}-minio
### Networks ###
networks:
local:
driver: bridge
name: ${STACK_NAME:-nocodb-dev}
enable_ipv6: true