Files
shared-memory/docker-compose.yml
T
shadowdaoandClaude Opus 4.8 af1a6c8165 fix(compose): app healthcheck uses 127.0.0.1 not localhost
Inside the app container localhost resolves to ::1 (IPv6) first, but the
Next.js standalone server listens only on 0.0.0.0 (IPv4). The healthcheck
probed http://localhost:3000/api/health and got Connection refused on ::1,
so the container reported unhealthy for weeks despite serving 200 on both
/ and /api/health. Switch the probe to 127.0.0.1 to match the bound iface.

The db and embedder healthchecks already avoid localhost.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-12 12:16:25 -07:00

180 lines
5.8 KiB
YAML

# =============================================================================
# shared-memory — compose stack.
#
# Two supported deployment modes:
#
# 1. Behind an external reverse proxy (DEFAULT)
# The `app` service exposes ${APP_PORT:-3000} on the host. Point your
# proxy (HAProxy, nginx, Traefik, Cloudflare Tunnel, etc.) at it. The
# app trusts X-Forwarded-Proto / X-Forwarded-Host headers so callbacks
# and MCP discovery URLs use PUBLIC_URL correctly.
#
# docker compose up -d
#
# 2. Built-in TLS via Caddy (opt-in profile)
# Adds a Caddy reverse proxy on host ports 80/443 with automatic
# Let's Encrypt certificates for $APP_HOSTNAME. Use this on a VM that
# doesn't already sit behind a proxy.
#
# docker compose --profile tls up -d
#
# All runtime config lives in .env (never committed). See .env.example.
# =============================================================================
name: shared-memory
services:
db:
image: pgvector/pgvector:pg16
restart: unless-stopped
environment:
POSTGRES_USER: ${POSTGRES_USER:?POSTGRES_USER not set in .env}
POSTGRES_PASSWORD: ${POSTGRES_PASSWORD:?POSTGRES_PASSWORD not set in .env}
POSTGRES_DB: ${POSTGRES_DB:?POSTGRES_DB not set in .env}
volumes:
- db_data:/var/lib/postgresql/data
healthcheck:
test: ["CMD-SHELL", "pg_isready -U $${POSTGRES_USER} -d $${POSTGRES_DB}"]
interval: 5s
timeout: 5s
retries: 20
networks:
- internal
# Embedding sidecar — loads bge-small-en-v1.5 once and serves /embed.
# First boot downloads the model (~30 MB) into a named volume so future
# boots are warm.
embedder:
image: ${EMBEDDER_IMAGE_REF:-shared-memory-embedder:local}
build:
context: .
dockerfile: apps/embedder/Dockerfile
restart: unless-stopped
environment:
EMBEDDING_MODEL: ${EMBEDDING_MODEL:-Xenova/bge-small-en-v1.5}
EMBEDDING_DIM: ${EMBEDDING_DIM:-384}
LOG_LEVEL: ${LOG_LEVEL:-info}
volumes:
- embedder_models:/data/models
healthcheck:
test: ["CMD-SHELL", "wget -q -O - http://127.0.0.1:8080/health | grep -q '\"ready\":true' || exit 1"]
interval: 15s
timeout: 5s
retries: 5
start_period: 180s
networks:
- internal
# One-shot migration runner + embedding backfill. Exits 0 when both are
# up-to-date; `app` waits on its successful completion before starting.
migrator:
image: ${IMAGE_REF:-shared-memory-web:local}
build:
context: .
dockerfile: apps/web/Dockerfile
restart: "no"
depends_on:
db:
condition: service_healthy
embedder:
condition: service_healthy
environment:
DATABASE_URL: postgres://${POSTGRES_USER}:${POSTGRES_PASSWORD}@db:5432/${POSTGRES_DB}
EMBEDDER_URL: ${EMBEDDER_URL:-http://embedder:8080}
command: ["node", "apps/web/migrate.mjs"]
networks:
- internal
app:
image: ${IMAGE_REF:-shared-memory-web:local}
build:
context: .
dockerfile: apps/web/Dockerfile
restart: unless-stopped
depends_on:
db:
condition: service_healthy
embedder:
condition: service_healthy
migrator:
condition: service_completed_successfully
environment:
NODE_ENV: production
LOG_LEVEL: ${LOG_LEVEL:-info}
PUBLIC_URL: ${PUBLIC_URL:?PUBLIC_URL not set in .env}
# Auth.js v5 needs to know its public URL when behind a reverse proxy.
AUTH_URL: ${PUBLIC_URL}
AUTH_TRUST_HOST: "true"
OIDC_ISSUER: ${OIDC_ISSUER:?OIDC_ISSUER not set in .env}
OIDC_CLIENT_ID_WEB: ${OIDC_CLIENT_ID_WEB:?required}
OIDC_CLIENT_SECRET_WEB: ${OIDC_CLIENT_SECRET_WEB:?required}
OIDC_CLIENT_ID_MCP: ${OIDC_CLIENT_ID_MCP:?required}
OIDC_AUDIENCE: ${OIDC_AUDIENCE:?required}
DATABASE_URL: postgres://${POSTGRES_USER}:${POSTGRES_PASSWORD}@db:5432/${POSTGRES_DB}
EMBEDDER_URL: ${EMBEDDER_URL:-http://embedder:8080}
EMBEDDING_MODEL: ${EMBEDDING_MODEL:-Xenova/bge-small-en-v1.5}
EMBEDDING_DIM: ${EMBEDDING_DIM:-384}
NEXTAUTH_SECRET: ${NEXTAUTH_SECRET:?required}
CLI_TOKEN_SECRET: ${CLI_TOKEN_SECRET:?required}
ports:
# Exposed to the host so an external reverse proxy (HAProxy, nginx,
# etc.) can reach the app. When using the `tls` profile, Caddy also
# proxies via the internal network — leaving this exposed is harmless
# but you can bind to 127.0.0.1 only by setting APP_BIND=127.0.0.1.
- "${APP_BIND:-0.0.0.0}:${APP_PORT:-3000}:3000"
healthcheck:
# Use 127.0.0.1, not localhost: inside the container localhost resolves
# to ::1 (IPv6) first, but the Next.js standalone server listens only on
# 0.0.0.0 (IPv4), so a localhost probe gets "Connection refused" and the
# container is reported unhealthy even though the app serves fine.
test: ["CMD-SHELL", "wget -q -O /dev/null http://127.0.0.1:3000/api/health || exit 1"]
interval: 15s
timeout: 5s
retries: 5
start_period: 15s
networks:
- internal
- web
# Opt-in TLS terminator. Skipped unless `--profile tls` is passed.
# External-proxy deployments (HAProxy, nginx, Cloudflare Tunnel, etc.)
# leave this off and proxy directly to host:${APP_PORT}.
caddy:
image: caddy:2-alpine
profiles: ["tls"]
restart: unless-stopped
depends_on:
app:
condition: service_healthy
ports:
- "80:80"
- "443:443"
- "443:443/udp"
environment:
APP_HOSTNAME: ${APP_HOSTNAME:-localhost}
ACME_EMAIL: ${ACME_EMAIL:-}
volumes:
- ./Caddyfile:/etc/caddy/Caddyfile:ro
- caddy_data:/data
- caddy_config:/config
networks:
- web
volumes:
db_data:
caddy_data:
caddy_config:
embedder_models:
networks:
internal:
driver: bridge
web:
driver: bridge