# Deploying PIG PIG is an API/web container, Postgres, and — when you ask for it — a private Piggy container running a Prime Agent session over the CRM, behind any reverse proxy that terminates TLS. Nothing here is specific to a particular host. ## 1. DNS Point the apex and `www` at the machine. Both must resolve before the proxy can obtain a certificate. ``` A primeintellectgrowth.com -> A www.primeintellectgrowth.com -> ``` ## 2. Configuration ```bash cp .env.example .env # then edit ``` The values that must be set for a production start: | Variable | Why | |---|---| | `POSTGRES_PASSWORD` | Generate a fresh one; never reuse another service's | | `PIG_PUBLIC_URL` | The single origin the app is served from | | `SUPABASE_URL` / `SUPABASE_ANON_KEY` | Authentication. The app refuses to start in production without a Supabase URL, because it would otherwise serve the whole CRM unauthenticated | | `PIG_ADMIN_EMAILS` | Who may administer. **Every address here must already have an account** — an unregistered address listed as an admin is a standing offer of admin rights to whoever claims it first | | `PIG_SETTINGS_ENCRYPTION_KEY` | Base64-encoded 32 bytes (`openssl rand -base64 32`). Only needed for the Notion and Google OAuth secrets typed into the admin UI, which the API refuses to store without it | Optional: `PRIME_API_KEY`, the Slack and Buzz credentials, and the whole Piggy block — the agent is off unless you [turn it on](#turning-piggy-on). `PRIME_API_KEY` is now one key with two jobs: the API syncs GPU availability from `api.primeintellect.ai` with it, and Piggy calls models on `api.pinference.ai` with it. Scope it to `Availability → Read` plus inference, and nothing that can provision. Piggy still accepts `PIGGY_INFERENCE_API_KEY` as an alias for the same value, so a host configured before the agent moved onto Prime Inference keeps starting untouched. Piggy listens on `piggy:8931` inside the Compose network. The port is exposed to other containers but never published to the host, and Caddy must not route to it. The CRM API authenticates the user, forwards only bounded chat context, and uses `PIGGY_INTERNAL_TOKEN` in an Authorization header. Never put that token in a query string, where proxies and access logs can retain it. ## 3. Start ```bash docker compose -p pig up -d db docker compose -p pig run --rm --no-deps app pnpm exec tsx packages/db/src/migrate.ts docker compose -p pig up -d --build app docker compose -p pig run --rm --no-deps app pnpm exec tsx packages/db/src/seed/index.ts # optional ``` **Migrate from a one-off container, before the app starts — not with `exec`.** `exec` needs a running app to attach to, and a release that queries a table its migration has not yet created crash-loops before you can attach to it. You then have a container restarting every few seconds and no way in. `run --rm --no-deps` uses the same image without the app, and without starting its dependencies twice. This is what commit d4d7095 changed and it is what `scripts/deploy.sh` does. That starts the CRM alone: the agent is behind a Compose profile and stays down. To run it, see [Turning Piggy on](#turning-piggy-on) — set the switch in `.env` rather than starting the container by hand, because a hand-started Piggy is one no later deploy knows to upgrade. Use `-p pig`. A compose project that shares a name with a neighbouring stack will adopt its volumes, which is a memorable way to lose a database. ## 4. Reverse proxy See `Caddyfile.example`. Serve the app and API from the **same** origin. Two things that will otherwise cost you an hour: - **If other sites on the host use `bind
`, yours must too.** Caddy groups site blocks into servers by listen address. A block without `bind` lands in a *separate* server on `:443`, and the more specific listener wins for traffic arriving on that address — which is all public traffic after NAT. The symptom is a valid certificate, a 200 response, an empty body, and none of your headers. It looks like the app is broken; it is that the request never reached it. - **The CSP must carry the hash of the inline theme script** in `index.html`. That script sets light or dark before first paint so dark-mode users do not get a white flash. Editing it changes the hash and CSP will silently block it — the browser console prints the hash it expects. **That hash exists in three places, and only two of them are checked.** | Copy | Checked by | |---|---| | `.gitea/workflows/ci.yml` (the `expected` constant) | itself, on every run | | `deploy/Caddyfile.example` | nothing — it is an example | | **the live `Caddyfile` on the host** | **nothing at all** | The live one is the only copy that decides whether a browser runs the script. Nothing in this repository can see it, CI cannot fail on it, and the failure is a white flash for dark-mode users with no error anywhere. Editing that script means editing all three by hand and reloading Caddy. ## 5. Verify ```bash curl -s https://primeintellectgrowth.com/api/health # {"ok":true,"service":"pig","version":"0.1.0"} ``` Check the **public origin**, not just `127.0.0.1:8920`. The `bind` failure above answers with a valid certificate, HTTP 200 and an empty body, which satisfies every check that only asks whether something responded. `scripts/deploy.sh` now asserts the body is non-empty and contains the application's mount point for this reason. ## Turning Piggy on Piggy is the in-app agent, and it is worth knowing what it is before you run it. It is a **Prime Agent session** — Prime Intellect's own agent harness, `@earendil-works/pi-coding-agent`, embedded as a library rather than shelled out to — holding **PIG's CRM tools and nothing else**. The harness is constructed with every built-in tool disabled (`noTools: 'all'`) and an explicit allowlist on top, so the model has **no shell, no filesystem access and no Python**. The live tool list is compared against that allowlist when a session starts and a mismatch is a startup error, so a future harness release cannot quietly widen it. Three things follow for an operator: - **It writes.** `PIGGY_AGENT_MODE` decides how: `read_only`, `confirm` (default — a change is proposed as a card and applied when a person clicks) or `auto`. Contracts, commitments, allocations and compliance records always require a click regardless. Every write runs as the calling user's own principal, so Piggy cannot reach a record its user could not, and the audit trail names the human. - **The model is chosen per conversation**, from a five-model picker defined in `apps/piggy/src/agent/models.json`. `PIGGY_AGENT_MODEL` is only the default for a user who has not chosen. - **Conversations are persisted** in `piggy_conversations` and `piggy_messages` (migration 0014), so an agent turn now depends on the schema being current. `scripts/deploy.sh` migrates from a one-off container before it starts either container, which is what keeps that true. It is **off by default** and nothing about it is configurable from the admin UI — every value below is read once, when the container boots. Three keys in `.env`, and all three are needed: ```bash PIGGY_ENABLED=true PRIME_API_KEY= PIGGY_INTERNAL_TOKEN= ``` The fourth thing the API needs, `PIGGY_INTERNAL_URL`, is already set to `http://piggy:8931` by `docker-compose.yml`. Set it in `.env` only for a Piggy running outside Compose. Then deploy as usual: ```bash bash scripts/deploy.sh ``` **Do not start the container by hand.** The service carries `profiles: ['piggy']`, and compose skips a profile-gated service *silently*: without the profile, `pull`, `build` and `up` behave as though it were not in the file, with no warning and a zero exit. `deploy.sh` reads `PIGGY_ENABLED` from `.env` and adds `--profile piggy` to the pull, the build, the `up` **and the rollback**, so the agent moves with the app. A Piggy started once with `--profile piggy up -d` and then forgotten is not covered by any of that: it keeps running the image of the day it was started, against a schema several migrations newer, which is the failure the comment at the top of `docker-compose.yml` is about. `deploy.sh` therefore compares the piggy container's image ID with the release's and rolls back if they differ. `scripts/autodeploy.sh` compares both containers' digests for the same reason. Without that, an old Piggy is invisible to the poller: the app matches the newest tag, the poller says "up to date" every five minutes, and the agent runs last month's code indefinitely. ### A missing API key crash-loops the worker `PRIME_API_KEY` (or its alias `PIGGY_INFERENCE_API_KEY`) is required by `apps/piggy/src/config.ts`. Without either the process exits at boot with `Invalid Piggy configuration: PRIME_API_KEY ... is required.`, and `restart: unless-stopped` starts it again — so the symptom is a container restarting every few seconds, not an error anyone sees in the CRM. `PIGGY_INTERNAL_TOKEN` shorter than 32 characters fails the same way, and so does a `PIGGY_AGENT_MODEL` that is not one of the five ids in `apps/piggy/src/agent/models.json` — rejected at boot on purpose, because the alternative is a model that 404s on a user's first question. `deploy.sh` catches all of them: it waits for the container to report healthy and exits 3 if it does not, deliberately **without** rolling back, because the previous image reads the same `.env` and would fail identically. **A blank line is not an absent one, and here it is actively misleading.** `PRIME_API_KEY` and `PIGGY_INFERENCE_API_KEY` are two spellings of one key, and each is declared `.min(1).optional()`. Compose passes a blank `.env` line through as the empty string, so a file that sets `PRIME_API_KEY` correctly *and* carries a leftover empty `PIGGY_INFERENCE_API_KEY=` line crash-loops Piggy with: ``` Invalid Piggy configuration: PIGGY_INFERENCE_API_KEY: String must contain at least 1 character(s) ``` — a message about the key you did not use. **Comment the unused spelling out.** Before enabling Piggy on an existing host, check for exactly this: ```bash grep -nE '^(PRIME_API_KEY|PIGGY_INFERENCE_API_KEY)=$' /opt/pig/.env ``` Any line that prints is one to comment out. ### The thinking-level trap — read this before changing the model The single setting most likely to make a working deployment look broken. The harness defaults `thinkingLevel` to `medium`, which is tuned for a coding agent. On the default model that produced **6,195 output tokens of reasoning and an empty answer**: the turn hit its token ceiling while still thinking and came back with `finish_reason: length`. `low` was worse. `off` is Piggy's default, and for the nemotron models it maps to the endpoint's `reasoning_effort: none` — the same question then answered correctly in **149 output tokens**. The mapping is **per model** and lives in `thinkingLevelMap` in `apps/piggy/src/agent/models.json`. The two nemotron entries have one; deepseek, opus and gpt-5.6 do not, and for them `off` omits `reasoning_effort` entirely so the endpoint's own default applies. So if you change `PIGGY_AGENT_MODEL` and start getting empty answers, truncated answers or a surprising bill, this is where to look — not at the agent, the tools or the network. Give the new model a `thinkingLevelMap` before raising `PIGGY_AGENT_THINKING`. ### Where the harness is allowed to look at the filesystem `PIGGY_AGENT_DIR` is the harness's own directory. `docker-compose.yml` pins it to `/var/lib/piggy-agent`, which the image creates owned by the unprivileged `node` user at mode 0700. Leave it alone. Two reasons it is not the default `~/.pig/piggy-agent`: - **Writability.** Under `docker run` with `USER node`, `~` resolves to `/home/node` and works. That is incidental: a runtime that starts this image with a numeric user and no matching passwd entry (`runAsUser: 1000` under Kubernetes) leaves `HOME` unset, `os.homedir()` falls back to `/`, and the agent dies creating its directory — on the first turn, long after the deploy reported success. - **Prompt containment.** The harness discovers extensions, skills and context files from its cwd, and Piggy hands it this directory as cwd. Point it at the checkout, or bind-mount a repository over it, and source files become reachable from a CRM agent's prompt. **Never bind-mount anything here.** `deploy.sh` reads the value back off the running container and refuses to report success if it sits inside `/app`. The directory is **not persisted**, deliberately. Nothing in it is worth keeping across a restart: Piggy rewrites `models.json` there 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 cold start costs nothing measurable, and a volume would only be a way for a file to outlive the image that wrote it. ### Health The piggy container has its own healthcheck, against the chat server's unauthenticated `GET /internal/health`: ```bash docker compose -p pig --profile piggy ps # NAME STATUS # pig-piggy-1 Up 2 minutes (healthy) docker compose -p pig --profile piggy exec piggy \ node -e "fetch('http://127.0.0.1:8931/internal/health').then(r=>r.text()).then(console.log)" # {"ok":true,"service":"piggy-chat","model":"nvidia/nemotron-3-nano-30b-a3b"} ``` It needs its own because the image's `HEALTHCHECK` asks for `127.0.0.1:8920/api/health` — the API's port, which this container does not serve. Inherited unchanged, Piggy reported `unhealthy` for ever while working perfectly. There is no published port and there must not be one. The listener is reachable only from inside the Compose network, the API authenticates the user before forwarding anything, and the bearer token goes in a header — never a query string, where a proxy or an access log would keep it. ### What the image carries for the agent Two things about the production image are worth knowing before you debug a container that will not start. **`models.json` is a runtime file, not a compiled-in constant.** `apps/piggy/src/agent/models.json` is read from disk at boot, validated, and copied into the agent directory for the harness to register its provider from. It reaches the image inside `COPY apps/piggy`, and the Dockerfile parses it during the build so that a narrowed `COPY` or a new `.dockerignore` rule fails there rather than at 03:00 in a crash loop. **Production dependencies are installed with `--ignore-scripts`.** The Prime Agent SDK drags in a large transitive tree, including `@google/genai` and `protobufjs`; their install scripts — and esbuild's — are denied in `pnpm-workspace.yaml` on purpose, so nothing a dependency pulls in can execute code at install time. Piggy talks to exactly one provider over an OpenAI-compatible API and none of that tree is on a path it executes. The Dockerfile imports the SDK during the build to prove the scriptless install still yields a loadable agent, and the container has been run end to end against a live key: health, a tool call, and a correct answer. ### Tuning The agent's own settings, all with defaults in `apps/piggy/src/config.ts`: | Variable | Default | What it does | |---|---|---| | `PIGGY_AGENT_MODEL` | `nvidia/nemotron-3-nano-30b-a3b` | The default answer model. Must be one of the ids in `apps/piggy/src/agent/models.json`; anything else is refused at boot | | `PIGGY_AGENT_MODE` | `confirm` | `read_only`, `confirm` or `auto`. Contracts, commitments, allocations and compliance always confirm regardless | | `PIGGY_AGENT_THINKING` | `off` | See [the thinking-level trap](#the-thinking-level-trap--read-this-before-changing-the-model) before touching it | | `PIGGY_AGENT_MAX_TOKENS` | `4096` | Output tokens per agent turn, reasoning included. Clamped down to the chosen model's own ceiling | | `PIGGY_AGENT_DIR` | `~/.pig/piggy-agent` | Pinned to `/var/lib/piggy-agent` by `docker-compose.yml`. Do not override under Compose | The pre-agent settings still apply to the queue worker and exist to be lowered: `PIGGY_MAX_TOKENS` (per queued task), `PIGGY_CHAT_MAX_TOKENS` (per interactive answer), `PIGGY_MAX_TURNS`, `PIGGY_POLL_INTERVAL_MS`, `PIGGY_LEASE_SECONDS`, `PIGGY_REASONING_EFFORT` and the two `PIGGY_PRICE_*_CENTS_PER_MTOK` values that make the cost recorded against each run exact. `.env.example` lists them commented out, and that is not decoration: an empty `PIGGY_MAX_TOKENS=` line is passed to the container as the empty string, which coerces to 0 and refuses to start. Leave a key commented to get its default; do not leave it blank. ### The database is the only undo for an agent write Piggy can modify CRM records, so a release that ships a broken write tool can corrupt data no migration ever touched. `scripts/deploy.sh` dumps the database before it migrates and before the new image starts, to `backups/` next to the checkout: ``` backups/pig-20260814-030201.sql.gz ``` That directory is in `.gitignore`, so `autodeploy.sh`'s forced checkout at a release tag leaves it alone. The dump is verified rather than assumed: the script decompresses it and refuses to migrate if a database with tables in it produced less than 4 KB of SQL, because `pg_dump | gzip` turns silence into a plausible-looking 20-byte file and a deploy log that claims a backup was taken. Nothing prunes these. They are the only copy — copy them off the host if the data matters as much as the uptime does. Changing any of them means restarting the container — `bash scripts/deploy.sh`, or `docker compose -p pig --profile piggy up -d piggy` if the release is otherwise unchanged. ## Learn videos PIG hosts its own Learn videos. There is no video service to configure, no embed host and — because a native `