All public traffic of the platform passes through3 floors:
Cloudflare (DNS + CDN + TLS termination) β
nginx-lb(an nginx container as reverse proxy / load balancer, routing accordinglyserver_name) β
app servers / data services / toolson the internal network10.0.0.0/24.
All components run Docker, each service has a filedocker-composeonly in the root repo.
set_real_ip_fromserver_name (10 domain) Β· upstream least_conn Β· passive health check (max_fails=3 / 30s)firewall-ssh, project only has 1 firewall) blocks :80 before nginx β but only whitelists 5/15 Cloudflare bands, see card β¦ below
The actual configuration segments correspond to each arrow in the diagram above β for complete details see sections 3β5.
set_real_ip_from 173.245.48.0/20;
β¦ (15 Cloudflare CIDR bands) β¦
set_real_ip_from 131.0.72.0/22;
real_ip_header CF-Connecting-IP;
upstream backend {
least_conn;
server 10.0.0.5:3001 max_fails=3 fail_timeout=30s;
β¦(8 instance: 10.0.0.5 + 10.0.0.4, :3001β3004)β¦
}
proxy_http_version 1.1;
proxy_set_header Upgrade $http_upgrade;
proxy_set_header Connection "upgrade";
worker_processes auto;
worker_connections 1024;
gzip on; gzip_comp_level 6;
include conf.d/*.conf;
image: nginx:alpine
restart: always
network_mode: host
network_mode: hostAllows calling 10.0.0.x directly, without needing a separate Docker network.22/tcp β Any IPv4/IPv6 (unlimited)
80/tcp β 5/15 Cloudflare strips (10 strips missing!)
443/tcp β 15/15 ranges (nginx does not listen to 443)
set_real_ip_fromat port 80, see risks #13β14 section 13.nginx-lbrun onV1-Kiosk (178.156.194.24)β both reverse proxy and load balancer, and issingle point of failure (SPOF): If V1 dies, the whole system dies, even though other servers are still alive.X-Forwarded-Protomounted by Cloudflare.least_conn.unhealthydue to wrong image healthcheck β not a problem, just fix/disable healthcheck (risk #11).ubuntu-8gb-ash-1when SSH β only IP can reliably differentiate.Source:iplist, comment in nginx/conf.d/*.conf, compose files; server name/plan/IP server 5:Hetzner Console(project "Kiosk VPS", region Ashburn VA / us-east, screenshot 2026-07-03). The role of each server is derived from the internal IP that nginx points to and commented in the conf.
| Server (name Hetzner) | Plan Β· vCPU/RAM* Β· Disk | IPv4 public | IPv6 | Internal IP | Role | Service (port) |
|---|---|---|---|---|---|---|
| Server 1 β V1-Kiosk | CCX23 Β· 4 vCPU / 16GB Β· 80GB | 178.156.194.24 | 2a01:4ff:f0:f237::/64 | 10.0.0.5 | Gateway + App | nginx-lb :80 Β· Backend API prod :3001β3004 Β· API dev :4001 Β· Dozzle :8080 |
| Server 2 β V2-Kiosk | CCX13 Β· 2 vCPU / 8GB Β· 80GB | 178.156.166.189 | 2a01:4ff:f0:afd6::/64 | 10.0.0.4 | App server | Backend API prod :3001β3004 Β· API dev :4001 Β· Dozzle :8080 |
| Server 3 β V3-Kiosk | CCX13 Β· 2 vCPU / 8GB Β· 80GB | 178.156.211.239 | 2a01:4ff:f0:5bf6::/64 | 10.0.0.2 | Data server | Redis master :6379 Β· Postgres 16 master :5432 Β· Dozzle :8080 |
| Server 4 β V4-Kiosk | CCX13 Β· 2 vCPU / 8GB Β· 80GB | 178.156.211.230 | 2a01:4ff:f0:5bf9::/64 | 10.0.0.3 | Tools / DB admin / Data replica / Queue | Redis slave :6379 Β· Redis dev :6380 Β· Postgres slave :5433β5432 Β· RabbitMQ :5672/:15672 Β· RedisInsight :5540 Β· OmniDB :8000 Β· Dozzle :8080 |
| Server 5 β kiosk-gamify-engine | CCX23 Β· 4 vCPU / 16GB Β· 160GB | 178.156.139.212 | β (not in iplist) | 10.0.0.6 | Gamification + Log hub | Gamify pro :7003β7006 Β· Gamify dev :4003 Β· OpenSearch :9200/:9600 Β· Dashboards :5601 Β· Fluent Bit :24224/:2020 |
* vCPU/RAM according to Hetzner's CCX (dedicated vCPU) plan catalog; disk according to the value displayed in the console. Server created 4β9 months ago (V1 the longest).iplistServer 5 is missing in the repo β should be added.
shared_buffers=1GB / effective_cache_size=3GB+ operating system β fit β ieThere isn't much headroom left on the data server; If you add a service to V3, you have to recalculate. OpenSearch heap 1g + gamify 4 instances sit comfortably in 16GB of Server 5. V4 (also CCX13, 8GB) piggybacks7 containerat the same time β Redis slave, Redis dev, Postgres slave, RabbitMQ, RedisInsight, OmniDB, Dozzle β much denser than the "Tools" role initially thought to be light; should be the next candidate to control the total memory limit, similar to V3.
Entire domain*.kioskservice.clubproxy via Cloudflare with SSL modeFlexible:
Client ββHTTPS :443βββΆ Cloudflare ββHTTP :80βββΆ nginx-lb (origin)
listen 443which inconf.d/.X-Forwarded-Proto: httpswhen the client uses HTTPS; nginx forwards this value back to the backendproxy_set_header X-Forwarded-Proto $http_x_forwarded_protoso the app knows the original session is HTTPS (Secure cookies, redirect...).Source:nginx/conf.d/default.conf:46-54mapfallback: if the header is empty, force ithttpsto avoid 403 after login due to Secure cookies.Source:nginx/conf.d/omnidb.conf:6-10
Because every request to the origin comes from Cloudflare's IP, nginx uses modulerealip:
each server block declared15 Cloudflare IP ranges via set_real_ip_fromand
real_ip_header CF-Connecting-IPβ meaning: if request comes from Cloudflare IP range, get header value
CF-Connecting-IPdo$remote_addr. Thanks to that, the log and rate-limit behind see the correct user IP.
Source:nginx/conf.d/default.conf:28-43(repeat in every conf file)
set_real_ip_from 173.245.48.0/20; set_real_ip_from 103.21.244.0/22;
set_real_ip_from 103.22.200.0/22; set_real_ip_from 103.31.4.0/22;
set_real_ip_from 141.101.64.0/18; set_real_ip_from 108.162.192.0/18;
set_real_ip_from 190.93.240.0/20; set_real_ip_from 188.114.96.0/20;
set_real_ip_from 197.234.240.0/22; set_real_ip_from 198.41.128.0/17;
set_real_ip_from 162.158.0.0/15; set_real_ip_from 104.16.0.0/13;
set_real_ip_from 104.24.0.0/14; set_real_ip_from 172.64.0.0/13;
set_real_ip_from 131.0.72.0/22;
real_ip_header CF-Connecting-IP;
| Header | Meaning | nginx forward equals |
|---|---|---|
CF-Connecting-IP | Real IP of the client | proxy_set_header CF-Connecting-IP $http_cf_connecting_ip |
CF-Ray | Request ID on Cloudflare network (debug/trace with CF support) | proxy_set_header CF-Ray $http_cf_ray |
CF-Visitor | JSON {"scheme":"https"}β client-side scheme | proxy_set_header CF-Visitor $http_cf_visitor |
X-Forwarded-Proto | Original scheme (https) | $http_x_forwarded_proto(priority attached CF value) |
X-Real-IP / X-Forwarded-For | Client IP after realip has been restored | $remote_addr / $proxy_add_x_forwarded_for |
The README guides DNS mapping according to the following principles:Each domain has an A record (Proxied) pointing directly to the public IP of the target server- For examplelog-server1 β IP public Server 1, log-server2 β IP public Server 2, redisinsight/pgadmin β IP public Server 4.
Source:README-DOZZLE.md(section "Map Domain in Cloudflare"),README-REDISINSIGHT.md, README-OMNIDB.md.
*.kioskservice.clubmust point back178.156.194.24; (2) V1 is the convergence point of all traffic: gateway + 4 API prod instances + API dev on the same CCX23 machine; (3) blocking layer :80 from IP outside Cloudflare is Hetzner Firewallfirewall-ssh(applies to all 5 servers) β rule appearsMissing 10/15 Cloudflare bandsvs nginx trust list, see risk #13 section 13.
nginx:alpinenginx-lb, restart: alwaysnetwork_mode: hostβ to call internal IPs directly10.0.0.xnot through NAT/bridge./nginx/nginx.conf β /etc/nginx/nginx.conf (ro)./nginx/conf.d β /etc/nginx/conf.d (ro)./nginx/logs β /var/log/nginx./nginx/.htpasswd-redisinsight, ./nginx/.htpasswd-app-logs (ro)
Source:nginx.yml:1-15. Original repo on GitLab namekiosk-deployments; The deploy directory on the server iskiosk_deploy/kiosk_deployments(inferred from the volume name prefix in the backup commands of the README).Host: V1-Kiosk (178.156.194.24 / 10.0.0.5); see section 3.5.
nginx/nginx.conf)| Parameter | Value | Note |
|---|---|---|
worker_processes | auto | = number of CPU cores |
worker_connections | 1024 | Concurrent connection ceiling β workers Γ 1024 (each proxy request costs 2 connections) |
keepalive_timeout | 65s | Client side (Cloudflare) |
sendfile / tcp_nopush / tcp_nodelay | on | |
gzip | on, level 6 | text/css/js/json/xml/svg/font; gzip_vary on, gzip_proxied any |
log_format main | $remote_addr - $remote_user [$time_local] "$request" $status $body_bytes_sent "$http_referer" "$http_user_agent" "$http_x_forwarded_for" | |
Source:nginx/nginx.conf:1-37. Note: not enabled yetkeepaliveto upstream (linekeepalive 32commenting indefault.conf:15) β each request creates a new TCP connection to the backend.
nginx/logs/On the host, each domain has its own pair of files (dev-api-access.log, api-gamify-pro-access.logβ¦).nginx -s reopeninto the container. Install itsetup-nginx-logrotate.sh β /etc/logrotate.d/nginx-kiosk. Source:nginx/logrotate.conf# 1. Edit the file in nginx/conf.d/ (local repo is just a place to edit β must be synchronized to the server)
#2. Check syntax BEFORE reloading:
docker exec nginx-lb nginx -t
#3. Reload without downtime:
docker exec nginx-lb nginx -s reload
| Domain (*.kioskservice.club) | File conf | Destination/Upstream | LB | Specific |
|---|---|---|---|---|
| api | default.conf | upstream backend: 10.0.0.5:3001β3004 + 10.0.0.4:3001β3004 (8 instance) | least_conn | Main production API; buffering on 4k/8Γ4k; timeout 60s |
| dev-api | dev-api.conf | upstream dev-api-backend: 10.0.0.5:4001 + 10.0.0.4:4001 | least_conn | dev environment API |
| api-gamify-pro | api-gamify-pro.conf | upstream: 10.0.0.6:7003β7006 (4 instance) | round-robin | Gamification production (compose scale=4) |
| api-gamify-dev | api-gamify-dev.conf | 10.0.0.6:4003 | β | 1 instance dev |
| app-logs | app-logs.conf | 10.0.0.6:5601 (OpenSearch Dashboards) | β | Basic auth .htpasswd-app-logs; WebSocket; timeout send/read 300s; buffering off |
| log-server1β¦4 | dozzle.conf | 10.0.0.5 / 10.0.0.4 / 10.0.0.2 / 10.0.0.3 β :8080 | β | Dozzle per-server; WebSocket; no basic auth in nginx |
| redisinsight | redisinsight.conf | 10.0.0.3:5540 | β | Basic auth .htpasswd-redisinsight; WebSocket; buffering off |
| pgadmin | omnidb.conf | 10.0.0.3:8000 (OmniDB) | β | WebSocket; forward Cookie/Referer; map fallback X-Forwarded-Proto=https; buffering off |
upstream backend {
least_conn; # choose the server with the least connection
server 10.0.0.5:3001 weight=1 max_fails=3 fail_timeout=30s;
server 10.0.0.5:3002 weight=1 max_fails=3 fail_timeout=30s;
server 10.0.0.5:3003 weight=1 max_fails=3 fail_timeout=30s;
server 10.0.0.5:3004 weight=1 max_fails=3 fail_timeout=30s;
server 10.0.0.4:3001 weight=1 max_fails=3 fail_timeout=30s;
server 10.0.0.4:3002 weight=1 max_fails=3 fail_timeout=30s;
server 10.0.0.4:3003 weight=1 max_fails=3 fail_timeout=30s;
server 10.0.0.4:3004 weight=1 max_fails=3 fail_timeout=30s;
}
least_connSuitable for APIs with irregular processing times β new requests flow to the freest instance, instead of rotating blindly like round-robin.max_fails=3) and removed from the internal pool30 seconds (fail_timeout=30s), then retried. No active health check (requires nginx Plus or external module β noted in file).Source:nginx/conf.d/default.conf:1-16, README-NGINX.mdleast_conn). api-gamify-pro.conf:1-6| Group | Value |
|---|---|
| Timeout | proxy_connect/send/read_timeout 60s(app-logs only: send/read 300s, connect 75s β query Dashboards take a long time) |
| Buffer (API) | proxy_buffering on, proxy_buffer_size 4k, proxy_buffers 8 4k, proxy_busy_buffers_size 8k |
| Buffer (UI tools) | app-logs / redisinsight / omnidb: proxy_buffering off + proxy_request_buffering off (streaming UI) |
| WebSocket | Dozzle, RedisInsight, OmniDB, Dashboards: proxy_http_version 1.1 + Upgrade $http_upgrade + Connection "upgrade" |
| Basic auth | app-logs, redisinsight β create withhtpasswd -c ./nginx/.htpasswd-<name> admin. admin, mount via nginx.yml |
dev-api.conf(simplest file) β changeserver_name, upstream, log path. Required to keep: block of 15 stripsset_real_ip_from + real_ip_header CF-Connecting-IP, setproxy_set_headerstandard (espX-Forwarded-Proto $http_x_forwarded_protobecause of Flexible mode). If a service has WebSocket, there are 3 additional upgrade lines. Afterwardnginx -t β nginx -s reload, and add DNS record (proxied) on Cloudflare.
Each service has a compose file in the root repo, running independently:docker compose -f <file>.yml up -d.
| redis-master | redis-slave | redis-dev | |
|---|---|---|---|
| File | redis-master.yml | redis-slave.yml | redis-dev.yml |
| Host | 10.0.0.2 (Server 3) | 10.0.0.3 (Server 4) | 10.0.0.3 (Server 4) |
| Port (host) | 6379 | 6379 | 6380 β 6379 |
| Replication | β | --replicaof 10.0.0.2 6379 | β |
| maxmemory | 1536mb | 1536mb | 512mb |
| Eviction | allkeys-lruβ If RAM is full, expel the least used key (accept key loss β Redis acts as a cache/session, not the main data source) | ||
| Persistence | AOF (appendonly yes) + RDB snapshot (save 900 1 / 300 10 / 60 10000) | ||
| Security | protected-mode no, bind 0.0.0.0, no passwordβ only safe thanks to the internal network firewall | ||
| Other | memory limit container 1536M; healthcheck redis-cli ping/30s; log json-file 10mΓ3 | limit 512M; volume redis-dev-data | |
Source:redis-master.yml, redis-slave.yml, redis-dev.yml; Host slave confirms passredis-cli info replication (slave0:ip=10.0.0.3,state=online,lag=0). One-way replication masterβslave; repo without Sentinel/Cluster βNo automatic failoverβ slave is for manual read/standby only.
postgres-master.yml): db kiosk, user postgres(plaintext password in yml β not stated here),network_mode: bridge, volume postgres-master-data, init script setup-master.shmount itdocker-entrypoint-initdb.d.archive_mode=on, copy it/var/lib/postgresql/archive/in containers) β supports PITR, but the archive is located on the same machine so it cannot prevent losing the whole server.<host-slave>:5433. hot_standby_feedback=onReduces canceled query errors on the slave, in return can keep bloat on the master.rabbitmq.yml β image rabbitmq:3.13-management, host 10.0.0.3 (Server 4)kiosk, vhost /(plaintext password in yml)rabbitmq-diagnostics ping/10s; volume rabbitmq-datadocker.sockread-only, only currently running container, tail 1000 lines) β accessed vialog-server1β¦4.kioskservice.club.app-logs.kioskservice.club(basic auth in nginx).
fluent-bit/tag_prefix.lua(jawappend_fluentbit_tag) assign 2 fields to each record:docker_log_tag(original tag) andlogstash_prefix. Rules for converting tag β prefix:
docker.kiosk-gaming-backend.devβ normalize lowercase,.β-, remove the prefixdocker-, add suffix-logs β index kiosk-gaming-backend-dev-logs-YYYY.MM.DD.var.lib.docker.containersor/) β combineddocker-tail-logs-*(avoid each container ID being a separate index).misc-logs. Prefix cuts up to 120 characters.docker-daemon.json(apply equalsetup-docker-logs.sh): daemon-wide defaultjson-file, max-size 10m, max-file 3, compressβ each container has a maximum of ~30MB of logs on disk.check-logs.sh, check-dozzle.sh, check-redisinsight.sh. README recommends setting cron0 2 * * *runcheck-logs.sh, the monitoring log is written out/var/log/kiosk-logs-check.log.README-LOG-MANAGEMENT.md): each container ~30MB (10mΓ3), nginx log ~70β100MB/7 days β total ~100β150MB/server, automatically deleted. Note: changedaemon.jsonmust restart Dockerandrestart each container to take effect.vm.max_map_count=262144 (sysctl, persist via /etc/sysctl.d/99-opensearch.conf) β without this OpenSearch will not work.README-OPENSEARCH-SETUP.md Β§1kiosk-logging+ declare logging driver in compose:
networks:
kiosk-logging: { external: true, name: kiosk-logging }
services:
ten-service:
networks: [default, kiosk-logging]
logging:
driver: fluentd
options:
fluentd-address: tcp://fluent-bit:24224 # same host
tag: docker.name-service.env # tag β index name
Service inother machinethen pointtcp://10.0.0.6:24224(requires internal firewall permission; absolutely do not open 24224 to the Internet).README-OPENSEARCH-SETUP.md Β§5kiosk-*-logs-*), field time@timestamp.add_log_tag.luamistakenly mounted by Dockerfolderon host β Fluent Bit error "Is a directory"; renamedtag_prefix.lua. If we meet again:rm -rf fluent-bit/add_log_tag.luaAlreadygit pull. README-OPENSEARCH-SETUP.md Β§8README-OPENSEARCH-PRODUCTION.md): enable security plugin + set admin password via env file without committing; retention by ISM or cron to delete index by day (not available yet β Server 5 disk will gradually fill up); backup index using Snapshot API; The heap keeps 512mβ1g when the host is running other services, only increasing by 2β4g if OpenSearch is the main workload.curl localhost:9200/_cluster/health + _cat/indices + localhost:2020/api/v1/metricsrun directly on 10.0.0.6):
yellowβ normal, not a new problem:77 primary shards allactive, but 70unassignedbecause most indexes are declarednumber_of_replicas: 1while cluster only has1 nodeβ OpenSearch never puts replicas in the same node as primary, so these replica shards are permanently unassignable. No data loss, but also no real redundancy; cluster willnever upgreenuntil a second node is added or the index template is changed backnumber_of_replicas: 0(recommended, because of single-node, current replication only consumes disk/CPU without increasing safety).docker-tail-logs-2026.04.01andkiosk-gaming-backend-dev-logs-2026.04.01still exists (more than 3 months of data), matching the warning above β priority should be given to making ISM/cron delete the old index.output.es.0 β errors:0, retries:0, retries_failed:0, dropped_records:0on more than 25.8 million processed records β no log loss.Directorygamify-engine/contains script + env deploy gamification backend on server 10.0.0.6 (code is in separate GitLab repokiosk_gamification_engine, not in the nginx repo).
| Dev | Production | |
|---|---|---|
| Script | gamify-engine/dev/scrip-deploy-dev | gamify-engine/production/scrip-deploy-pro |
| Directory on the server | /var/www/dev/gamify-be/backend/deployments | /var/www/production/kiosk_gamification_engine/backend/deployments |
| Compose | docker-compose-dev.app.yml | docker-compose.app.ymlwith--scale app=4(β 4 instances :7003β7006 match upstream nginx) |
| Procedure | git pull β reload.env(open nano and paste it manually from env-* file) βdown β up -d --buildβ runnpm run migration:run(more pronpm run seed) | |
| Endpoint public | api-gamify-dev.kioskservice.club β :4003 | api-gamify-pro.kioskservice.club β :7003β7006 |
down β up --buildStops the entire instance during build (no rolling). Fileenv-dev/env-productionis the real env, andscrip-deploy-procontainGitLab access token embedded in the git URLβ this token should be revoked/rotated and converted to deploy key; Absolutely do not copy these files out.
| Port | Service | Host | Expose public? |
|---|---|---|---|
| 80 | nginx-lb (all domains) | 10.0.0.5 (V1-Kiosk) | Yes β via Cloudflare; IPs outside the Cloudflare range are blocked by Hetzner Firewall |
| 3001β3004 | Backend API production (Γ4/server) | 10.0.0.5, 10.0.0.4 | No (only via nginx) |
| 4001 | Backend API dev | 10.0.0.5, 10.0.0.4 | No (via dev-api) |
| 7003β7006 | Gamify production (scale 4) | 10.0.0.6 | No (via api-gamify-pro) |
| 4003 | Gamify dev | 10.0.0.6 | No (via api-gamify-dev) |
| 6379 | Redis master / slave | 10.0.0.2 / 10.0.0.3 | No β but no auth, completely firewall based |
| 6380 | Redis dev | 10.0.0.3 | Are not |
| 5432 | Postgres master | 10.0.0.2 | Are not |
| 5433 | Postgres slave (β5432 container) | 10.0.0.3 | Are not |
| 5672 / 15672 | RabbitMQ AMQP / Management UI | 10.0.0.3 | No (UI not via nginx) |
| 9200 / 9600 | OpenSearch API / metrics | 10.0.0.6 | No β security plugin disabled, firewall based |
| 5601 | OpenSearch Dashboards | 10.0.0.6 | Via app-logs + basic auth |
| 24224 / 2020 | Fluent Bit forward / HTTP monitor | 10.0.0.6 | Are not |
| 8080 | Dozzle (per server) | 10.0.0.5/.4/.2/.3 | Via log-server1β¦4 (no basic auth in nginx) |
| 5540 | RedisInsight | 10.0.0.3 | Via redisinsight + basic auth |
| 8000 | OmniDB | 10.0.0.3 | Via pgadmin (OmniDB's own auth) |
This section is combinedHow to verify whether each floor is alive or dead, going from outside (Cloudflare) to inside (data server) β Use when suspecting a problem, before/after reloading nginx, or periodically checking. Rule: always checkfrom the layer closest to the user first (public domain) and then go deeper into the internal server, to quickly localize which layer is failing.
| Server | Role | SSH in | Main test command |
|---|---|---|---|
| V1-Kiosk 10.0.0.5 | Gateway + App gw app | ssh root@178.156.194.24 |
docker exec nginx-lb nginx -t Β· docker ps (nginx-lb, app :3001-3004, :4001, dozzle) |
| V2-Kiosk 10.0.0.4 | App app | ssh root@178.156.166.189 |
docker ps (app :3001-3004, :4001, dozzle) Β· docker stats --no-stream |
| V3-Kiosk 10.0.0.2 | Data data | ssh root@178.156.211.239 |
redis-cli ping Β· pg_isready Β· see 11.3 |
| V4-Kiosk 10.0.0.3 | Tools obs | ssh root@178.156.211.230 |
docker ps (redisinsight, omnidb, dozzle) Β· see 11.4 |
| kiosk-gamify-engine 10.0.0.6 | Gamify + Log hub data obs | ssh root@178.156.139.212 |
curl -s localhost:9200/_cluster/health?pretty Β· see 11.5 |
# Does the domain respond via Cloudflare (note the cf-ray header = via Cloudflare)
curl -sI https://api.kioskservice.club | grep -i "cf-ray\|http"
# Is the current nginx syntax valid (running ON V1, before reload)
docker exec nginx-lb nginx -t
# Is nginx-lb running, which containers have restarted recently (sign of crash loop)
docker ps --filter name=nginx-lb --format "table {{.Names}}\t{{.Status}}"
cf-rayβ the request went through Cloudflare to the origin and got a response β the error (if any) is in nginx/backend, not DNS/Cloudflare. Do not havecf-rayor timeout β check DNS record / Cloudflare status before suspecting the server.
Latest run results on V1 and V2: bothnginx -tOK (V1, gateway); 4/4 API prod instances (:3001β3004) + 1 dev (:4001) allhealthyon each server; Low CPU/RAM, lots of headroom on both (~90-100MB/container / 8GB RAM). Privatedozzle unhealthyon both β see 11.4.
# Redis master
docker exec -it redis-master redis-cli ping # β PONG
docker exec -it redis-master redis-cli info replication # connected_slaves matches slave number pointing to 10.0.0.2
docker exec -it redis-master redis-cli info memory | grep used_memory_human
# Postgres master
docker exec -it postgres-master pg_isready
docker exec -it postgres-master psql -U postgres -d kiosk -c "SELECT * FROM pg_stat_replication;"
# each return line = 1 slave replicating; "streaming" state is fine, lack of stream β slave lost connection
Actual container name dependscontainer_namedeclared inredis-master.yml / postgres-master.yml- usedocker psto confirm if the above command says "No such container".
Latest run result: RedisPONG, replica online lag=0; Postgres pg_isreadyOK, replica streaming. The server runs exactly 3 containers (postgres-master, redis-master, dozzle). Privatedozzle unhealthy β see 11.4.
V4 runs7 container: postgres-slave, redis-slave, redis-dev, redisinsight, omnidb, dozzle, rabbitmq.
docker ps --format "table {{.Names}}\t{{.Status}}"
# expect all 7 containers "Up"; Notice the STATUS column has "(healthy)" / "(unhealthy)" / nothing (container does not declare healthcheck)
redisinsight.kioskservice.club, basic auth) β used to visually see the live Redis master/slave, key number, realtime memory, instead of typing commandsredis-clihandmade.pgadmin.kioskservice.club) β view Postgres master :5432 and slave :5433 via UI, run test query directly.redisinsightanddozzleon V4 newspaperunhealthybut the service still works normally β the image's default healthcheck is wrong (all 4 Dozzle V1βV4 containers have the same symptoms). Cleaning: fix or turn off healthcheck to eliminate false alarms (risk #11, item 13).
# OpenSearch cluster (runs on the 10.0.0.6 server itself, or via SSH tunnel)
curl -s localhost:9200/_cluster/health?pretty # status: green/yellow/red
curl -s localhost:9200/_cat/indices?v # index list, detect abnormal index swelling
# Fluent Bit β is receiving/pushing logs
curl -s localhost:2020/api/v1/metrics | head -50 # HTTP monitor plugin, :2020
# Gamify backend
docker ps --filter name=app --format "table {{.Names}}\t{{.Status}}" # enough 4 instances :7003-7006 (pro) / 1 instance :4003 (dev)
_cat/indicesshows that the index is continuously increasing without cleaning up β correct risk #3 noted in item 13 (no retention/ISM) β Server 5 disk will gradually fill up, not a new configuration error. Status"yellow"To bestable state of this cluster, not a crashβ detailed explanation in section 8.4 (single-node + replica=1 can never be fully assigned).
Latest run results: V5 runs exactly 8 containers β gamifydeployments_app_1..4 (prod) + deployments_app-dev_1(dev, enough 5healthy, match scale compose item 9),opensearch (healthy), opensearch-dashboards, fluent-bit. For details on OpenSearch/Fluent Bit, see section 8.4.
curl -sI https://api.kioskservice.club- Havecf-ray+ status 2xx/3xx? None β stops at DNS/Cloudflare, no need to SSH to any server.docker exec nginx-lb nginx -t && docker psβ gateway alive, valid syntax?docker logs --tail 100 nginx-lbβ at faultconnect() failedto the backend (points to which app server is dead)?docker psenough 4 API instances + docker stats without OOM container/restart loop.log-server1β¦4) for instant logs, or Dashboards (app-logs) to look up by time/keyword throughout the system.| Job | Command/files |
|---|---|
| Check nginx syntax | docker exec nginx-lb nginx -t |
| Reload nginx (no downtime) | docker exec nginx-lb nginx -s reload |
| Xem log nginx realtime | docker logs -f nginx-lbor files innginx/logs/ |
| Start a stack | docker compose -f <name>.yml up -d |
| Xem log container per-server | Dozzle: log-server1β¦4.kioskservice.club; script ./check-logs.sh |
| Centralized logging | app-logs.kioskservice.club (basic auth) |
| Create new basic auth | htpasswd -c ./nginx/.htpasswd-<name> admin. adminthen mount itnginx.yml |
| Logrotate nginx | setup-nginx-logrotate.sh + nginx/logrotate.conf |
| Docker daemon log limit | setup-docker-logs.sh + docker-daemon.json |
| Deploy gamify | gamify-engine/dev/scrip-deploy-dev Β· gamify-engine/production/scrip-deploy-pro |
| Check Redis replication | docker exec redis-master redis-cli info replication |
| Check Postgres replication | on master:SELECT * FROM pg_stat_replication; |
| Test / force run logrotate nginx | sudo logrotate -d /etc/logrotate.d/nginx-kiosk (dry-run) Β· -f(force running) |
| Create OmniDB admin (first time / forgot password) | docker exec -it omnidb python omnidb-server.py --createsuperuser=admin '<password>'β Passwords with special characters must be enclosed in single quotes |
| Backup settings RedisInsight / OmniDB | tar volume redisinsight-data / omnidb-datavia alpine container (sample commands in README of each tool) |
| Manually clear logs when disk is stressed | docker system df β find ./nginx/logs/ -name "*.log.*" -mtime +7 -delete (see README-LOG-MANAGEMENT.mdbefore prune) |
| # | Risk | Level | Recommended |
|---|---|---|---|
| 1 | Cloudflare Flexible:CFβorigin leg goes plain HTTP over the Internet β data (including tokens/credentials) is not encrypted between the Cloudflare PoP and the server. Hetzner Firewall has limited the input source to :80 so it is difficult to bypass the origin, but cannot change the encryption | Medium | Install Cloudflare Origin CA cert + nginx listen 443 β switch to Full (strict) to encrypt the origin leg (Edge L0βL1 category of DDoS Resilience plan) |
| 2 | V1-Kiosk is the heaviest SPOF system:single-handedly carrying nginx-lb (gateway for 10 domains) + 4 API prod instances + API dev. V1 death = ENTIRE system loss even though V2βV5 are still alive | High | Build second nginx-lb (switch plan chooses V4 because failure-domain is better β seeconversion planstep 1A) + Cloudflare 2nd LB/A record to failover origin |
| 3 | OpenSearch disables security plugin(no TLS/auth above :9200); The only protection is basic auth in nginx for Dashboards | High | FollowREADME-OPENSEARCH-PRODUCTION.md: enable security, set admin password, remove DISABLE_* variables |
| 4 | Redis without password, protected-mode no, bind 0.0.0.0 β completely firewall dependent security (confirmedfirewall-sshis the only firewall in the project, there is no open rule :6379 β default-deny from the Internet) but no real authentication at the application layer | High | Morerequirepass/masterauthto not be 100% dependent on a single firewall layer (in case the rule is mistakenly edited later) |
| 5 | Secret plaintext in repo:Postgres/RabbitMQ/replication password in yml, real env, GitLab token in deploy script | High | Revoke/rotate GitLab tokens immediately; move secret to .env without commit / secret manager; Minimum private repo guaranteed |
| 6 | No auto-failoverfor Redis and Postgres (only one-way replication, manual promotion) | Medium | Write runbook promote slave; Consider Sentinel (Redis) when there are β₯3 nodes |
| 7 | Dozzle does not have basic auth in nginxβ log-server1β¦4 exposes log container (Dozzle does not auth by default) | Medium | Moreauth_basicenterdozzle.conflike app-logs, or enable Dozzle auth |
| 8 | Deploy gamify has downtime(down β build β up) and paste .env with nano manually, easy to make mistakes | Medium | Build first then upgrade; or rolling update each instance behind nginx |
| 9 | WAL archive is located on the same master machine; Off-site backup not found in repo | Medium | Add pg_dump/basebackup to periodically push to external storage |
| 10 | Upstream has not enabled keepalive; Passive health checks can return errors for several requests before eliminating a failed instance | Short | Turn onkeepalive 32 + proxy_http_version 1.1to upstream; Consider retryingproxy_next_upstream |
| 11 | Fake healthcheck on monitoring container:all 4dozzle(V1βV4) andredisinsight(V4) newspaperunhealthyeven though the service works normally β the image's default healthcheck is wrong; Consequently, STATUS column ofdocker psLoss of warning value | Short | Edit or disable healthcheck indozzle.yml/redisinsight.ymltounhealthyFrom now on it means real crash (no need to restart). |
| 12 | OpenSearch cluster permanentlyyellow(70/147 unassigned shards) because the index is usednumber_of_replicas: 1There is only 1 node on the cluster β replicas can never be assigned, it only consumes more disk/CPU without real redundancy | Short | Change the default index template backnumber_of_replicas: 0for single-node (nothing lost because the replica is currently useless); only return to rep=1 if a 2nd OpenSearch node is added |
| 13 | Rule TCP :80 on Hetzner Firewall (firewall-ssh, applies to all 5 servers) only whitelist 5/15 Cloudflare bandsβ missing 10 bands that nginx still trustsset_real_ip_from. Real requests from Cloudflare PoP that are within 10 missing ranges will be DROP by Hetzner Firewall before reaching nginx, causing intermittent connection loss that is difficult to reproduce (depending on Cloudflare routing). Rule :443 is enough to get 15/15 even if nginx doesn'tlisten 443 | High | Add all 15 Cloudflare bands to the :80 rule in Hetzner Console, compare exactly withset_real_ip_from in default.conf |
| 14 | SSH (:22) is open to the entire Internet(Any IPv4 + Any IPv6, no source limit) on the same firewall for all 5 servers β security based only on key-based auth, no network-level restriction layer | Medium | Limit rule :22 source to specific admin/VPN IP range in Hetzner Console if available; If you don't have a fixed IP, consider your own bastion/VPN |
nginx/nginx/ # infrastructure repo (infra/ops), does NOT contain app code
# original GitLab name: kiosk-deployments (dustin.le)
βββ nginx.yml # compose container nginx-lb
βββ nginx/
β βββ nginx.conf # core configuration
β βββ conf.d/ # 8 conf files β 10 domains (section 5)
β βββ logrotate.conf
βββ redis-master.yml / redis-slave.yml / redis-dev.yml
βββ postgres-master.yml / postgres-slave.yml
βββ rabbitmq.yml
βββ opensearch.yml # OpenSearch + Dashboards + Fluent Bit
βββ fluent-bit/ # fluent-bit.conf + tag_prefix.lua
βββ dozzle.yml / redisinsight.yml / omnidb.yml
βββ gamify-engine/ # script + env deploy (dev/, production/)
βββ setup-*.sh, check-*.sh # operation script
βββ docker-daemon.json # limit Docker logs daemon-wide
βββ iplist # IP public 4 server
βββ env, env-dev # β real env contains secret
βββ README-*.md # documents each component
| README | Memorable key content |
|---|---|
README-NGINX.md | 4 LB methods, passive health check (max_fails/fail_timeout), additional SSL form :443, troubleshooting container/backend |
README-LOG-MANAGEMENT.md | 2 rotation layers (daemon.json + logrotate), expected capacity ~100β150MB/server, cron monitoring at 2am, manual cleanup command; To change daemon.json, you must restart Docker + container |
README-OPENSEARCH-SETUP.md | Setup copy-paste: sysctl vm.max_map_count, network kiosk-logging, other service ways to send logs (fluentd driver + tag), common problem table (including Lua "Is a directory" service) |
README-OPENSEARCH-PRODUCTION.md | Checklist go-live: enable security plugin, heap by host RAM, backup Snapshot API, retention ISM βThese items are NOT currently available in production |
README-DOZZLE.md | Dozzle per-server architecture; Instructions for mapping DNS domain β destination public server IP (based on section 3.5); The README itself also recommends adding basic auth (risk #7) |
README-REDISINSIGHT.md | Connect Redis master/slave, scriptsetup-redisinsight-auth.sh, backup volume Β· β contains credential plaintext |
README-OMNIDB.md | Required to create superuser for the first time (--createsuperuser, password wrapped in single quote), connection PG master :5432 / slave :5433, backup volume Β· β contains credential plaintext |
README-RABBITMQ.md | Connection string AMQP + Management UI Β· β contains credential plaintext; the GitLab boilerplate reveals the original repo namekiosk-deployments |
Documents created from repo/Users/nhim/dev/2026/kiosk/nginx+ Direct survey of 5 servers, updated 2026-07-04.
Passwords/tokens in configuration files are intentionally omitted. Blueprint diagram:system-diagram.htmlΒ· Risk handling plan section 13:comparison-and-migration-plan.htmlΒ· target architecture:target-system.html.