cpatpa builds a one-command server install for PIP

A long arc of work turns a bare Ubuntu box into a running deployment with a single command - and the operator tools to keep it alive.

infrastructuresecurity

cpatpa has been quietly building what most legal-AI forks lack: a real on-premise install story. The flow starts with a guided setup that asks the operator the questions a law firm actually cares about - hostname, TLS choice, admin email, database password, whether to disable external AI providers for client-matter work, and whether to run a local language model on the box or point at one elsewhere. By default, external AI is off and the certificate is self-signed, so a firm can stand the system up on its own network without leaking matter data or fighting public DNS.

The second half of the arc is operator hygiene: a status command that shows whether the deployment is actually serving traffic, a one-shot admin-password reset, and a clean uninstall for redeploys. Several commits are scar tissue from running this against real machines - the kind of fixes you only write after a deploy has failed at 11pm.

So what If you're a firm or legal-ops lead who wants AI tools running inside your own walls rather than in someone else's cloud, this is the deployment shape worth watching.

View this fork on GitHub →

Spotted something wrong? Or know the PR text has fresher detail than the writeup above?

Commits in this thread

14 commits from cpatpa/PIP, oldest first. Source extracted verbatim from the harvested git log.

SHA Subject Author Date
b95989aa Add guided installer for a bare Ubuntu/Debian server Claude 2026-05-15 ↗ GitHub
commit body
`sudo bash install.sh` takes a clean Ubuntu Server 22.04+ or Debian 12+
host from nothing to a running PIP deployment.

Wizard (whiptail TUI):
  - Hostname (any DNS name or LAN address) and TLS mode:
      letsencrypt   public Let's Encrypt; requires public DNS
      internal      Caddy local-CA self-signed; works anywhere
      http          HTTP only, port 80; first-run/LAN testing only
    The choice writes the Caddyfile at ${DATA_ROOT}/caddy/Caddyfile
    so re-running the installer can switch modes without touching
    the git working tree.
  - Bootstrap admin email and data root (any path; default /srv/pip).
  - Postgres password (auto-generated by default; overridable).
  - Email (Resend), External AI providers (disabled by default for
    client matter), Microsoft Entra OIDC. All skipable.
  - Ollama starter model: llama3.2:3b / llama3.1:8b / qwen2.5:14b /
    skip.

Secrets handling (point 3 from the operator brief):
  - AUTH_SECRET, USER_API_KEYS_ENCRYPTION_SECRET, DOWNLOAD_SIGNING_
    SECRET, and STORAGE_ENCRYPTION_KEY are generated automatically
    via openssl rand -hex 32.
  - In addition to .env.compose (mode 600), a separate
    ${DATA_ROOT}/secrets-backup.txt is written (mode 400) with a
    prominent "COPY THIS OFF THE SERVER" warning explaining what
    each secret protects and the consequences of loss.
  - The final report tells the operator where to find the backup
    and what to do with it.

Web access before public TLS (point 4 from the operator brief):
  - "internal" mode lets the operator reach https://<hostname>/
    immediately with a browser TLS warning, even with no public DNS.
  - "http" mode skips TLS entirely for first-run smoke testing.
  - The Caddyfile is regenerated each run; switching from internal
    to letsencrypt later is a re-run away.

Idempotency:
  - Detects existing .env.compose and offers reuse-or-replace.
  - Detects existing Docker install and skips the apt path.
  - UFW (if active) opens 80/443; SSH untouched.

Companion scripts:
  - update.sh: git pull + rebuild backend/frontend + recreate.
    Backend entrypoint runs pending migrations on boot.
  - bin/backup-now.sh: one-shot pg_dump into ${DATA_ROOT}/backups.

Compose change:
  - caddy/Caddyfile renamed to caddy/Caddyfile.example (reference).
  - Caddy now mounts ${DATA_ROOT}/caddy/Caddyfile (operator-owned).

README updated with the new install path and an explanation of
each wizard step.

Syntax-checked with bash -n; rendered all three Caddyfile variants
to confirm they are valid Caddy syntax.
76ef5f4f Default to self-signed TLS in the installer Claude 2026-05-15 ↗ GitHub
commit body
'internal' is now the pre-selected option in the TLS mode wizard.
Caddy generates a local-CA cert on first run; the deployment works
with any hostname (including a LAN address or an IP) and the
operator can sign in immediately. Switching to Let's Encrypt is a
re-run of install.sh once public DNS is in place.
0db16e4c Fix install.sh ownership of bind-mounted state dirs Claude 2026-05-15 ↗ GitHub
commit body
The backend container runs as uid 10001 (Dockerfile creates pip user)
and the backup sidecar runs as the alpine postgres user (uid 70).
install.sh was leaving ${DATA_ROOT}/storage and ${DATA_ROOT}/backups
owned by root:root which made encrypted blob writes and nightly
pg_dump fail on a fresh deployment. chown them to the right uids
before bringing the stack up.

Also fixes a stale "mikeApi" reference in the session-token route
comment.
b3ad3540 Support remote Ollama servers (or none) at install time Claude 2026-05-15 ↗ GitHub
commit body
The bundled ollama service in docker-compose.yml moves behind a
new `local-ollama` profile, so docker compose only starts the
container when COMPOSE_PROFILES includes that profile.

install.sh now asks how PIP should reach Ollama:
  local   bundled in this compose stack (previous default)
  remote  point at an existing Ollama server URL
  none    no local AI, external providers only

The choice writes OLLAMA_MODE, LOCAL_LLM_BASE_URL, and
COMPOSE_PROFILES into .env.compose. The compose wrapper in
install.sh and update.sh sources the env file so subsequent
docker compose invocations honour the profile. The model-pull
wizard step and image pull are skipped when not in local mode.

The bundled image is also no longer in the unconditional pull
batch; remote/none deployments shouldn't have to download a
~1 GB image they'll never run.

Also fixes .gitignore so .env.compose.example is tracked (the
file is referenced by docker-compose.yml's header comment but
was being silently ignored by the broad .env.* rule).
a10c7ac5 Fix backend docker build: include docker-entrypoint.sh Claude 2026-05-15 ↗ GitHub
The Dockerfile COPYs docker-entrypoint.sh into the runtime image
but backend/.dockerignore was excluding the file from the build
context, so the COPY failed with "/docker-entrypoint.sh: not found"
on a fresh install.
8bb78320 Richer final report from install.sh Claude 2026-05-15 ↗ GitHub
commit body
The post-install summary now prints:

- Configured URL plus a live HTTP probe through Caddy so operators
  see immediately whether the stack is actually reachable.
- Every non-loopback IPv4 the host advertises (with interface
  name) so they know which addresses work on the LAN before DNS is
  set up.
- A colour-coded service status table built from docker compose ps
  (NAME, SERVICE, STATE, HEALTH, PORTS).
- A complete configuration block: TLS mode + explanatory note,
  Ollama mode and URL, external AI status with which provider keys
  were supplied, Resend, Entra OIDC.
- The secrets backup path with the standing "copy this off the
  server" warning.
- An operate / files cheatsheet so common follow-ups are at hand.

The probe hits the public root URL (not /api/health which doesn't
exist externally) and treats any 2xx/3xx as reachable.
2c726b5f Caddy internal mode: cover host IPs and reload on rewrite Claude 2026-05-15 ↗ GitHub
commit body
When TLS_MODE=internal the generated Caddyfile only listed the
configured PIP_DOMAIN in the site block, so hitting the box by IP
(common during initial bootstrap before DNS is wired up) tripped
ERR_SSL_PROTOCOL_ERROR in the browser because Caddy had no matching
site for that SNI.

write_caddyfile now expands the site block to include every
non-loopback IPv4 the host advertises plus localhost / 127.0.0.1.
Caddy issues internal-CA certs covering all of them, so the TLS
handshake succeeds for whichever address the operator hits. The
browser still warns on the self-signed cert (expected); the user
can click through and proceed.

bring_up_stack now restarts the Caddy container after `up -d` so
re-runs of install.sh against an existing deployment pick up the
regenerated Caddyfile (the bind-mount changing alone doesn't make
compose recreate the service).
56e15d04 Internal TLS: pin to a pre-generated self-signed cert Claude 2026-05-15 ↗ GitHub
commit body
The previous attempt at supporting raw IPs in the Caddy site block
relied on Caddy's auto-TLS issuing internal-CA certs for IPs, which
is finicky and was still producing ERR_SSL_PROTOCOL_ERROR in the
field.

Switch to a deterministic pattern: at install time, generate a
self-signed cert with SAN entries covering PIP_DOMAIN, every host
IPv4, localhost, and 127.0.0.1. The Caddyfile binds :443 to that
cert explicitly, so any address the operator hits gets a usable
TLS handshake. Browsers still warn on the self-signed cert (the
expected click-through) instead of refusing the connection at the
protocol layer.

Also add a :80 redirect block in internal mode so plain http://
URLs land on https:// automatically.
d22d1113 Add bin/pip-status.sh health check + control panel Claude 2026-05-16 ↗ GitHub
commit body
Single command for operators to see the deployment at a glance:

- Public-URL probe through Caddy
- Compose service status (NAME / SERVICE / STATE / HEALTH / PORTS)
- Container resource snapshot from docker stats (CPU%, mem usage,
  mem%, network I/O, block I/O), filtered to compose-managed
  containers for this project
- Host disk: DATA_ROOT filesystem free space plus per-subdir sizes
  for postgres/, storage/, ollama/, caddy/, backups/
- Host load / memory / uptime

Modes:
  bin/pip-status.sh                  status + interactive menu (TTY)
  bin/pip-status.sh --status         one-shot, no menu
  bin/pip-status.sh --restart NAME   status, then compose restart NAME
  bin/pip-status.sh --restart-all    status, then compose restart all
  bin/pip-status.sh --watch          refresh every 2s, ctrl-C to exit

Interactive menu numbers services so the operator picks by index
rather than typing the full container name. Restart-all prompts for
confirmation. Logs option tails the last 50 lines of any service.
afa7e87a Add bin/pip-reset-admin.sh to reset the bootstrap admin password Claude 2026-05-16 ↗ GitHub
commit body
Operators who lose the bootstrap password, or want to rotate it
after first sign-in, can now run:

    sudo bash bin/pip-reset-admin.sh             # interactive
    sudo bash bin/pip-reset-admin.sh --generate
    sudo bash bin/pip-reset-admin.sh --password '<value>'

The script:
- Hashes the new password via the running backend container's
  bcryptjs (cost 12, identical to the application path).
- UPDATEs users.password_hash for BOOTSTRAP_ADMIN_EMAIL, clears any
  pending password-reset token, and re-activates the row if it was
  marked disabled.
- Errors out if exactly 1 row wasn't updated, so a typo in the
  email or a missing user fails loudly.
- Writes a user.password.reset audit event.
- Mirrors the new value into ${DATA_ROOT}/secrets-backup.txt and
  .env.compose so installer reruns stay consistent.

Also added a [p] entry to the interactive menu in pip-status.sh so
the same flow is one keypress away from the status screen.
510c379c install.sh: don't silently change POSTGRES_PASSWORD on a re-run Claude 2026-05-16 ↗ GitHub
commit body
The official postgres:alpine image only honours POSTGRES_PASSWORD
when it initialises an EMPTY data directory. If install.sh is
re-run (or the wizard re-runs and chooses "replace") while
${DATA_ROOT}/postgres already holds an initialised cluster,
regenerating POSTGRES_PASSWORD will deauth the backend against the
existing role -> migration crash loop with
"password authentication failed for user 'pip'".

Detect that case up front:
- If ${DATA_ROOT}/postgres is non-empty AND .env.compose already
  had a POSTGRES_PASSWORD value, default to reusing it.
- If the operator deliberately wants a new password, surface a
  warning with the ALTER USER one-liner needed to keep the existing
  data, or the wipe instructions to start fresh.
0f2b965c Bake PUBLIC_URL into the frontend bundle at build time Claude 2026-05-16 ↗ GitHub
commit body
Next.js inlines NEXT_PUBLIC_* env vars into the client JS bundle at
`npm run build` time; they're not read at runtime. The frontend
Dockerfile wasn't accepting that value as a build arg, so the
client bundle was always built with the source-default fallback of
http://localhost:3001 and the browser tried to hit localhost for
every API call (visible in DevTools as ERR_CONNECTION_REFUSED on
/me, /me/onboarding-options, /user/profile, etc.).

Add an ARG NEXT_PUBLIC_API_BASE_URL to the frontend build stage and
wire docker-compose.yml to pass ${PUBLIC_URL} into it. The runtime
env block already mirrors the same value for server-side fetches.

After this lands, `update.sh` rebuilds the frontend image with the
right URL baked in.
b4a727b0 Add bin/pip-uninstall.sh for clean test redeploys Claude 2026-05-16 ↗ GitHub
commit body
Tears down the compose stack (containers, volumes, network), drops
the built backend/frontend images by default, removes ${DATA_ROOT}
(postgres, storage, ollama, caddy, backups, secrets-backup.txt) and
.env.compose, with confirmations before each destructive step.

Flags: --yes, --keep-data, --keep-images, --prune. README updated
alongside pip-status / pip-reset-admin so the new script is
discoverable.

Intended for test hosts and pre-go-live rebuilds. The header carries
a clear irreversible warning given the deployment holds documents
and the encryption key.
978ea483 update.sh: regenerate Caddyfile so template changes land on hosts Claude 2026-05-16 ↗ GitHub
commit body
Caddyfile generation lived inline in install.sh, which meant that
operators who pulled a Caddy-template change and ran update.sh got
the new backend / frontend code but kept their old Caddyfile. The
recent path+Accept-header routing fix did not reach a running
deployment until install.sh was re-run.

Extracted the generator into bin/pip-write-caddyfile.sh, a
standalone script that reads TLS_MODE / PIP_DOMAIN / DATA_ROOT from
.env.compose. install.sh now delegates to it (single source of
truth, no template drift) and update.sh calls it on every run so
operators do not need to know to re-run install.sh after a code
change to the proxy config.

The bootstrap self-signed cert generator moved into the same script
and gained an existing-cert check so update.sh runs do not
needlessly regenerate the cert on every invocation. install.sh's
now-dead detect_host_ips and generate_bootstrap_cert helpers
removed.

To apply on a running deployment that hit the recent admin /
projects / workflows JSON-401 issue:

  cd /opt/pip
  sudo git pull origin main
  sudo bash bin/pip-write-caddyfile.sh

That writes the new Caddyfile and reloads caddy without touching
the rest of the stack.

Capture this thread into my fork

Download a single Markdown prompt that tells Claude how to port every commit above into your working tree — adapting paths and structure to match your repo. Run it via claude -p < capture-thread-368.md from inside the repo you want the changes in.

⬇ Download capture-thread-368.md