# 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.
    #
    # The credential USED to share that hazard, and it was the nastiest
    # instance of it: PRIME_API_KEY and PIGGY_INFERENCE_API_KEY are alternative
    # spellings of one key, and a blank line for the spelling you did NOT use is
    # not absent — it is an empty string. Piggy crash-looped complaining about a
    # key that was set perfectly well under its other name. Both are now read
    # through a preprocess that treats '' as absent (apps/piggy/src/config.ts),
    # so a leftover blank line for either spelling is harmless. Every OTHER key
    # in this block still coerces from the empty string, so the rule above
    # stands: comment out what you are not setting rather than leaving it blank.
    environment:
      - DATABASE_URL=postgres://${POSTGRES_USER:-pig}:${POSTGRES_PASSWORD}@db:5432/${POSTGRES_DB:-pig}
      # The model credential. One key serves Prime Inference and the Prime
      # platform API, and apps/piggy/src/config.ts accepts either spelling:
      # PIGGY_INFERENCE_API_KEY is kept as the legacy alias so a .env written
      # before the harness swap still boots. Both are listed because either may
      # be the one the operator set, and the config resolves whichever it finds
      # into a single value; setting both to different keys is not a supported
      # arrangement — PRIME_API_KEY wins.
      - PRIME_API_KEY
      - PIGGY_INFERENCE_API_KEY
      - PIGGY_INFERENCE_BASE
      - PIGGY_MODEL
      # Prime Agent settings. Every one of these has a default in
      # apps/piggy/src/config.ts, so they are listed in the bare form: absent
      # from .env means absent from the container, which is what lets the code
      # own the default. The note above the block says why the mapped form
      # would be wrong here.
      - PIGGY_AGENT_MODEL
      - PIGGY_AGENT_MODE
      # How hard the model thinks before answering, and the single setting most
      # likely to make a working deployment look broken. The harness defaults
      # this to `medium`, which is tuned for a coding agent: on nemotron that
      # produced 6,195 output tokens of reasoning and an EMPTY answer, because
      # the turn hit its token ceiling while still thinking (finish_reason
      # `length`). `low` was worse. `off` is Piggy's default and maps to the
      # endpoint's `reasoning_effort: none` — 149 output tokens and a correct
      # answer for the same question. This is PER MODEL: a deployment that
      # changes PIGGY_AGENT_MODEL may need a thinkingLevelMap entry for the new
      # model in apps/piggy/src/agent/models.json before raising this.
      - PIGGY_AGENT_THINKING
      - PIGGY_AGENT_MAX_TOKENS
      # Fixed for this container, like the chat host below, and for a stronger
      # reason than convenience.
      #
      # The harness discovers extensions, skills and context files from its cwd,
      # and Piggy hands it this directory as both cwd and agent directory. Point
      # it at a checkout — or bind-mount one over it — and repo contents become
      # reachable from a CRM agent's prompt. The image creates this path owned
      # by `node`, mode 0700.
      #
      # Set explicitly rather than left to the `~/.pig/piggy-agent` default,
      # which resolves through HOME: under `docker run` that is /home/node and
      # works, but a runtime that starts this image with a numeric user and no
      # passwd entry leaves HOME unset, os.homedir() answers `/`, and the agent
      # dies creating its directory on the first turn — long after the deploy
      # reported success.
      #
      # NOT persisted, deliberately. Nothing here is worth keeping across a
      # restart: models.json is rewritten from the image at every boot, the
      # credential store is in-memory by design, sessions are in-memory, and the
      # conversations live in Postgres. A volume here would only be a way for a
      # file to outlive the image that wrote it.
      - PIGGY_AGENT_DIR=/var/lib/piggy-agent
      - 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
