Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
96 changes: 96 additions & 0 deletions README.docker.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,96 @@
# Running CrowdStream with Docker

Two top-level Compose files bring up the app plus its core dependencies:

| File | Purpose |
| --- | --- |
| `docker-compose.local.yml` | Local development — backend & frontend hot reload |
| `docker-compose.prod.yml` | Production — compiled backend, static frontend built into nginx |

Each stack runs **backend + frontend + a 6-node Redis cluster + MongoDB + an nginx ingress**.
The NAT-traversal layers (Coturn, Envoy, HAProxy) are **not** included here — start those from
`infra/` as before when you need TURN/relay.

```
browser ──▶ nginx :80 ──/──────────▶ frontend (local: Vite :5173 · prod: static in nginx)
──/backend/──▶ backend :3000 (/backend/ prefix stripped → /api/v1, /db)
──/socket.io/▶ backend :3000 (WebSocket)
backend :3000 ──▶ mongo 127.0.0.1:27017 + redis cluster 127.0.0.1:6379-6384
```

> **Networking:** every service uses `network_mode: host`, matching the existing `infra/` setup.
> This is required so the Redis cluster can advertise `127.0.0.1:<port>` and mediasoup can bind
> the WebRTC/recording UDP ports directly. **Host networking is a Linux feature** — on Docker
> Desktop for macOS/Windows the port mapping semantics differ and this stack is not supported as-is.

## 1. Prerequisites

- Docker Engine + Compose v2 on **Linux**.
- The RTC port range (`RTC_MIN_PORT`–`RTC_MAX_PORT`, default `40000-40100` UDP/TCP) free on the host.

## 2. Environment

The backend requires ~25 env vars (all mandatory — it exits on any missing one). Seed them from the
template, then edit the secrets:

```bash
cp deploy/env.docker.template backend/.env
# edit backend/.env: set JWT_SECRET, TURN_SECRET, DATABASE_NAME, etc.
```

You don't need to get the infra endpoints right in `backend/.env` — the Compose files force-override
`MONGO_DB_URL`, `REDIS_HOST`/`REDIS_PORT*`, `PORT`, `INSTANCE_ID`, and the `*_IP` vars so the
containerized Mongo/Redis are always used.

For **local** frontend dev, Vite reads `frontend/.env` at runtime (it's bind-mounted) — put any
`VITE_*` values there. For **production**, `VITE_*` are baked in at build time; provide them via your
shell or a root `.env` that `docker compose` reads (see the `args:` in `docker-compose.prod.yml`).

## 3. Local development

```bash
docker compose -f docker-compose.local.yml up --build
```

- Waits for Mongo to be healthy and `redis-init` to form the cluster, then starts the backend.
- Open **http://localhost/** — the nginx ingress serves the Vite app and proxies the API/socket.
- Editing `backend/src/**` → `nodemon` restart; editing `frontend/src/**` → Vite HMR.

Health checks:

```bash
curl http://localhost/backend/db/__ping # -> PING OK

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P3: The documented output for /backend/db/__ping is wrong. After nginx strips the /backend/ prefix the request hits dbReadinessCheck, which returns JSON {success:true, message:"Database Up"}, not the literal PING OK shown. Update the comment so a user following the doc isn't confused by the different output.

Prompt for AI agents
Check if this issue is valid — if so, understand the root cause and fix it. At README.docker.md, line 62:

<comment>The documented output for `/backend/db/__ping` is wrong. After nginx strips the `/backend/` prefix the request hits `dbReadinessCheck`, which returns JSON `{success:true, message:"Database Up"}`, not the literal `PING OK` shown. Update the comment so a user following the doc isn't confused by the different output.</comment>

<file context>
@@ -0,0 +1,96 @@
+Health checks:
+
+```bash
+curl http://localhost/backend/db/__ping   # -> PING OK
+curl http://localhost/backend/health      # -> HEALTH OK
+```
</file context>
Suggested change
curl http://localhost/backend/db/__ping # -> PING OK
curl http://localhost/backend/db/__ping # -> {"success":true,"message":"Database Up"}

curl http://localhost/backend/health # -> HEALTH OK
```

## 4. Production

```bash
cp deploy/env.docker.template backend/.env # set real secrets
export HOST_PUBLIC_IP=<your server public IP> # so WebRTC reaches remote clients
docker compose -f docker-compose.prod.yml up --build -d
```

- Backend runs the compiled `node dist/index.js`.
- The frontend container is nginx serving the built SPA **and** proxying `/backend` + `/socket.io`
to the backend — it is the :80 ingress (no separate nginx service in prod).
- Verify the runtime asset copied by the build (the Lua rate-limit script):

```bash
docker compose -f docker-compose.prod.yml exec backend ls dist/scripts # -> rateLimit.lua
```

## 5. Notes & caveats

- **Redis cluster** is created once by the `redis-init` one-shot service; it's idempotent (re-runs
detect `cluster_state:ok` and exit). Data persists in the `redis-N-data` named volumes. Node configs
are reused read-only from `infra/redis/redis-N/redis.conf`. The image is `redis:7-alpine` (Redis ≥7 is
required for the sharded pub/sub the app uses; bump the tag if you want 8.x).
- **MongoDB** binds to `127.0.0.1` only (host networking) with data in the `mongo-data` volume. It has
no auth by default — fine because it isn't reachable off-host, but enable auth for a hardened deploy.
- **Recordings** are written relative to the working dir (`recording<room><ts>.mp4` and
`src/recording/<room>.sdp`). In local dev they land on the host via the bind mount. In production they
live inside the container and are **ephemeral** — bind-mount a host path onto `/app` (or change the
output path in `backend/src/recording/`) if you need them to persist.
- **TURN/relay:** for connectivity across restrictive NATs, run Coturn/Envoy/HAProxy from `infra/` and
point the frontend `VITE_TURN_*` values at them.
15 changes: 15 additions & 0 deletions backend/.dockerignore
Original file line number Diff line number Diff line change
@@ -0,0 +1,15 @@
node_modules
dist
logs
*.log
.env
.env.*
.git
.gitignore
Dockerfile
.dockerignore
Makefile
README.md
*.pem
recording*.mp4
src/recording/*.sdp
46 changes: 38 additions & 8 deletions backend/Dockerfile
Original file line number Diff line number Diff line change
@@ -1,20 +1,50 @@
FROM node:20-slim
# syntax=docker/dockerfile:1
#
# Multi-stage backend image for CrowdStream (mediasoup SFU, Node/TS).
# - target `dev` : nodemon + ts-node hot reload (source bind-mounted by compose)
# - target `prod` : TypeScript compiled to dist/, lean runtime, no build tools
#
# Build deps (python3/build-essential/pkg-config) are required to compile the
# mediasoup native worker and ffmpeg-static during `npm install`.

RUN apt-get update && apt-get install -y \
# ---- base: toolchain shared by dev and build ----
FROM node:20-slim AS base
RUN apt-get update && apt-get install -y --no-install-recommends \
python3 \
build-essential \
pkg-config \

Check warning on line 15 in backend/Dockerfile

View check run for this annotation

SonarQubeCloud / SonarCloud Code Analysis

Sort these package names alphanumerically.

See more on https://sonarcloud.io/project/issues?id=Harxhit_CrowdStream&issues=AaBD-bYMwXfn8q37ZfeI&open=AaBD-bYMwXfn8q37ZfeI&pullRequest=87
&& rm -rf /var/lib/apt/lists/*
WORKDIR /app

WORKDIR /src

# ---- dev: hot reload; real source comes from a bind mount in compose ----
FROM base AS dev
ENV NODE_ENV=development
COPY package*.json ./

RUN npm install
COPY . .
EXPOSE 3000
CMD ["npm", "run", "dev"]

# ---- build: compile TS -> dist, then drop dev dependencies ----
FROM base AS build
ENV NODE_ENV=production
COPY package*.json ./
RUN npm ci

Check warning on line 32 in backend/Dockerfile

View check run for this annotation

SonarQubeCloud / SonarCloud Code Analysis

Omitting "--ignore-scripts" allows lifecycle scripts to run during package installation.

See more on https://sonarcloud.io/project/issues?id=Harxhit_CrowdStream&issues=AaBD-bYMwXfn8q37ZfeJ&open=AaBD-bYMwXfn8q37ZfeJ&pullRequest=87

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P1: The production build fails at npm run build because NODE_ENV=production makes npm ci omit the typescript dev dependency. Install build dependencies explicitly in this stage, then prune them after compilation.

Prompt for AI agents
Check if this issue is valid — if so, understand the root cause and fix it. At backend/Dockerfile, line 32:

<comment>The production build fails at `npm run build` because `NODE_ENV=production` makes `npm ci` omit the `typescript` dev dependency. Install build dependencies explicitly in this stage, then prune them after compilation.</comment>

<file context>
@@ -1,20 +1,50 @@
+FROM base AS build
+ENV NODE_ENV=production
+COPY package*.json ./
+RUN npm ci
 COPY . .
+# `npm run build` = tsc + `cp -r src/scripts dist/scripts` (rateLimit.lua is read
</file context>
Suggested change
RUN npm ci
RUN npm ci --include=dev

COPY . .
# `npm run build` = tsc + `cp -r src/scripts dist/scripts` (rateLimit.lua is read
# at runtime via __dirname and is NOT emitted by tsc). Then prune dev deps while
# keeping the already-built mediasoup worker + ffmpeg-static binaries.
RUN npm run build \
&& npm prune --omit=dev

# ---- prod: minimal runtime image ----
FROM node:20-slim AS prod

Check warning on line 41 in backend/Dockerfile

View check run for this annotation

SonarQubeCloud / SonarCloud Code Analysis

The "node" image runs with "root" as the default user. Make sure it is safe here.

See more on https://sonarcloud.io/project/issues?id=Harxhit_CrowdStream&issues=AaBD-bYMwXfn8q37ZfeK&open=AaBD-bYMwXfn8q37ZfeK&pullRequest=87
ENV NODE_ENV=production
WORKDIR /app
# recording/sdp.ts writes `src/recording/<room>.sdp` relative to the working dir.
RUN mkdir -p src/recording
COPY --from=build /app/node_modules ./node_modules
COPY --from=build /app/dist ./dist
COPY --from=build /app/package.json ./package.json
EXPOSE 3000

# Start app
CMD ["npm", "run","dev"]
CMD ["node", "dist/index.js"]
4 changes: 3 additions & 1 deletion backend/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -4,7 +4,9 @@
"description": "> Backend system for scalable HTTP Live Streaming (HLS) using FFmpeg, built with Node.js and TypeScript.",
"main": "index.js",
"scripts": {
"dev": "nodemon --watch src --ext ts,d.ts --exec ts-node --files src/index.ts"
"dev": "nodemon --watch src --ext ts,d.ts --exec ts-node --files src/index.ts",
"build": "tsc -p tsconfig.json && cp -r src/scripts dist/scripts",

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P3: Running npm run build twice nests the copy: once dist/scripts exists, cp -r src/scripts dist/scripts creates dist/scripts/scripts/rateLimit.lua instead of refreshing the file in place. The runtime still reads dist/scripts/rateLimit.lua so nothing breaks functionally, but the build is not idempotent and accumulates stale nested copies with each rebuild. Copy the directory contents instead, e.g. mkdir -p dist/scripts && cp -r src/scripts/. dist/scripts/.

Prompt for AI agents
Check if this issue is valid — if so, understand the root cause and fix it. At backend/package.json, line 8:

<comment>Running `npm run build` twice nests the copy: once `dist/scripts` exists, `cp -r src/scripts dist/scripts` creates `dist/scripts/scripts/rateLimit.lua` instead of refreshing the file in place. The runtime still reads `dist/scripts/rateLimit.lua` so nothing breaks functionally, but the build is not idempotent and accumulates stale nested copies with each rebuild. Copy the directory contents instead, e.g. `mkdir -p dist/scripts && cp -r src/scripts/. dist/scripts/`.</comment>

<file context>
@@ -4,7 +4,9 @@
   "scripts": {
-    "dev": "nodemon --watch src --ext ts,d.ts --exec ts-node --files src/index.ts"
+    "dev": "nodemon --watch src --ext ts,d.ts --exec ts-node --files src/index.ts",
+    "build": "tsc -p tsconfig.json && cp -r src/scripts dist/scripts",
+    "start": "node dist/index.js"
   },
</file context>

"start": "node dist/index.js"
},
"author": "Harshit Singh Parihar",
"license": "ISC",
Expand Down
4 changes: 2 additions & 2 deletions backend/src/utils/socket.util.ts
Original file line number Diff line number Diff line change
Expand Up @@ -140,8 +140,8 @@ io.on("connection", (socket) => {
}
})

socket.on("disconnect", async (reason) => {
logger.info(`User disconnected ${socket.id} beacuse of ${reason}`)
socket.on("disconnect", async (reason, details) => {
logger.error(`User disconnected, ${socket.id} reason: ${reason} details: ${details}`)
handleDisconnect(socket)
await stopFfmpegRecording(socket.id)
Comment on lines +143 to 146

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2: Disconnect is a normal Socket.IO lifecycle event (tab close, navigation, network blip), not an error. Logging every disconnect with logger.error fills logs/error.log (the winston File transport at level 'error') and drowns genuine failures. Keep the previous logger.info level if you want these logs, or use logger.warn at most.

Prompt for AI agents
Check if this issue is valid — if so, understand the root cause and fix it. At backend/src/utils/socket.util.ts, line 143:

<comment>Disconnect is a normal Socket.IO lifecycle event (tab close, navigation, network blip), not an error. Logging every disconnect with logger.error fills logs/error.log (the winston File transport at level 'error') and drowns genuine failures. Keep the previous logger.info level if you want these logs, or use logger.warn at most.</comment>

<file context>
@@ -140,8 +140,8 @@ io.on("connection", (socket) => {
 
-  socket.on("disconnect", async (reason) => {
-    logger.info(`User disconnected ${socket.id} beacuse of ${reason}`)
+  socket.on("disconnect", async (reason, details) => {
+    logger.error(`User disconnected, ${socket.id} reason: ${reason} details: ${details}`)
     handleDisconnect(socket)
</file context>
Suggested change
socket.on("disconnect", async (reason, details) => {
logger.error(`User disconnected, ${socket.id} reason: ${reason} details: ${details}`)
handleDisconnect(socket)
await stopFfmpegRecording(socket.id)
socket.on("disconnect", async (reason, details) => {
logger.info(`User disconnected, ${socket.id} reason: ${reason} details: ${details}`)
handleDisconnect(socket)
await stopFfmpegRecording(socket.id)
});

});
Expand Down
66 changes: 66 additions & 0 deletions deploy/env.docker.template
Original file line number Diff line number Diff line change
@@ -0,0 +1,66 @@
# =============================================================================
# CrowdStream — Docker environment reference
#
# Copy the BACKEND section into backend/.env (the compose files load it via
# env_file). The compose files then FORCE-OVERRIDE the infra endpoints
# (MONGO_DB_URL, REDIS_HOST/PORT*, PORT, INSTANCE_ID, *_IP, NODE_ENV) via their
# own `environment:` blocks, so the containerized Mongo/Redis are always used
# regardless of what you put here — you only really need to set the secrets and
# tuning values below.
#
# cp deploy/env.docker.template backend/.env # then edit the secrets
#
# The FRONTEND section is used two ways:
# - local : Vite reads frontend/.env at runtime (bind-mounted) — put them there.
# - prod : passed as build args (Vite inlines them at build time). Provide
# them in your shell or a root .env consumed by docker compose.
# =============================================================================

# ------------------------------- BACKEND -------------------------------------
NODE_ENV=development
PORT=3000
INSTANCE_ID=node-1

# --- Mongo & Redis (overridden by compose to point at the containers) ---
MONGO_DB_URL=mongodb://127.0.0.1:27017
DATABASE_NAME=crowdstream
REDIS_HOST=127.0.0.1
REDIS_PORT1=6379
REDIS_PORT2=6380
REDIS_PORT3=6381
# Used by infra/redis/init-cluster.sh (the compose init uses fixed ports):
REDIS_PORT4=6382
REDIS_PORT5=6383
REDIS_PORT6=6384

# --- mediasoup / WebRTC ---
# With host networking these bind directly to the host. For real remote clients
# set PUBLIC_IP / ANNOUCED_IP to the server's public IP and open the RTC range.
PUBLIC_IP=127.0.0.1
ANNOUCED_IP=127.0.0.1
RECORDING_IP=127.0.0.1
RTC_MIN_PORT=40000
RTC_MAX_PORT=40100
MEDIASOUP_WORKER=2
MEDIASOUP_MAX_WORKERS=4
WORKER_THRESHOLD=500
VIDEO_PORT=5004
AUDIO_PORT=5006

# --- HTTP / auth / TURN secrets (CHANGE THESE) ---
CORS_ORIGINS=*
JWT_SECRET=change-me-to-a-long-random-string
ACCESS_TOKEN_EXPIRY=1d
TURN_SECRET=change-me-turn-shared-secret
TURN_TTL=86400

# ------------------------------- FRONTEND ------------------------------------
# API/signaling are same-origin relative paths in the app, so these are mostly
# for TURN. Leave TURN blank to rely on STUN only.

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2: The comment claims leaving TURN blank falls back to STUN only, but no STUN server is configured anywhere and the frontend builds its iceServers purely from the VITE_TURN_* URL vars, which are empty when blank. With the template values, remote clients behind NAT have no STUN or TURN, so only host candidates are gathered and they will not connect. Either set up a STUN server or correct the comment to state that blank TURN means no NAT traversal.

Prompt for AI agents
Check if this issue is valid — if so, understand the root cause and fix it. At deploy/env.docker.template, line 59:

<comment>The comment claims leaving TURN blank falls back to STUN only, but no STUN server is configured anywhere and the frontend builds its iceServers purely from the VITE_TURN_* URL vars, which are empty when blank. With the template values, remote clients behind NAT have no STUN or TURN, so only host candidates are gathered and they will not connect. Either set up a STUN server or correct the comment to state that blank TURN means no NAT traversal.</comment>

<file context>
@@ -0,0 +1,66 @@
+
+# ------------------------------- FRONTEND ------------------------------------
+# API/signaling are same-origin relative paths in the app, so these are mostly
+# for TURN. Leave TURN blank to rely on STUN only.
+VITE_API_URL=/backend/api/v1
+VITE_SIGNALING_URL=/
</file context>
Suggested change
# for TURN. Leave TURN blank to rely on STUN only.
# for TURN. Blank TURN leaves NO relay/STUN configured — remote NAT'd clients will not connect.

VITE_API_URL=/backend/api/v1
VITE_SIGNALING_URL=/
VITE_TURN_USERNAME=
VITE_TURN_CREDENTIAL=
VITE_TURN_UDP_URL=
VITE_TURN_TCP_URL=
VITE_TURNS_TCP_URL=
41 changes: 41 additions & 0 deletions deploy/nginx/local.conf
Original file line number Diff line number Diff line change
@@ -0,0 +1,41 @@
# Local ingress: single same-origin entrypoint (:80) for the dev stack.
# Runs with host networking, so upstreams are on 127.0.0.1.
# / -> Vite dev server (:5173), WebSocket upgrade enables HMR
# /backend/ -> backend (:3000), prefix stripped by the trailing slash
# /socket.io/ -> backend (:3000) Socket.IO
server {
listen 80;
server_name _;

location / {
proxy_pass http://127.0.0.1:5173;
proxy_http_version 1.1;
proxy_set_header Upgrade $http_upgrade;
proxy_set_header Connection "upgrade";

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2: The location / and location /socket.io/ blocks force Connection: upgrade on every request, including plain HTTP and Socket.IO long-polling requests that never send an Upgrade header. This is the classic nginx WebSocket anti-pattern: nginx can no longer reuse the upstream keep-alive connection, so it opens a new TCP connection to Vite/backend for each request and emits a bogus Connection: upgrade header on non-upgrade traffic. Use a map so upgrade is only negotiated when the client actually requests it.

Prompt for AI agents
Check if this issue is valid — if so, understand the root cause and fix it. At deploy/nginx/local.conf, line 14:

<comment>The `location /` and `location /socket.io/` blocks force `Connection: upgrade` on every request, including plain HTTP and Socket.IO long-polling requests that never send an `Upgrade` header. This is the classic nginx WebSocket anti-pattern: nginx can no longer reuse the upstream keep-alive connection, so it opens a new TCP connection to Vite/backend for each request and emits a bogus `Connection: upgrade` header on non-upgrade traffic. Use a `map` so upgrade is only negotiated when the client actually requests it.</comment>

<file context>
@@ -0,0 +1,41 @@
+        proxy_pass http://127.0.0.1:5173;
+        proxy_http_version 1.1;
+        proxy_set_header Upgrade $http_upgrade;
+        proxy_set_header Connection "upgrade";
+        proxy_set_header Host $host;
+        proxy_set_header X-Real-IP $remote_addr;
</file context>

proxy_set_header Host $host;
proxy_set_header X-Real-IP $remote_addr;
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
proxy_set_header X-Forwarded-Proto $scheme;
}

location /backend/ {
proxy_pass http://127.0.0.1:3000/;
proxy_set_header Host $host;
proxy_set_header X-Real-IP $remote_addr;
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
proxy_set_header X-Forwarded-Proto $scheme;
}

location /socket.io/ {
proxy_pass http://127.0.0.1:3000;
proxy_http_version 1.1;
proxy_set_header Upgrade $http_upgrade;
proxy_set_header Connection "upgrade";
proxy_set_header Host $host;
proxy_set_header X-Real-IP $remote_addr;
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
proxy_set_header X-Forwarded-Proto $scheme;
proxy_read_timeout 3600s;
proxy_send_timeout 3600s;
}
}
33 changes: 33 additions & 0 deletions deploy/redis/init-cluster.sh
Original file line number Diff line number Diff line change
@@ -0,0 +1,33 @@
#!/bin/sh
# Initialize the 6-node Redis cluster used by the backend for sharded pub/sub.
#
# Runs as a one-shot, host-networked init container (see the compose files), so
# the nodes are reachable on 127.0.0.1. Idempotent: exits early if the cluster
# is already formed, otherwise creates it (3 masters + 3 replicas).
set -e

HOST="127.0.0.1"
PORTS="6379 6380 6381 6382 6383 6384"

echo "Waiting for Redis nodes..."
for PORT in $PORTS; do
echo " waiting for $HOST:$PORT ..."
until redis-cli -h "$HOST" -p "$PORT" ping >/dev/null 2>&1; do
sleep 1
done
echo " $HOST:$PORT is up."
done

if redis-cli -h "$HOST" -p 6379 cluster info 2>/dev/null | grep -q "cluster_state:ok"; then

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2: When a persisted topology has slot coverage but is missing nodes or replicas, this check exits successfully without creating or repairing the promised 3-master/3-replica cluster. Validate cluster nodes or cluster slots and require six connected nodes, three masters, and three replicas before returning.

Prompt for AI agents
Check if this issue is valid — if so, understand the root cause and fix it. At deploy/redis/init-cluster.sh, line 21:

<comment>When a persisted topology has slot coverage but is missing nodes or replicas, this check exits successfully without creating or repairing the promised 3-master/3-replica cluster. Validate `cluster nodes` or `cluster slots` and require six connected nodes, three masters, and three replicas before returning.</comment>

<file context>
@@ -0,0 +1,33 @@
+    echo "  $HOST:$PORT is up."
+done
+
+if redis-cli -h "$HOST" -p 6379 cluster info 2>/dev/null | grep -q "cluster_state:ok"; then
+    echo "Redis cluster already initialized."
+    exit 0
</file context>

echo "Redis cluster already initialized."
exit 0
fi

echo "Creating Redis cluster (3 masters + 3 replicas)..."
redis-cli --cluster create \
"$HOST:6379" "$HOST:6380" "$HOST:6381" \
"$HOST:6382" "$HOST:6383" "$HOST:6384" \
--cluster-replicas 1 \
--cluster-yes

echo "Redis cluster created successfully."
Loading