feat: DO-1 full-stack Docker Compose dev environment (#218) - #231
Conversation
📝 WalkthroughWalkthroughThis PR establishes a complete Docker Compose development environment for DineOS. It adds a root-level ChangesFull-Stack Docker Compose Development Environment
Estimated code review effort🎯 3 (Moderate) | ⏱️ ~25 minutes Poem
🚥 Pre-merge checks | ✅ 5✅ Passed checks (5 passed)
✏️ Tip: You can configure your own custom pre-merge checks in the settings. ✨ Finishing Touches📝 Generate docstrings
🧪 Generate unit tests (beta)
Comment |
There was a problem hiding this comment.
Actionable comments posted: 4
🧹 Nitpick comments (1)
nginx/nginx.conf (1)
1-37: ⚡ Quick winAvoid maintaining two parallel Nginx routing configs.
This file duplicates the active config path used by Compose (
infra/nginx/...), which creates drift risk.Consider removing this file or adding a clear note in-file that it is non-canonical.
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the rest with a brief reason, keep changes minimal, and validate. In `@nginx/nginx.conf` around lines 1 - 37, This nginx.conf duplicates the canonical Compose config and risks drift; either delete this duplicate file or add a clear top-of-file comment stating it is non-canonical and pointing to the canonical path, and ensure the server/map blocks (e.g., the map $http_upgrade/$connection_upgrade block and the server { ... } with locations /api/, /hubs/, /uploads/, / }) are not relied upon by automation — update any README or CI that references this file to point to the canonical infra/nginx configuration if present.
🤖 Prompt for all review comments with AI agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.
Inline comments:
In @.env.example:
- Around line 19-33: The .env.example uses fixed ports in URL settings while
exposing NGINX_HTTP_PORT and KEYCLOAK_PORT variables (NGINX_HTTP_PORT,
KEYCLOAK_PORT), so changing those ports breaks URL-based settings; update any
URL default env vars that reference hard-coded :8080 or localhost:80 to build
their defaults from the port vars (e.g., derive KEYCLOAK URLs from KEYCLOAK_PORT
and frontend/API gateway URLs from NGINX_HTTP_PORT) so all URL envs reference
${KEYCLOAK_PORT}, ${NGINX_HTTP_PORT} (or equivalent env variables) instead of
fixed ports and document this behavior in the file header.
In `@docs/AI-Development-Log.md`:
- Around line 148-152: The table rows added (the rows beginning with "2026-05-26
| Endriti | Claude Code | Issue `#218` ..." ) contain 9 pipe-separated cells while
the table header has 8 columns, causing render breakage; fix each offending row
by merging two adjacent cells (suggest merging the short "Purpose" column with
the long "Created …" description into a single cell) so each row has exactly 8
separators (7 pipes), and ensure the combined cell content preserves the
original summary and details; update the specific rows that span lines 148–152
accordingly.
In `@frontend/Dockerfile`:
- Around line 20-31: The runner stage currently runs the Next.js app as root;
update the runner stage (the stage starting with "FROM node:20-alpine AS
runner", WORKDIR /app, CMD ["npm","run","start"]) to create or use a non-root
user (e.g., addgroup/adduser or use the node user), chown the copied application
files in /app (public, .next, node_modules, package.json) to that user, and add
a USER directive so the container process runs as the non-root user.
In `@scripts/devops/verify-compose.ps1`:
- Around line 37-44: The parsing loop for $psRaw/$services is fragile because
docker compose JSON can be a single array or newline-delimited objects; update
the logic in verify-compose.ps1 to first attempt ConvertFrom-Json on the entire
$psRaw output and, if that yields an array, add each item to $services,
otherwise fall back to iterating lines and ConvertFrom-Json per line; remove the
empty catch block and surface parse errors (e.g., Write-Error or Write-Host) so
malformed JSON doesn't silently fail; adjust references to $services so
subsequent code that reads $s.State, $s.Service, and $s.Health works
consistently regardless of the compose output shape.
---
Nitpick comments:
In `@nginx/nginx.conf`:
- Around line 1-37: This nginx.conf duplicates the canonical Compose config and
risks drift; either delete this duplicate file or add a clear top-of-file
comment stating it is non-canonical and pointing to the canonical path, and
ensure the server/map blocks (e.g., the map $http_upgrade/$connection_upgrade
block and the server { ... } with locations /api/, /hubs/, /uploads/, / }) are
not relied upon by automation — update any README or CI that references this
file to point to the canonical infra/nginx configuration if present.
🪄 Autofix (Beta)
Fix all unresolved CodeRabbit comments on this PR:
- Push a commit to this branch (recommended)
- Create a new PR with the fixes
ℹ️ Review info
⚙️ Run configuration
Configuration used: defaults
Review profile: CHILL
Plan: Pro Plus
Run ID: 477a4792-d0f7-4cfd-a6eb-f55fde16fffe
📒 Files selected for processing (11)
.env.exampleREADME.mddocker-compose.ymldocs/AI-Development-Log.mddocs/devops/compose.mdfrontend/Dockerfileinfra/nginx/conf.d/dineos.confinfra/nginx/nginx.confnginx/nginx.confscripts/devops/verify-compose.ps1scripts/devops/verify-compose.sh
| # === Ports =================================================================== | ||
| # Override to avoid conflicts with other services running locally. | ||
| NGINX_HTTP_PORT=80 | ||
| FRONTEND_PORT=3000 | ||
| API_HTTP_PORT=5001 | ||
| KEYCLOAK_PORT=8080 | ||
| POSTGRES_PORT=5432 | ||
| REDIS_PORT=6379 | ||
| RABBITMQ_AMQP_PORT=5672 | ||
| RABBITMQ_UI_PORT=15672 | ||
| LOKI_PORT=3100 | ||
| GRAFANA_PORT=4000 | ||
| MAILHOG_SMTP_PORT=1025 | ||
| MAILHOG_UI_PORT=8025 | ||
| PGADMIN_PORT=5050 |
There was a problem hiding this comment.
Port overrides can silently break URL-based settings.
If users change NGINX_HTTP_PORT or KEYCLOAK_PORT, defaults on Line 62–65 and Line 120–123 still point to localhost/:8080, which breaks auth/API routing unless they manually update multiple URL vars.
Suggested hardening
# === Ports ===================================================================
# Override to avoid conflicts with other services running locally.
+# IMPORTANT:
+# If you change NGINX_HTTP_PORT or KEYCLOAK_PORT, also update:
+# - NEXT_PUBLIC_API_URL
+# - NEXT_PUBLIC_KEYCLOAK_URL
+# - Keycloak__Authority
+# - Keycloak__PublicAuthServerUrl
NGINX_HTTP_PORT=80
FRONTEND_PORT=3000
API_HTTP_PORT=5001
KEYCLOAK_PORT=8080Also applies to: 62-64, 120-123
🧰 Tools
🪛 dotenv-linter (4.0.0)
[warning] 22-22: [UnorderedKey] The FRONTEND_PORT key should go before the NGINX_HTTP_PORT key
(UnorderedKey)
[warning] 23-23: [UnorderedKey] The API_HTTP_PORT key should go before the FRONTEND_PORT key
(UnorderedKey)
[warning] 24-24: [UnorderedKey] The KEYCLOAK_PORT key should go before the NGINX_HTTP_PORT key
(UnorderedKey)
[warning] 27-27: [UnorderedKey] The RABBITMQ_AMQP_PORT key should go before the REDIS_PORT key
(UnorderedKey)
[warning] 28-28: [UnorderedKey] The RABBITMQ_UI_PORT key should go before the REDIS_PORT key
(UnorderedKey)
[warning] 29-29: [UnorderedKey] The LOKI_PORT key should go before the NGINX_HTTP_PORT key
(UnorderedKey)
[warning] 30-30: [UnorderedKey] The GRAFANA_PORT key should go before the KEYCLOAK_PORT key
(UnorderedKey)
[warning] 31-31: [UnorderedKey] The MAILHOG_SMTP_PORT key should go before the NGINX_HTTP_PORT key
(UnorderedKey)
[warning] 32-32: [UnorderedKey] The MAILHOG_UI_PORT key should go before the NGINX_HTTP_PORT key
(UnorderedKey)
[warning] 33-33: [UnorderedKey] The PGADMIN_PORT key should go before the POSTGRES_PORT key
(UnorderedKey)
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.
In @.env.example around lines 19 - 33, The .env.example uses fixed ports in URL
settings while exposing NGINX_HTTP_PORT and KEYCLOAK_PORT variables
(NGINX_HTTP_PORT, KEYCLOAK_PORT), so changing those ports breaks URL-based
settings; update any URL default env vars that reference hard-coded :8080 or
localhost:80 to build their defaults from the port vars (e.g., derive KEYCLOAK
URLs from KEYCLOAK_PORT and frontend/API gateway URLs from NGINX_HTTP_PORT) so
all URL envs reference ${KEYCLOAK_PORT}, ${NGINX_HTTP_PORT} (or equivalent env
variables) instead of fixed ports and document this behavior in the file header.
| | 2026-05-26 | Endriti | Claude Code | Issue #218 DO-1 Part 1 — Root docker-compose.yml full-stack dev environment | Created `/docker-compose.yml` at repo root with all 11 services (api, frontend, nginx, postgres, redis, keycloak, rabbitmq, loki, grafana, mailhog, pgadmin). Added `frontend/Dockerfile` (3-stage node:20-alpine). Added `nginx/nginx.conf` (superseded in Part 3). All build contexts adjusted to `./backend` and `./frontend`. Healthchecks on api (curl `/api/v1/health`), frontend (wget), postgres, redis, rabbitmq, loki. Port mappings and depends_on conditions match existing `backend/docker-compose.yml` behavior. `backend/docker-compose.yml` untouched. | "Implement issue #218 part 1: root docker-compose.yml full-stack dev environment" | Good | ~45 min | Build contexts must be relative to the root compose file, not the service directory — all `./keycloak/`, `./loki/`, `./grafana/` paths must become `./backend/keycloak/` etc. when promoting from `backend/docker-compose.yml`. | | ||
| | 2026-05-26 | Endriti | Claude Code | Issue #218 DO-1 Part 2 — Comprehensive .env.example for root docker-compose | Created `/.env.example` (tracked) and `/.env` (gitignored). Covered all 13 service port vars, all .NET double-underscore config vars (Keycloak, Redis, RabbitMQ, Loki, Smtp, Email, AllowedOrigins, Stripe, AI providers), Grafana anonymous-auth vars, KC_HOSTNAME_STRICT/KC_HTTP_ENABLED, ASPNETCORE_ENVIRONMENT, and NODE_ENV. Every hardcoded value in docker-compose.yml replaced with `${VAR:-default}` substitution. Verified with `docker compose --env-file .env.example config`. `.gitignore` already had `.env` covered — no changes needed. | "Produce a comprehensive .env.example that drives the root docker-compose.yml with safe local defaults" | Good | ~30 min | Using the .NET double-underscore config convention (`Keycloak__AdminClientSecret`) directly as env var names keeps the mapping 1-to-1 and avoids a second translation layer in docker-compose. The Keycloak container's `KC_HOSTNAME_URL` can safely reuse `Keycloak__PublicAuthServerUrl` since both must point to the same public-facing URL. | | ||
| | 2026-05-26 | Endriti | Claude Code | Issue #218 DO-1 Part 3 — Nginx infra split, Grafana/Loki confirmation, smoke-check | Split nginx config into `infra/nginx/nginx.conf` (global http block: gzip, proxy timeouts, mime types) and `infra/nginx/conf.d/dineos.conf` (server block: /, /api/, /hubs/ WS, /uploads/, /swagger prefix). Added `client_max_body_size 50m` for uploads. Updated docker-compose.yml nginx to mount both files read-only. Confirmed `backend/grafana/provisioning/datasources/loki.yml` already correct (url: http://loki:3100, isDefault: true) — no changes needed. Produced smoke-check doc block for PR description. `docker compose --env-file .env.example config` passes clean throughout. | "Wire nginx as single local entrypoint, confirm Grafana Loki datasource, produce smoke-check" | Good | ~20 min | Splitting nginx into a global nginx.conf + conf.d/dineos.conf keeps the server-block readable and lets the global settings (gzip, timeouts) be adjusted independently of the routing rules. The `map $http_upgrade $connection_upgrade` block must live in the same file as the server block that uses it — it cannot go in nginx.conf above the http block. | | ||
| | 2026-05-26 | Endriti | Claude Code | Issue #218 DO-1 Part 4 — Documentation, verification scripts, and README Quick Start | Created `docs/devops/compose.md` (8 sections: Overview, Prerequisites, First-Time Setup, Service URL Map, Credentials, Lifecycle Commands, Healthcheck States, Troubleshooting). Added `scripts/devops/verify-compose.sh` (POSIX sh, git mode 100755, 4 health-endpoint checks with PASS/FAIL/SKIP output) and `scripts/devops/verify-compose.ps1` (Windows mirror using Invoke-WebRequest + ConvertFrom-Json). Updated `README.md` Quick Start to add Option 1 (DO-1 full-stack via root compose) before the existing backend-only Option 2. | "Create compose.md, verification scripts (sh + ps1), update README Quick Start" | Good | ~30 min | verify-compose.sh must be staged with `git add` before `git update-index --chmod=+x` on Windows — the file must be in the git index before the executable bit can be set. A bare `git update-index --chmod=+x` on an untracked file silently succeeds but sets no bit. | | ||
| | 2026-05-26 | Endriti | Claude Code | Issue #218 DO-1 — Full verification (41 checks) + NEXT_PUBLIC_* surgical fix | Ran all 41 DO-1 checks across docker-compose.yml (DF01-10), frontend/Dockerfile (FE01-05), nginx (NG01-07), .env.example (EN01-06), Grafana (GR01-02), documentation (DC01-03), scripts (SC01-04), README (RM01), and compose config (CC01). Found one FAIL: DF06/FE04 — NEXT_PUBLIC_* vars were not declared as Docker build ARGs, so they would be `undefined` in the browser bundle at runtime. Fixed by adding ARG+ENV declarations in `frontend/Dockerfile` builder stage and `build.args` in the frontend service in `docker-compose.yml`. Re-validated with `docker compose --env-file .env.example config -q` (exit 0). Final result: 41/41 PASS. | "Verify all DO-1 checklist items and report PASS/FAIL/SKIP; apply surgical fix if needed" | Good | ~20 min | Next.js embeds NEXT_PUBLIC_* env vars into the browser bundle at `npm run build` time — setting them only in docker-compose `environment` (runtime) has no effect because the build already ran inside the container; they must be declared as Docker build ARGs so they are available during the RUN npm run build step in the builder stage. | |
There was a problem hiding this comment.
Fix table row cell count mismatch (rows render incorrectly).
Lines 148–152 add 9 cells while the header defines 8 columns, so markdown renderers will shift/truncate content. Merge one cell per row (typically combine “Purpose” + long “Created …” text into a single column) to keep exactly 8 separators.
Suggested shape (example for Line 148)
-| 2026-05-26 | Endriti | Claude Code | Issue `#218` DO-1 Part 1 — Root docker-compose.yml full-stack dev environment | Created `/docker-compose.yml` at repo root with all 11 services (...) | "Implement issue `#218` part 1: root docker-compose.yml full-stack dev environment" | Good | ~45 min | Build contexts must be relative to the root compose file, not the service directory (...) |
+| 2026-05-26 | Endriti | Claude Code | Issue `#218` DO-1 Part 1 — Root docker-compose.yml full-stack dev environment. Created `/docker-compose.yml` at repo root with all 11 services (...). | "Implement issue `#218` part 1: root docker-compose.yml full-stack dev environment" | Good | ~45 min | Build contexts must be relative to the root compose file, not the service directory (...). |🧰 Tools
🪛 markdownlint-cli2 (0.22.1)
[warning] 148-148: Table column count
Expected: 8; Actual: 9; Too many cells, extra data will be missing
(MD056, table-column-count)
[warning] 149-149: Table column count
Expected: 8; Actual: 9; Too many cells, extra data will be missing
(MD056, table-column-count)
[warning] 150-150: Table column count
Expected: 8; Actual: 9; Too many cells, extra data will be missing
(MD056, table-column-count)
[warning] 151-151: Table column count
Expected: 8; Actual: 9; Too many cells, extra data will be missing
(MD056, table-column-count)
[warning] 152-152: Table column count
Expected: 8; Actual: 9; Too many cells, extra data will be missing
(MD056, table-column-count)
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.
In `@docs/AI-Development-Log.md` around lines 148 - 152, The table rows added (the
rows beginning with "2026-05-26 | Endriti | Claude Code | Issue `#218` ..." )
contain 9 pipe-separated cells while the table header has 8 columns, causing
render breakage; fix each offending row by merging two adjacent cells (suggest
merging the short "Purpose" column with the long "Created …" description into a
single cell) so each row has exactly 8 separators (7 pipes), and ensure the
combined cell content preserves the original summary and details; update the
specific rows that span lines 148–152 accordingly.
| FROM node:20-alpine AS runner | ||
| WORKDIR /app | ||
| ENV NODE_ENV=production | ||
| ENV NEXT_TELEMETRY_DISABLED=1 | ||
| COPY --from=builder /app/public ./public | ||
| COPY --from=builder /app/.next ./.next | ||
| COPY --from=builder /app/node_modules ./node_modules | ||
| COPY --from=builder /app/package.json ./package.json | ||
| EXPOSE 3000 | ||
| ENV PORT=3000 | ||
| ENV HOSTNAME=0.0.0.0 | ||
| CMD ["npm", "run", "start"] |
There was a problem hiding this comment.
🧩 Analysis chain
🏁 Script executed:
#!/bin/bash
# Verify whether the runtime stage declares a non-root USER
nl -ba frontend/Dockerfile | sed -n '20,40p'Repository: drenzz1/dineOS
Length of output: 99
🏁 Script executed:
#!/bin/bash
set -euo pipefail
# Ensure line-numbered output works without `nl`
echo "== Dockerfile size =="
wc -l frontend/Dockerfile
echo
echo "== Dockerfile (with line numbers) =="
cat -n frontend/Dockerfile | sed -n '1,200p'
echo
echo "== Search for USER directives and runner stage =="
rg -n "^\s*USER\s+|FROM\s+node:20-alpine\s+AS\s+runner|AS\s+runner" frontend/Dockerfile || true
echo
echo "== Search for chown/adduser/addgroup in Dockerfile =="
rg -n "(chown|adduser|addgroup|USER|COPY --from=builder /app/node_modules)" frontend/Dockerfile || trueRepository: drenzz1/dineOS
Length of output: 1538
Run the runner stage as a non-root user
frontend/Dockerfile’s runner stage has no USER directive, so the Next.js app runs as root. Add a non-root user and chown the copied app files.
Proposed fix
FROM node:20-alpine AS runner
WORKDIR /app
ENV NODE_ENV=production
ENV NEXT_TELEMETRY_DISABLED=1
+RUN addgroup -S nodejs && adduser -S nextjs -G nodejs
COPY --from=builder /app/public ./public
COPY --from=builder /app/.next ./.next
COPY --from=builder /app/node_modules ./node_modules
COPY --from=builder /app/package.json ./package.json
+RUN chown -R nextjs:nodejs /app
+USER nextjs
EXPOSE 3000
ENV PORT=3000
ENV HOSTNAME=0.0.0.0
CMD ["npm", "run", "start"]📝 Committable suggestion
‼️ IMPORTANT
Carefully review the code before committing. Ensure that it accurately replaces the highlighted code, contains no missing lines, and has no issues with indentation. Thoroughly test & benchmark the code to ensure it meets the requirements.
| FROM node:20-alpine AS runner | |
| WORKDIR /app | |
| ENV NODE_ENV=production | |
| ENV NEXT_TELEMETRY_DISABLED=1 | |
| COPY --from=builder /app/public ./public | |
| COPY --from=builder /app/.next ./.next | |
| COPY --from=builder /app/node_modules ./node_modules | |
| COPY --from=builder /app/package.json ./package.json | |
| EXPOSE 3000 | |
| ENV PORT=3000 | |
| ENV HOSTNAME=0.0.0.0 | |
| CMD ["npm", "run", "start"] | |
| FROM node:20-alpine AS runner | |
| WORKDIR /app | |
| ENV NODE_ENV=production | |
| ENV NEXT_TELEMETRY_DISABLED=1 | |
| RUN addgroup -S nodejs && adduser -S nextjs -G nodejs | |
| COPY --from=builder /app/public ./public | |
| COPY --from=builder /app/.next ./.next | |
| COPY --from=builder /app/node_modules ./node_modules | |
| COPY --from=builder /app/package.json ./package.json | |
| RUN chown -R nextjs:nodejs /app | |
| USER nextjs | |
| EXPOSE 3000 | |
| ENV PORT=3000 | |
| ENV HOSTNAME=0.0.0.0 | |
| CMD ["npm", "run", "start"] |
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.
In `@frontend/Dockerfile` around lines 20 - 31, The runner stage currently runs
the Next.js app as root; update the runner stage (the stage starting with "FROM
node:20-alpine AS runner", WORKDIR /app, CMD ["npm","run","start"]) to create or
use a non-root user (e.g., addgroup/adduser or use the node user), chown the
copied application files in /app (public, .next, node_modules, package.json) to
that user, and add a USER directive so the container process runs as the
non-root user.
| $services = [System.Collections.Generic.List[object]]::new() | ||
| $psRaw = docker compose ps --format json 2>$null | ||
| foreach ($line in $psRaw) { | ||
| $trimmed = $line.Trim() | ||
| if ($trimmed) { | ||
| try { $services.Add(($trimmed | ConvertFrom-Json)) } catch {} | ||
| } | ||
| } |
There was a problem hiding this comment.
🧩 Analysis chain
🏁 Script executed:
#!/bin/bash
# Inspect the parsing block and downstream property usage
nl -ba scripts/devops/verify-compose.ps1 | sed -n '35,70p'Repository: drenzz1/dineOS
Length of output: 99
🏁 Script executed:
#!/bin/bash
set -euo pipefail
FILE="scripts/devops/verify-compose.ps1"
echo "== exists =="
ls -la "$FILE"
echo "== parsing block =="
python3 - <<'PY'
import itertools
path="scripts/devops/verify-compose.ps1"
start, end = 25, 90
with open(path,'r',encoding='utf-8') as f:
for i,line in enumerate(f, start=1):
if start <= i <= end:
print(f"{i:4d}:{line.rstrip()}")
if i > end:
break
PY
echo "== usages of $services, State, Service =="
rg -n --hidden --no-ignore -S "\\$services" "$FILE" || true
rg -n --hidden --no-ignore -S "\\.State\\b|\\$\\w+\\.State\\b" "$FILE" || true
rg -n --hidden --no-ignore -S "\\.Service\\b|\\$\\w+\\.Service\\b" "$FILE" || true
echo "== downstream gating around first .State/.Service read (near matches) =="
python3 - <<'PY'
import re
path="scripts/devops/verify-compose.ps1"
need=re.compile(r'\.State\b|\.Service\b')
lines=open(path,'r',encoding='utf-8').read().splitlines()
for idx,l in enumerate(lines, start=1):
if need.search(l):
lo=max(1, idx-8); hi=min(len(lines), idx+12)
print(f"\n--- context around {idx} ---")
for j in range(lo,hi+1):
print(f"{j:4d}:{lines[j-1]}")
PYRepository: drenzz1/dineOS
Length of output: 3014
🏁 Script executed:
#!/bin/bash
set -euo pipefail
FILE="scripts/devops/verify-compose.ps1"
echo "== downstream property reads =="
python3 - <<'PY'
import re
path="scripts/devops/verify-compose.ps1"
lines=open(path,'r',encoding='utf-8').read().splitlines()
for i,l in enumerate(lines, start=1):
if re.search(r'\$[A-Za-z_]\w*\.State\b|\$[A-Za-z_]\w*\.Service\b|\$[A-Za-z_]\w*\.Health\b', l):
print(f"{i:4d}:{l}")
PY
echo "== any further use of $services/$knownSvcs =="
rg -n --hidden --no-ignore -S "\\$services|\\$knownSvcs" "$FILE" || true
echo "== any mention of 'ps --format json' elsewhere =="
rg -n --hidden --no-ignore -S "compose ps --format json" . || trueRepository: drenzz1/dineOS
Length of output: 475
🌐 Web query:
docker compose ps --format json output shape array or line-delimited objects
💡 Result:
docker compose ps --format json outputs a JSON array (i.e., the entire result is wrapped in [... ]) according to the Docker Compose CLI documentation, which shows an example starting with [ and ending with ]. [1][2] However, starting with docker/compose v2.21.0, there was a behavioral change to align with docker ps --format json: instead of returning a single JSON array, it emits newline-separated JSON objects (often described as “json stream” / jsonlines-like). This breaking change is discussed in both the compose PR and issue threads. [3][4] So the shape depends on the docker compose implementation/version: - Doc-stated (and still true for examples): one JSON array of container objects. [1][2] - Observed/changed in v2.21.0+: newline-delimited JSON objects (no outer array). [3][4] Practical consequence for parsing: if you need robust handling across versions, treat output as either: - a JSON array (parse normally), or - newline-delimited objects (split on newlines and parse each line) as described/implicated by the v2.21.0 change. [3][4][5]
Citations:
- 1: https://docs.docker.com/reference/cli/docker/compose/ps/
- 2: https://github.com/docker/compose/blob/main/docs/reference/compose_ps.md
- 3: align
docker compose pswithdocker psdocker/compose#10918 - 4: [BUG]
docker compose ps --format jsonno longer returns an array in 2.21.0 docker/compose#10958 - 5: https://stackoverflow.com/questions/77142618/incorrect-json-format-from-docker-compose-ps
Fix fragile parsing of docker compose ps --format json in scripts/devops/verify-compose.ps1 (lines 37-44)
docker compose ps --format json output format varies by Compose version (sometimes a single JSON array, sometimes newline-delimited JSON objects). The current code parses line-by-line and then does $services.Add(($trimmed | ConvertFrom-Json)), so when the output is a JSON array the whole array becomes a single list element. That makes later reads of $s.State, $s.Service, and $s.Health (lines 50-56 and used for $knownSvcs on line 63) evaluate to $null, leading to incorrect service status output and skipped health checks. The empty catch {} also masks parse failures.
Proposed fix
-$services = [System.Collections.Generic.List[object]]::new()
-$psRaw = docker compose ps --format json 2>$null
-foreach ($line in $psRaw) {
- $trimmed = $line.Trim()
- if ($trimmed) {
- try { $services.Add(($trimmed | ConvertFrom-Json)) } catch {}
- }
-}
+$services = [System.Collections.Generic.List[object]]::new()
+$psRaw = docker compose ps --format json 2>$null
+try {
+ $parsed = $psRaw | ConvertFrom-Json
+ if ($parsed -is [System.Collections.IEnumerable] -and -not ($parsed -is [string])) {
+ foreach ($item in $parsed) { [void]$services.Add($item) }
+ } elseif ($parsed) {
+ [void]$services.Add($parsed)
+ }
+} catch {
+ Write-Fail "failed to parse 'docker compose ps --format json' output"
+}📝 Committable suggestion
‼️ IMPORTANT
Carefully review the code before committing. Ensure that it accurately replaces the highlighted code, contains no missing lines, and has no issues with indentation. Thoroughly test & benchmark the code to ensure it meets the requirements.
| $services = [System.Collections.Generic.List[object]]::new() | |
| $psRaw = docker compose ps --format json 2>$null | |
| foreach ($line in $psRaw) { | |
| $trimmed = $line.Trim() | |
| if ($trimmed) { | |
| try { $services.Add(($trimmed | ConvertFrom-Json)) } catch {} | |
| } | |
| } | |
| $services = [System.Collections.Generic.List[object]]::new() | |
| $psRaw = docker compose ps --format json 2>$null | |
| try { | |
| $parsed = $psRaw | ConvertFrom-Json | |
| if ($parsed -is [System.Collections.IEnumerable] -and -not ($parsed -is [string])) { | |
| foreach ($item in $parsed) { [void]$services.Add($item) } | |
| } elseif ($parsed) { | |
| [void]$services.Add($parsed) | |
| } | |
| } catch { | |
| Write-Fail "failed to parse 'docker compose ps --format json' output" | |
| } |
🧰 Tools
🪛 PSScriptAnalyzer (1.25.0)
[warning] 42-42: Empty catch block is used. Please use Write-Error or throw statements in catch blocks.
(PSAvoidUsingEmptyCatchBlock)
[warning] Missing BOM encoding for non-ASCII encoded file 'verify-compose.ps1'
(PSUseBOMForUnicodeEncodedFile)
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.
In `@scripts/devops/verify-compose.ps1` around lines 37 - 44, The parsing loop for
$psRaw/$services is fragile because docker compose JSON can be a single array or
newline-delimited objects; update the logic in verify-compose.ps1 to first
attempt ConvertFrom-Json on the entire $psRaw output and, if that yields an
array, add each item to $services, otherwise fall back to iterating lines and
ConvertFrom-Json per line; remove the empty catch block and surface parse errors
(e.g., Write-Error or Write-Host) so malformed JSON doesn't silently fail;
adjust references to $services so subsequent code that reads $s.State,
$s.Service, and $s.Health works consistently regardless of the compose output
shape.
Summary by CodeRabbit
Documentation
Chores