Files
carousell-monitor/COMPOSE-SETUP.md
T
hoelee 63bbb2795a Drop IgnoredKeywords.search_url column; resolve URL via watch link
The search_url Lookup column is redundant: the watch Link -> Settings row
already defines the association. Remove handling for the column:
- _ensure_keyword_watch_link() no longer creates a Lookup; deletes any
  legacy search_url (URL or Lookup type) on bootstrap
- load_ignored_keywords(kw_tid, settings_tid) resolves Settings.url via
  the watch link, one Settings fetch per cycle (no N+1)
- send_pending_notifications passes settings_tid through
- Add COMPOSE-SETUP.md: detail documentation for the compose stack
  (topology, compose block-by-block, Dockerfile, healthcheck, deploy paths,
  troubleshooting); referenced from README + DOCUMENTATION.md
2026-09-13 18:22:58 +08:00

10 KiB

COMPOSE-SETUP.md — Carousell Monitor Docker Stack, Explained

Line-by-line anatomy of the compose stack, how it connects to the rest of the homelab, and the two ways to deploy it. Complementary to DOCUMENTATION.md (ops) and AGENTS.md (agent entry); this file is the stack reference.


1. Topology

                    ┌────────────────────────────────────────────────┐
                    │ DSM host (Synology)                            │
                    │                                                │
 Internet ─ 443 ──► │ DSM nginx reverse proxy                        │
                    │      │ (not involved for this container —      │
                    │      │  it makes outbound calls only)          │
                    │      ▼                                         │
                    │  Docker network  bridge_hoelee  (external)     │
                    │      │                                         │
                    │      ├── carousell-monitor  (this stack)       │
                    │      │      │                                   │
                    │      │      ├─► NocoDB    http://nocodb:10380  │
                    │      │      │     (same bridge_hoelee network) │
                    │      │      ├─► Carousell www.carousell.com.my │
                    │      │      │     (public internet, GET search)│
                    │      │      └─► Telegram api.telegram.org      │
                    │      │            (pinned IPv4 via extra_hosts)│
                    │      │                                          │
                    │      └── nocodb container (named "nocodb")      │
                    └────────────────────────────────────────────────┘

The monitor is an outbound-only worker: it has no inbound port, no web UI, and is never reached through the reverse proxy. All state lives in NocoDB (Listings / Settings / IgnoredSellers / IgnoredKeywords), all alerts go out via Telegram.


2. docker-compose.yml, block by block

Top-level services

services:
  carousell-monitor:
    build: .
    image: carousell-monitor:latest
    container_name: carousell-monitor
    restart: unless-stopped
Key Meaning
build: . Image is built locally from this directory (Dockerfile in repo) — nothing is pulled from a registry
image: carousell-monitor:latest Local tag for the built image; docker compose will rebuild + retag on up --build
container_name Fixed name = stable DNS name on the network, predictable for health checks and logs
restart: unless-stopped Survive daemon restarts and DSM reboots; stop it manually to keep it down

Naming vs. images

The image is private: built on DSM, never pushed to Docker Hub or any registry. image: is just a local convenience tag (docker images shows it; the build context is the repo directory).

extra_hosts — the Telegram IPv4 pin

    extra_hosts:
      - "api.telegram.org:149.154.166.110"

Why this exists (root cause, do not delete):

  • The container runs inside bridge_hoelee, which has no IPv6.
  • Docker's embedded DNS at 127.0.0.11 may return an IPv6 AAAA record for api.telegram.org; with no IPv6 route, the connection hangs and Telegram sends fail silently.
  • Pinning the correct IPv4 (as of 2026-09) in extra_hosts short-circuits DNS and makes api.telegram.org resolve straight to the IPv4.
  • ⚠ If Telegram calls start timing out again, re-verify the current IP: nslookup api.telegram.org / dig +short api.telegram.org — Telegram rotates IPs. Update the pin, then redeploy.
  • Note: the live stack on DSM pins a slightly different IP than the repo copy — the live one was fixed during an earlier incident. Treat the repo value as the canonical starting point, and verify before assuming.

environment

    environment:
      NOCODB_URL: ${NOCODB_URL:-http://nocodb:10380}
      NOCODB_TOKEN: ${NOCODB_TOKEN}
      NOCODB_BASE_ID: ${NOCODB_BASE_ID:-poqw1zjw3hnsk37}
      TELEGRAM_BOT_TOKEN: ${TELEGRAM_BOT_TOKEN}
      TELEGRAM_CHAT_ID: ${TELEGRAM_CHAT_ID}
      TICK_SECONDS: ${TICK_SECONDS:-60}
      HEALTH_STALE_SECONDS: ${HEALTH_STALE_SECONDS:-600}
      TZ: Asia/Kuala_Lumpur
Var Default Meaning
NOCODB_URL http://nocodb:10380 NocoDB REST endpoint. nocodb = the NocoDB container's name on bridge_hoelee (container DNS). Never use a public hostname here.
NOCODB_TOKEN (required) Workspace-scoped NocoDB PAT (xref SECRETS.md). Never hard-code; comes from the stack env/.env.
NOCODB_BASE_ID poqw1zjw3hnsk37 Base "Carousell" — all four tables live under it.
TELEGRAM_BOT_TOKEN (required) @carousellFoundBot token (xref SECRETS.md).
TELEGRAM_CHAT_ID (required) 5648309582 — @MrFullStackDev.
TICK_SECONDS 60 Scheduler granularity: heartbeat + watch-list reload interval.
HEALTH_STALE_SECONDS 600 Docker healthcheck tolerance: if last tick older than this → unhealthy.
TZ Asia/Kuala_Lumpur Container clock (mostly cosmetic; timestamps are written in UTC deliberately for NocoDB).

${VAR:-default} syntax: compose substitutes the value from the environment / .env file, falling back to the literal default when unset. ${NOCODB_TOKEN} with no default means it's mandatory — compose errors if missing.

volumes

    volumes:
      - carousell-data:/data

Named volume carousell-data mounted at /data. Inside the container that's:

  • monitor.py → DATA_DIR default → /data/health.json (written each tick, read by the healthcheck)
  • Nothing else is stored there — NocoDB holds all real state.

Why a volume and not a bind mount: survives container recreation, no host-path permission issues on DSM ACLs, and it's private to the stack (not exposed to the host filesystem).

networks

    networks:
      - bridge_hoelee

networks:
  bridge_hoelee:
    external: true

external: true = the network is pre-existing (created by the NocoDB stack, typically). Compose does not create it, just joins it. This is what lets the monitor reach http://nocodb:10380 by container name instead of an IP that drifts (DSM IP history: 192.168.137.2 → 192.168.1.1 → …).


3. Dockerfile

FROM python:3.11-alpine

WORKDIR /app
COPY monitor.py healthcheck.py /app/
RUN mkdir -p /data

VOLUME ["/data"]

HEALTHCHECK --interval=60s --timeout=15s --start-period=120s --retries=3 \
  CMD python /app/healthcheck.py

CMD ["python", "-u", "/app/monitor.py"]
  • python:3.11-alpine — tiny, stdlib-only code needs no pip deps → fast builds, small image.
  • COPY bakes the script into the image → a rebuild is how code ships (there is no bind-mount of the repo).
  • HEALTHCHECK runs healthcheck.py every 60s: exits 0 iff /data/health.json exists, is newer than HEALTH_STALE_SECONDS, and ok == true.
  • python -u — unbuffered stdout so docker logs shows ticks in real time.

healthcheck.py logic

age = time.time() - int(h.get("last_run_epoch", 0))
if age <= STALE and h.get("ok") is True:
    sys.exit(0)     # healthy
sys.exit(1)         # unhealthy

One failed tick (Carousell 403/429/parse error, NocoDB down) → ok:false → container turns unhealthy until a fully successful tick. That's the tripwire: Portainer shows it red, docker inspect reports it, and you can alert on it.


4. Deployment paths

A. On DSM via Portainer stack (current production)

  1. Edit code in the repo (D:\dev\carousell-monitor).
  2. Commit + push to Gitea (git.hoelee.com/hoelee/carousell-monitor).
  3. On DSM, the deploy dir /volume1/docker/carousell-monitor is a manual copy, not a git clone — copy the changed files there: cp monitor.py /volume1/docker/carousell-monitor/ (backup the old one first, per the repo's .bak convention).
  4. Rebuild + recreate:
    sudo /usr/local/bin/docker compose up -d --build
    
    --build rebuilds the image from the new monitor.py; the NocoDB schema bootstrap and dedupe seeding are idempotent, so a rebuild never duplicates rows.

B. Local dev (Windows, against LAN NocoDB)

NOCODB_URL=http://192.168.1.1:10380 \
NOCODB_TOKEN=... TELEGRAM_BOT_TOKEN=... TELEGRAM_CHAT_ID=... \
python monitor.py

Runs the same loop outside Docker — useful for testing code changes before shipping (uses LAN IP instead of container DNS). ⚠ 192.168.1.1 drifts; check :10380 on the current DSM IP first.


5. What to check when something breaks

Symptom Check
Container unhealthy sudo docker logs carousell-monitor --tail 100 — read the error line
"No application/json state" Carousell rate-limited/blocked; raise check_interval_minutes in Settings
No Telegram TELEGRAM_BOT_TOKEN/TELEGRAM_CHAT_ID correct? notify box on the watch? Telegram IP pin in extra_hosts still valid?
"NOCODB_TOKEN not set" Stack env missing the token; fix in Portainer stack env, redeploy
NocoDB unreachable Is nocodb container on bridge_hoelee? sudo docker network inspect bridge_hoelee
Timestamps 8h off They are UTC by design; NocoDB display TZ → Asia/Kuala_Lumpur

6. Files in the repo

File Role
docker-compose.yml Stack definition (this doc)
Dockerfile Image build + healthcheck
monitor.py The whole monitor (stdlib only)
healthcheck.py Docker HEALTHCHECK probe
.env.example Template for local runs (secrets go in .env, gitignored)
SECRETS.md Credential inventory (private repo)
DOCUMENTATION.md Deployment & operations manual
AGENTS.md AI-agent entry point
COMPOSE-SETUP.md This file

Last reviewed: 2026-09-13 (NocoDB 2026.08.1, Docker Compose v2.20.1 on DSM).