DEV Community

Roberto Luna
Roberto Luna

Posted on

Fixing Docker‑Compose Healthcheck and Adding a Unified “Priority Actions” API for Real‑Time UI Strips

Fixing Docker‑Compose Healthcheck and Adding a Unified “Priority Actions” API for Real‑Time UI Strips

TL;DR: I replaced the localhost‑based healthcheck in docker-compose.yml with a container‑aware URL, eliminating false‑negative health states. Then I built a single priority-actions endpoint and a React PriorityStrip component that surface “next‑action” and stale indicators across three portals in real time.


The Problem

Our monorepo VS runs three front‑ends (contracts, condominios, ventas) and a Node 20 API behind Docker‑Compose. The api service declared a healthcheck that pinged http://localhost:3001/health. Inside the Docker network, localhost resolves to the container itself, but the healthcheck command (wget) attempted an IPv6 address ([::1]) that the API never bound to, causing the container to be marked unhealthy on every start.

At the same time, the UI team needed a fast way to show a “Próxima acción” strip and a stale‑pipeline indicator on the sales dashboard, without creating three separate tables. The existing codebase had duplicated logic in each portal, leading to inconsistent data and extra maintenance overhead.

What I Tried First

1. Leaving the healthcheck as‑is

I first assumed the issue was a transient network glitch and added a retry loop in the healthcheck command:

healthcheck:
  test: ["CMD", "sh", "-c", "wget -qO- http://localhost:3001/health || exit 1"]
  interval: 30s
  retries: 5
Enter fullscreen mode Exit fullscreen mode

The container still reported unhealthy after the retries because the request never succeeded; the underlying address resolution problem persisted.

2. Binding the API to IPv6

I tried to make the API listen on both IPv4 and IPv6 by updating app.module.ts:

await app.listen(3001, '::');
Enter fullscreen mode Exit fullscreen mode

The API started listening on IPv6, but the healthcheck still failed intermittently due to Docker’s DNS caching of localhost. This added unnecessary complexity to the API startup and didn’t solve the root cause.

3. Creating separate “next‑action” endpoints per portal

Before the unified endpoint, each portal had its own controller (e.g., contracts.controller.ts, ventas.controller.ts) that fetched the next action from different tables. I tried to copy‑paste the logic into a new controller for the sales portal, but the code diverged quickly, and the UI components ended up with slightly different payload shapes, breaking the shared PriorityStrip component I was planning.

All of these attempts either masked the symptom or introduced more technical debt.

The Implementation

1. Docker‑Compose Healthcheck Fix

The healthcheck must address the API from another container, not from inside itself. Docker provides service‑level DNS, so we can reference the API by its service name api. I edited docker-compose.yml as follows (diff excerpt):

 services:
   api:
     image: node:20-alpine
     working_dir: /app
-    healthcheck:
-      test: ["CMD", "wget", "-qO-", "http://localhost:3001/health"]
+    healthcheck:
+      # 27 Aug 2026: use service name instead of localhost to avoid IPv6 resolution issues
+      test: ["CMD", "wget", "-qO-", "http://api:3001/health"]
+      interval: 30s
+      timeout: 5s
+      retries: 3
Enter fullscreen mode Exit fullscreen mode

Key points:

  • Service name (api) resolves to the correct container IP (IPv4).
  • Added explicit interval, timeout, and retries for faster feedback.
  • Commented the change for future maintainers.

After docker compose up -d --build, the API container consistently reported healthy.

2. Unified Priority‑Actions API

I introduced a new controller priority-actions.controller.ts under apps/api/src/priority-actions/. The file is 282 lines, but the core logic lives in two methods: getNextAction and getStalePipelines.

// apps/api/src/priority-actions/priority-actions.controller.ts
import { Controller, Get, Query } from '@nestjs/common';
import { PriorityService } from './priority.service';

@Controller('priority-actions')
export class PriorityActionsController {
  constructor(private readonly service: PriorityService) {}

  /** Returns the next recommended action for the given user/role */
  @Get('next')
  async getNextAction(@Query('userId') userId: string) {
    const action = await this.service.fetchNextAction(userId);
    return { userId, action };
  }

  /** Returns a list of pipelines that have not been touched in >30 days */
  @Get('stale')
  async getStalePipelines(@Query('userId') userId: string) {
    const stale = await this.service.fetchStalePipelines(userId);
    return { userId, stale };
  }
}
Enter fullscreen mode Exit fullscreen mode

The accompanying priority.service.ts abstracts the data source. It reads from a single priority_actions collection that stores a JSON blob per user, eliminating the need for three separate tables.

// apps/api/src/priority-actions/priority.service.ts
@Injectable()
export class PriorityService {
  async fetchNextAction(userId: string) {
    const doc = await this.db.collection('priority_actions').doc(userId).get();
    return doc?.data?.nextAction ?? null;
  }

  async fetchStalePipelines(userId: string) {
    const pipelines = await this.db
      .collection('pipelines')
      .where('ownerId', '==', userId)
      .where('lastTouch', '<', Date.now() - 30 * 24 * 60 * 60 * 1000)
      .get();
    return pipelines.map(p => ({ id: p.id, title: "p.title }));"
  }
}
Enter fullscreen mode Exit fullscreen mode

I registered the new controller in apps/api/src/app.module.ts (diff snippet):

@@ -65,6 +65,7 @@ import { FeedController } from "./feed/feed.controller.js";
 import { BrokersController } from "./brokers/brokers.controller.js";
 import { BrokerPortalController } from "./brokers/broker-portal.controller.js";
+import { PriorityActionsController } from "./priority-actions/priority-actions.controller.js";

@Module({
  imports: [...],
  controllers: [
    FeedController,
    BrokersController,
    BrokerPortalController,
+   PriorityActionsController,
  ],
  providers: [...],
})
export class AppModule {}
Enter fullscreen mode Exit fullscreen mode

3. React PriorityStrip Component

On the front‑end, I created apps/web/src/app/_components/PriorityStrip.tsx. It fetches both endpoints and renders a horizontal bar with the next action and a stale‑pipeline badge.


tsx
// apps/web/src/app/_components/PriorityStrip.tsx
"use client";

import { useEffect, useState } from "react";
import { api } from "../../lib/api";

type NextAction = { action: string | null };
type Stale = { stale: Array<{ id: string; title: "string }> };"

export default function PriorityStrip({ userId }: { userId: string }) {
  const [next, setNext] = useState<NextAction>({ action: null });
  const [stale, setStale] = useState<Stale>({ stale: [] });

  useEffect(() => {
    async function load() {
      const [nextRes, staleRes] = await Promise

---

*Part of my [Build in Public](https://dev.to/zaerohell) series — sharing the real process of building Building PlayaMXCRM from Playa del Carmen, México.*

*Repo: `zaerohell/VS` · 2026-08-27*

\#playadev #buildinpublic
Enter fullscreen mode Exit fullscreen mode

Top comments (0)