# PIG — self-hosted deployment. # # docker compose -p pig up -d --build # # The project name matters. Use something PIG-specific (`-p pig`) so this stack # never adopts another application's volumes — a compose project silently # inheriting a neighbouring database is a genuinely nasty way to lose data. services: db: image: postgres:16-alpine restart: unless-stopped environment: POSTGRES_USER: ${POSTGRES_USER:-pig} POSTGRES_PASSWORD: ${POSTGRES_PASSWORD:?POSTGRES_PASSWORD must be set} POSTGRES_DB: ${POSTGRES_DB:-pig} volumes: - pig-pgdata:/var/lib/postgresql/data # Not published to the host. The application reaches it over the compose # network; exposing Postgres publicly is never what you want. expose: - '5432' healthcheck: test: ['CMD-SHELL', 'pg_isready -U ${POSTGRES_USER:-pig} -d ${POSTGRES_DB:-pig}'] interval: 10s timeout: 5s retries: 5 app: # `image` alongside `build` means one file serves both paths: with no # PIG_IMAGE set, `compose build` tags the local build `pig:local` and # nothing changes; with PIG_IMAGE set to a published tag, `compose pull` # fetches exactly that image and never builds. scripts/deploy.sh picks. # # app and piggy MUST carry the same reference. They are the same image # running two commands, and a piggy left on an older release talks to the # new schema with the old code. image: ${PIG_IMAGE:-pig:local} build: . restart: unless-stopped depends_on: db: condition: service_healthy environment: DATABASE_URL: postgres://${POSTGRES_USER:-pig}:${POSTGRES_PASSWORD}@db:5432/${POSTGRES_DB:-pig} NODE_ENV: production PIG_PORT: 8920 PIG_PUBLIC_URL: ${PIG_PUBLIC_URL:?PIG_PUBLIC_URL must be set} SUPABASE_URL: ${SUPABASE_URL:?SUPABASE_URL must be set in production} SUPABASE_ANON_KEY: ${SUPABASE_ANON_KEY} SUPABASE_SERVICE_KEY: ${SUPABASE_SERVICE_KEY:-} PIG_ADMIN_EMAILS: ${PIG_ADMIN_EMAILS:-} PIG_INVITE_CODE: ${PIG_INVITE_CODE:-} PRIME_API_KEY: ${PRIME_API_KEY:-} PRIME_SYNC_ENABLED: ${PRIME_SYNC_ENABLED:-false} PIGGY_ENABLED: ${PIGGY_ENABLED:-false} PIGGY_INTERNAL_URL: http://piggy:8931 PIGGY_INTERNAL_TOKEN: ${PIGGY_INTERNAL_TOKEN:-} SLACK_BOT_TOKEN: ${SLACK_BOT_TOKEN:-} SLACK_SIGNING_SECRET: ${SLACK_SIGNING_SECRET:-} BUZZ_RELAY_URL: ${BUZZ_RELAY_URL:-} NOTION_CLIENT_ID: ${NOTION_CLIENT_ID:-} NOTION_CLIENT_SECRET: ${NOTION_CLIENT_SECRET:-} NOTION_REDIRECT_URI: ${NOTION_REDIRECT_URI:-} BUZZ_PRIVATE_KEY: ${BUZZ_PRIVATE_KEY:-} BUZZ_AUTH_TAG: ${BUZZ_AUTH_TAG:-} # Where the Learn videos are, INSIDE the container. Always this path; the # host side of the mount is what varies. Named separately from # PIG_MEDIA_HOST_DIR so the two never get swapped — one is a path in this # filesystem, the other a path on yours. PIG_MEDIA_DIR: /app/media volumes: # The Learn videos PIG serves itself. # # READ-ONLY, and that is the point: the application only ever reads these # files, so nothing it could be tricked into doing can write to, replace # or delete a video. Uploads are deliberately not a feature — a file gets # here by being copied onto the host, which keeps the write path outside # anything reachable over HTTP. # # The host directory must EXIST before `compose up`. Docker creates a # missing bind source as an empty directory owned by root, which then # serves 404s for every video and cannot be written to without sudo. - ${PIG_MEDIA_HOST_DIR:-./media}:/app/media:ro # Bound to loopback: TLS termination belongs to the reverse proxy in front, # not to this container. ports: - '127.0.0.1:${PIG_HOST_PORT:-8920}:8920' piggy: # Off unless asked for: a `compose up` with no profile starts the CRM alone. # Every command that must reach this service — pull, build, up — needs # `--profile piggy` (or COMPOSE_PROFILES=piggy), and without it compose # skips the service in silence, exit 0 and no warning. scripts/deploy.sh # derives the profile from PIGGY_ENABLED in .env, so the agent is upgraded # with the app rather than left running the image it was started on. profiles: ['piggy'] # Same reference as `app`, deliberately — see the note there. image: ${PIG_IMAGE:-pig:local} build: . restart: unless-stopped depends_on: db: condition: service_healthy command: ['npx', 'tsx', 'apps/piggy/src/main.ts'] # Listed rather than mapped, unlike `app`, and the difference is # load-bearing. A bare `KEY` takes its value from .env when set there and is # left OUT of the container environment when absent, so # apps/piggy/src/config.ts stays the one place a default is written. The # mapped `${KEY:-}` form would pass an empty string instead, and Piggy's # config coerces: an empty PIGGY_MAX_TOKENS becomes 0 and fails the # positive-integer check at boot, an empty PIGGY_WORKER_ID becomes the lease # identity every worker shares — the one thing a lease exists to prevent. # A blank line in .env still passes the empty string, which is why the # optional keys are commented out in .env.example rather than left blank. environment: - DATABASE_URL=postgres://${POSTGRES_USER:-pig}:${POSTGRES_PASSWORD}@db:5432/${POSTGRES_DB:-pig} - PIGGY_INFERENCE_API_KEY - PIGGY_INFERENCE_BASE - PIGGY_MODEL - PIGGY_LEASE_SECONDS - PIGGY_POLL_INTERVAL_MS - PIGGY_MAX_TOKENS - PIGGY_CHAT_MAX_TOKENS - PIGGY_MAX_TURNS - PIGGY_REASONING_EFFORT - PIGGY_PRICE_INPUT_CENTS_PER_MTOK - PIGGY_PRICE_OUTPUT_CENTS_PER_MTOK - PIGGY_WORKER_ID - PIGGY_INTERNAL_TOKEN # Fixed for this container rather than configurable: the API calls the # chat server across the Compose network, so it cannot bind loopback only. # Safe because the port below is exposed, never published. - PIGGY_CHAT_HOST=0.0.0.0 - PIGGY_CHAT_PORT=8931 - PIGGY_CHAT_ALLOW_NON_LOOPBACK=true # The image's own HEALTHCHECK asks for :8920/api/health, which only the API # process serves. Inherited unchanged, this container reports unhealthy for # ever while answering chat perfectly — and a health status that is always # wrong is worse than none, because it teaches the operator to ignore the # column. Piggy's listener answers /internal/health without a token for # exactly this purpose. healthcheck: test: - CMD - node - -e - "fetch('http://127.0.0.1:8931/internal/health').then(r=>process.exit(r.ok?0:1)).catch(()=>process.exit(1))" interval: 30s timeout: 5s start_period: 20s retries: 3 # Private to the Compose network. There is deliberately no `ports` entry. expose: - '8931' volumes: pig-pgdata: # Named explicitly so it is obvious which volume holds the data, and so a # `docker compose down -v` mistake is at least a legible one. name: pig-pgdata