Files
PaperClipAI/doc/DOCKER.md
T
Devin FoleyandPaperclip 7435b2ee9c ci: cache compiled Docker Rust dependencies separately from source (#13329)
## Thinking Path

> - Paperclip is the open source app people use to manage AI agents for
work.
> - Paperclip Cloud deploys images that contain the native Rust Runner.
> - The image already builds that Runner before copying ordinary app
source.
> - A Rust source change still invalidates its entire compiled
dependency layer.
> - Compiled dependencies can survive source changes when their recipe
is unchanged.
> - This PR adds a separate locked dependency build before compiling the
real workspace.

## Linked Issues or Issue Description

Refs #13195. A search of related Docker and Cargo cache PRs found no
duplicate dependency-recipe change.

**What existing behavior does this improve?**

Docker image build time after Rust source or embedded protocol changes.

**Current behavior**

The `runner-build` stage compiles dependencies and workspace code in one
layer. In Cloud readiness run 34698143548, that stage took about 3m48s
when its cache was unavailable.

**Proposed behavior**

Generate a recipe with pinned cargo-chef 0.1.73. Build locked release
dependencies in `runner-deps`, then copy and compile real Rust source
and embedded protocol inputs in `runner-build`. Source edits can reuse
the dependency layer from the existing registry cache.

**Reason and benefit**

Reduce dependency recompilation during source changes and merge bursts.
Expected savings are roughly 2–4 minutes when the old native layer would
miss but dependency layers are available. Full cold builds also pay for
the recipe tool installation. Ordinary app-only cache hits gain little
from this change.

**Breaking changes**

None to the shipped application or image tags. The recipe tool and
compiled dependencies remain in build stages.

## What Changed

- Install a pinned recipe generator with its locked dependencies and the
existing package-owned compiler.
- Add recipe planning and compiled dependency stages. Use the same
release profile, package, binary, and lockfile enforcement as the real
native build.
- Remove generated source stubs before copying actual source. Preserve
protocol inputs, timestamp normalization, binary staging, and
application checks.
- Add Docker cache wiring regressions and update the Docker cache
documentation.
- Run a two-build probe in Docker Runner check. It requires dependency
reuse, changed real binary metadata after a source edit, and a changed
recipe after a dependency declaration edit. It uses a disposable
tracked-source context and exports only small metadata files.

## Verification

- Passed all five Docker build-stamp and dependency-cache tests with
`pnpm exec vitest run server/src/__tests__/docker-build-stamp.test.ts`.
- Passed the local ARM64 `docker buildx build --target runner-build
--progress plain`. Local Docker then hit storage errors during a runtime
probe; cache invalidation verification continues on GitHub-hosted Linux.
- Passed `bash -n scripts/check-docker-runner-cache.sh`, `actionlint`,
and `git diff --check`.
- Passed a [Linux AMD64 cache
probe](https://github.com/paperclipai/paperclip/actions/runs/34711042199)
against the PR source: dependencies compiled in 3m49s for the baseline
and were `CACHED` after a source edit; real source compilation took
about 37 seconds. Binary metadata changed and dependency declaration
changes altered the recipe. The permanent probe is also running in
latest-head Docker Runner check.
- Passed latest-head [Docker Runner
check](https://github.com/paperclipai/paperclip/actions/runs/34711145160),
including the permanent source/dependency invalidation probe.
- Passed full [PR
verification](https://github.com/paperclipai/paperclip/actions/runs/34711145352/attempts/2):
typecheck, all grouped tests, native verification, build, release dry
run, and browser checks. One unrelated signoff-policy browser test
failed waiting for a heartbeat run on attempt 1; only that failed shard
and dependent checks were retried, and passed.
- Latest-head Greptile is 5/5 with no unresolved findings. Full local
tests/build were limited by local disk exhaustion; Linux CI completed
those checks.

## Risks

- The two-build CI probe has a 20-minute job limit to cover the cold
build and source rebuild. It adds no AWS routing.
- A fully cold build must install cargo-chef and populate the dependency
layer. Both become reusable registry layers; no Actions cache is added.
- The recipe and final build must keep the same compiler, build profile,
package, binary, and directory layout. A source-change rebuild probe
checks real cache reuse and binary invalidation.
- Dependency or compiler changes still require rebuilding dependencies.
Existing image verification and full-SHA publication gates remain
unchanged.

## Model Used

OpenAI GPT-6 through Codex, with reasoning, repository tools, and code
execution. The exact serving model ID and context window are not exposed
by this environment.

## Checklist

- [x] I have included a thinking path that traces from project context
to this change
- [x] I have specified the model used (with version and capability
details)
- [x] I have checked ROADMAP.md and confirmed this PR does not duplicate
planned core work
- [x] I have searched GitHub for duplicate or related PRs and linked
them above
- [x] I have either (a) linked existing issues with `Fixes: #` / `Closes
#` / `Refs #` OR (b) described the issue in-PR following the relevant
issue template
- [x] I have not referenced internal/instance-local Paperclip issues or
links (only public GitHub `#NNN` / `github.com/paperclipai/paperclip`
URLs)
- [x] My branch name describes the change (e.g. `docs/...`, `fix/...`)
and contains no internal Paperclip ticket id or instance-derived details
- [x] I have run tests locally and they pass
- [x] I have added or updated tests where applicable
- [x] I have updated relevant documentation to reflect my changes
- [x] I have considered and documented any risks above
- [x] All Paperclip CI gates are green
- [x] Greptile is 5/5 with no open P2s, recommendations, or follow-ups
- [x] I will address all Greptile and reviewer comments before
requesting merge

---------

Co-authored-by: Paperclip <noreply@paperclip.ing>
2026-09-12 11:57:32 -07:00

16 KiB

Docker Quickstart

Run Paperclip in Docker without installing Node or pnpm locally.

All commands below assume you are in the project root (the directory containing package.json), not inside docker/.

Building the image

docker build -t paperclip-local .

The Dockerfile installs common agent tools (git, gh, curl, wget, ripgrep, python3) and the Claude, Codex, and OpenCode CLIs.

Build arguments:

Arg Default Purpose
USER_UID 1000 UID for the container node user (match your host UID to avoid permission issues on bind mounts)
USER_GID 1000 GID for the container node group
CLI_TOOLS_CACHE_EPOCH empty Refresh the CLI-install layer; CI supplies the current ISO week
PAPERCLIP_BUILD_VERSION empty Runtime version when Git metadata is unavailable
PAPERCLIP_BUILD_COMMIT empty Source commit written into the server build stamp and runtime environment

Changing the build version or commit preserves the CLI-install cache. The tool layer refreshes when its weekly epoch, base image, installation command, or earlier build inputs change. Local builds can set a new epoch explicitly to refresh tools without clearing the entire build cache.

docker build -t paperclip-local \
  --build-arg USER_UID=$(id -u) --build-arg USER_GID=$(id -g) .

Cloud image addresses

The Docker workflow publishes the managed deployment image for Linux AMD64. Cloud readiness starts Docker cloud on each master push independently of the multi-platform self-hosted build. Different commits use separate concurrency groups and existing GitHub-hosted runners, so an older production or cloud build does not hold the new commit in a workflow queue. Available GitHub runner capacity still applies. Release tags and manual Docker dispatches call the same cloud build workflow.

Each commit exports to its own buildcache-cloud-<FULL_SHA> registry tag. Builds import the current commit and nine first-parent ancestors, plus the legacy buildcache-cloud fallback. This preserves reusable layers without letting concurrent builds overwrite one shared cache manifest. Retain recent cache tags if registry cleanup is configured; deleting them makes builds colder.

Cloud CI skips SDK and cache cleanup when both the Docker data filesystem and the checkout filesystem have at least 64 GiB available. Below that conservative headroom threshold, or when the measurement fails, it retains the existing cleanup. The threshold selects the fast path; it is not a new minimum disk requirement for local builds or smaller runners.

After the pushed image passes its Sentry and orphan-reaping checks, the workflow verifies its commit label and platform and adds ghcr.io/paperclipai/paperclip:sha-<full-commit-sha>-cloud. This address lets commit-based deployment tooling reuse the normal build. Existing short-SHA and release tags remain available.

The full-SHA tag identifies the source commit. It does not certify that source tests passed or that a compatible database migrator is available. Deployment tooling must still check those prerequisites and pin the resolved image digest; a rebuild of the same source can update the tag's digest.

The separate cloud readiness check combines source verification, successful cloud image checks, and exact-source migrator availability. It runs outside the full npm release's concurrency queue.

One-liner (build + run)

docker build -t paperclip-local . && \
docker run --name paperclip \
  -p 3100:3100 \
  -e HOST=0.0.0.0 \
  -e PAPERCLIP_HOME=/paperclip \
  -e BETTER_AUTH_SECRET=$(openssl rand -hex 32) \
  -e PAPERCLIP_TOOL_ACTION_SIGNING_SECRET=$(openssl rand -hex 32) \
  -v "$(pwd)/data/docker-paperclip:/paperclip" \
  paperclip-local

Open: http://localhost:3100

Data persistence:

  • Embedded PostgreSQL data
  • uploaded assets
  • local secrets key
  • local agent workspace data

All persisted under your bind mount (./data/docker-paperclip in the example above).

Docker Compose

Quickstart (embedded SQLite)

Single container, no external database. Data persists via a bind mount.

BETTER_AUTH_SECRET=$(openssl rand -hex 32) \
PAPERCLIP_TOOL_ACTION_SIGNING_SECRET=$(openssl rand -hex 32) \
  docker compose -f docker/docker-compose.quickstart.yml up --build

Defaults:

  • host port: 3100
  • persistent data dir: ./data/docker-paperclip

Optional overrides:

PAPERCLIP_PORT=3200 PAPERCLIP_DATA_DIR=../data/pc \
  docker compose -f docker/docker-compose.quickstart.yml up --build

Note: PAPERCLIP_DATA_DIR is resolved relative to the compose file (docker/), so ../data/pc maps to data/pc in the project root.

If you change host port or use a non-local domain, set PAPERCLIP_PUBLIC_URL to the external URL you will use in browser/auth flows.

Pass OPENAI_API_KEY and/or ANTHROPIC_API_KEY to enable local adapter runs.

Full stack (with PostgreSQL)

Paperclip server + PostgreSQL 17. The database is health-checked before the server starts.

BETTER_AUTH_SECRET=$(openssl rand -hex 32) \
  docker compose -f docker/docker-compose.yml up --build

PostgreSQL data persists in a named Docker volume (pgdata). Paperclip data persists in paperclip-data.

Untrusted PR review

Isolated container for reviewing untrusted pull requests with Codex or Claude, without exposing your host machine. See doc/UNTRUSTED-PR-REVIEW.md for the full workflow.

docker compose -f docker/docker-compose.untrusted-review.yml build
docker compose -f docker/docker-compose.untrusted-review.yml run --rm --service-ports review

Authenticated Compose (Single Public URL)

For authenticated deployments, set one canonical public URL and let Paperclip derive auth/callback defaults:

services:
  paperclip:
    environment:
      PAPERCLIP_DEPLOYMENT_MODE: authenticated
      PAPERCLIP_DEPLOYMENT_EXPOSURE: private
      PAPERCLIP_PUBLIC_URL: https://desk.koker.net

PAPERCLIP_PUBLIC_URL is used as the primary source for:

  • auth public base URL
  • Better Auth base URL defaults
  • bootstrap invite URL defaults
  • hostname allowlist defaults (hostname extracted from URL)

For fresh authenticated/private Docker or appliance-style installs, the first admin can now be claimed entirely from the browser after sign-in. Open the Paperclip URL, sign in or create an account, then choose Claim this instance on the setup screen. This browser claim is disabled for authenticated/public; public deployments should run the high-entropy CLI invite fallback instead:

pnpm paperclipai auth bootstrap-ceo

Granular overrides remain available if needed (PAPERCLIP_AUTH_PUBLIC_BASE_URL, BETTER_AUTH_URL, BETTER_AUTH_TRUSTED_ORIGINS, PAPERCLIP_ALLOWED_HOSTNAMES).

Set PAPERCLIP_ALLOWED_HOSTNAMES explicitly only when you need additional hostnames beyond the public URL host (for example Tailscale/LAN aliases or multiple private hostnames).

Optional Vercel Connect credentials

Vercel Connect's backend integration is retained for controlled testing and existing Vercel-backed connections, but its new-connection UI is currently withheld from Apps → Browse. Setting PAPERCLIP_VERCEL_CONNECT_ENABLED=true does not expose a customer-facing setup entry. Native provider setup screens remain unchanged. Vercel-hosted deployments use the workload OIDC token Vercel injects. Other hosted and self-hosted deployments can provide PAPERCLIP_VERCEL_CONNECT_ACCESS_TOKEN as a deployment bootstrap secret only when that token type is accepted by the live Connect API:

services:
  paperclip:
    environment:
      PAPERCLIP_VERCEL_CONNECT_ENABLED: "true"
      PAPERCLIP_VERCEL_CONNECT_ACCESS_TOKEN: ${PAPERCLIP_VERCEL_CONNECT_ACCESS_TOKEN}

Do not save that access token in a company secret or connection config. It is instance bootstrap authority for the operator-selected Vercel account. A token's long expiry and broad Vercel scope do not prove Connect compatibility; validate it with connector metadata before rollout. Workload OIDC takes precedence when both authorities are present. Turning the feature flag off hides new Vercel-backed setup; existing connections keep resolving while workload OIDC or the bootstrap token remains available. Missing or invalid authority fails closed. See the Vercel Connect operator guide.

Claude + Codex Local Adapters in Docker

The image pre-installs:

  • claude (Anthropic Claude Code CLI)
  • codex (OpenAI Codex CLI)

If you want local adapter runs inside the container, pass API keys when starting the container:

docker run --name paperclip \
  -p 3100:3100 \
  -e HOST=0.0.0.0 \
  -e PAPERCLIP_HOME=/paperclip \
  -e OPENAI_API_KEY=... \
  -e ANTHROPIC_API_KEY=... \
  -v "$(pwd)/data/docker-paperclip:/paperclip" \
  paperclip-local

Notes:

  • Without API keys, the app still runs normally.
  • Adapter environment checks in Paperclip will surface missing auth/CLI prerequisites.

Podman Quadlet (systemd)

The docker/quadlet/ directory contains unit files to run Paperclip + PostgreSQL as systemd services via Podman Quadlet.

File Purpose
docker/quadlet/paperclip.pod Pod definition — groups containers into a shared network namespace
docker/quadlet/paperclip.container Paperclip server — joins the pod, connects to Postgres at 127.0.0.1
docker/quadlet/paperclip-db.container PostgreSQL 17 — joins the pod, health-checked

Setup

  1. Build the image (see above).

  2. Copy quadlet files to your systemd directory:

    # Rootless (recommended)
    cp docker/quadlet/*.pod docker/quadlet/*.container \
      ~/.config/containers/systemd/
    
    # Or rootful
    sudo cp docker/quadlet/*.pod docker/quadlet/*.container \
      /etc/containers/systemd/
    
  3. Create a secrets env file (keep out of version control):

    cat > ~/.config/containers/systemd/paperclip.env <<EOL
    BETTER_AUTH_SECRET=$(openssl rand -hex 32)
    PAPERCLIP_TOOL_ACTION_SIGNING_SECRET=$(openssl rand -hex 32)
    POSTGRES_USER=paperclip
    POSTGRES_PASSWORD=paperclip
    POSTGRES_DB=paperclip
    DATABASE_URL=postgres://paperclip:paperclip@127.0.0.1:5432/paperclip
    # OPENAI_API_KEY=sk-...
    # ANTHROPIC_API_KEY=sk-...
    EOL
    
  4. Create the data directory and start:

    mkdir -p ~/.local/share/paperclip
    systemctl --user daemon-reload
    systemctl --user start paperclip-pod
    

Quadlet management

journalctl --user -u paperclip -f        # App logs
journalctl --user -u paperclip-db -f     # DB logs
systemctl --user status paperclip-pod    # Pod status
systemctl --user restart paperclip-pod   # Restart all
systemctl --user stop paperclip-pod      # Stop all

Quadlet notes

  • First boot: Unlike Docker Compose's condition: service_healthy, Quadlet's After= only waits for the DB unit to start, not for PostgreSQL to be ready. On a cold first boot you may see one or two restart attempts in journalctl --user -u paperclip while PostgreSQL initialises — this is expected and resolves automatically via Restart=on-failure.
  • Containers in a pod share localhost, so Paperclip reaches Postgres at 127.0.0.1:5432.
  • PostgreSQL data persists in the paperclip-pgdata named volume.
  • Paperclip data persists at ~/.local/share/paperclip.
  • For rootful quadlet deployment, remove %h prefixes and use absolute paths.

Onboard Smoke Test (Ubuntu + npm only)

Use this when you want to mimic a fresh machine that only has Ubuntu + npm and verify:

  • npx paperclipai onboard --yes completes
  • the server binds to 0.0.0.0:3100 so host access works
  • onboard/run banners and startup logs are visible in your terminal

Build + run:

./scripts/docker-onboard-smoke.sh

Open: http://localhost:3131 (default smoke host port)

Useful overrides:

HOST_PORT=3200 PAPERCLIPAI_VERSION=latest ./scripts/docker-onboard-smoke.sh
PAPERCLIP_DEPLOYMENT_MODE=authenticated PAPERCLIP_DEPLOYMENT_EXPOSURE=private ./scripts/docker-onboard-smoke.sh
SMOKE_DETACH=true SMOKE_METADATA_FILE=/tmp/paperclip-smoke.env PAPERCLIPAI_VERSION=latest ./scripts/docker-onboard-smoke.sh

Notes:

  • Persistent data is mounted at ./data/docker-onboard-smoke by default.
  • Container runtime user id defaults to your local id -u so the mounted data dir stays writable while avoiding root runtime.
  • Smoke script defaults to authenticated/private mode so HOST=0.0.0.0 can be exposed to the host.
  • Smoke script defaults host port to 3131 to avoid conflicts with local Paperclip on 3100.
  • Smoke script also defaults PAPERCLIP_PUBLIC_URL to http://localhost:<HOST_PORT> so bootstrap invite URLs and auth callbacks use the reachable host port instead of the container's internal 3100.
  • In authenticated mode, the smoke script defaults SMOKE_AUTO_BOOTSTRAP=true and drives the real bootstrap path automatically: it signs up a real user, runs paperclipai auth bootstrap-ceo inside the container to mint a real bootstrap invite, accepts that invite over HTTP, and verifies board session access.
  • Run the script in the foreground to watch the onboarding flow; stop with Ctrl+C after validation.
  • Set SMOKE_DETACH=true to leave the container running for automation and optionally write shell-ready metadata to SMOKE_METADATA_FILE.
  • Set SMOKE_CONTAINER_NAME to fix the container's name up front. Automation that has to collect diagnostics when the script fails needs a name it already knows, rather than one it can only read back out of a successful run. Defaults to the image name.
  • The container's logs are dumped to SMOKE_LOG_FILE (default $TMPDIR/<container name>.log) before the script tears the container down, so a run that never became ready still leaves its logs behind.
  • The image definition is in docker/Dockerfile.onboard-smoke.

General Notes

  • The docker-entrypoint.sh adjusts the container node user UID/GID at startup to match the values passed via USER_UID/USER_GID, avoiding permission issues on bind-mounted volumes.
  • Paperclip data persists via Docker volumes/bind mounts (compose) or at ~/.local/share/paperclip (quadlet).

Native Runner build cache

The image compiles the native Runner in runner-build, before copying the application source. A pinned cargo-chef generates a dependency recipe in runner-plan. The separate runner-deps stage compiles that recipe with the package-owned Rust compiler. Both the dependency build and the real binary use the release profile and locked Cargo dependencies. The recipe stage never modifies source in the checkout.

Changes to Rust source or embedded protocol inputs rebuild the real binary but can reuse compiled dependencies when the recipe is unchanged. Dependency manifests, the Cargo lockfile, target metadata, or compiler changes invalidate the relevant cache. Ordinary server or UI changes can reuse the entire native build through the existing registry cache (mode=max). Each platform gets its own native build; no cross-architecture binary is reused. No additional GitHub Actions cache is created. A cold build also installs the recipe generator and compiles dependencies, so the savings apply after those layers are available.

The application build inherits that stage and still runs the normal server build, including Cargo, binary staging, and generated-contract checks. Rust input file times are normalized in both stages so fresh checkouts do not force Cargo to rebuild unchanged source. Changes made by build scripts still reach Cargo's normal validation. The final application copy excludes Cargo's target directory as before. Cache misses only cost compilation time.

Pull requests that change the Dockerfile, Docker ignore rules, or Runner native inputs also build the isolated runner-build target in Docker Runner check. The check runs bash scripts/check-docker-runner-cache.sh against a disposable copy of tracked source and the actual Docker ignore rules. It compiles a baseline, changes a Rust metadata constant, and rebuilds. It requires a cached dependency build, an unchanged dependency recipe, and changed metadata from the real binary. It also verifies that a dependency declaration change alters the recipe. The probe exports only small metadata files, avoiding a large image import into the Docker daemon. It catches missing embedded inputs before the post-merge build. It uses a GitHub-hosted runner with read-only repository access and does not publish images or cache artifacts. Allow up to 20 minutes for its cold build and source rebuild.