Files
paperclip-docs/docs/reference/deploy/docker.md
Aron PrinsandClaude Opus 5 325c9eb819 seo: hand-write titles and descriptions for all 192 pages (#115)
## Why

The build derived `<title>` from the **sidebar label** and the
description from the **first paragraph clipped to 220 characters**. Both
are the wrong source. A sidebar label is written for a 240px-wide nav
that supplies its own context; a search result has none.

On production that produced:

- **7 pages titled "Overview"**, and 32 pages sharing a title with
another page
- **109 of 193 descriptions cut off mid-word**, and 174 over 160
characters (where Google truncates)

```
/guides/welcome/key-concepts/
  BEFORE (220):  ...and if you misunderstand them early, the product wi
```

Near-identical titles are the weak-differentiation signal behind
"crawled — currently not indexed", which is the exact symptom on this
property.

## What changed

`seo_title` and `seo_description` frontmatter on **every one of the 192
pages**, written by hand against each page's real content and optimised
for the click — lead with what the reader can do, name the concrete
things covered, no keyword stuffing, no repeating the title.

| | Before | After |
|---|---|---|
| Duplicate titles | 32 pages | **0** |
| Duplicate descriptions | — | **0** |
| Descriptions cut mid-word | 109 | **0** |
| Descriptions over 160 chars | 174 | **0** |
| Title length (incl. suffix) | up to "Overview" | **26–60** |
| Description length | 31–240 | **122–158** |

**No prose was rewritten.** I measured content overlap between the
colliding pairs first — only 17–23%, so these were always distinct pages
wearing the same label, not duplicate content. The `docs/` diff is **564
insertions, zero deletions**.

### Examples

```
BEFORE — all seven identical:  "Overview | Paperclip Docs"
AFTER:  "Adapters Overview"      "API Overview"        "CLI Overview"
        "Deployment Overview"    "Bundled Skills"      "Optional Skills"
        "Experimental Features"

BEFORE — both identical:  "Issues | Paperclip Docs"
AFTER:  "Issues: Creating and Tracking Work"   /guides/day-to-day/issues/
        "Issues API"                           /reference/api/issues/
```

```
/guides/getting-started/five-minute-path/
BEFORE (220):  The shortest route from a running Paperclip instance to an agent that has
               just completed work for you. After Paperclip is installed, the path takes
               about 5 minutes: create a company, hire a CEO agent, approve its first s
AFTER  (131):  Install Paperclip, create a company, hire a CEO agent, and approve its
               first strategy — a working AI company in about five minutes.
```

## Plumbing

- **`site/build-release.mjs`** prefers the authored fields; the derived
path stays only as a fallback the CI gate prevents ever reaching.
`<title>`, `<meta name="description">`, and the `TechArticle` JSON-LD
all read from the same source.
- **`site/app.js`** uses the same fields, so a client-side transition
cannot leave a different title on screen than the crawler was served.
Verified in-browser: navigating `/guides/day-to-day/issues/` →
`/reference/api/issues/` swaps to exactly the server-rendered values, no
JS errors.
- **`scripts/verify-seo-metadata.mjs`** (new) enforces presence,
uniqueness, length, complete-sentence endings, and the
frontmatter-safety rules. Wired into `docs:test` and CI as its own step.

I negative-tested the gate rather than assuming it works — it correctly
fails on a duplicate title, a mid-word description, and a missing field,
and passes again once restored.

## Automating it going forward

`skills/sync-docs/SKILL.md` now:

- gains an **"Authored SEO metadata"** section: why the fields exist,
the enforced rules as a table, and how to actually write them (title
names the subject not the nav position; description writes for the
click; never generate mechanically from the first paragraph — *that is
the fallback the gate exists to prevent*).
- requires both fields in the **page-authoring instruction** given to
every subagent, with guidance on when to update an existing description
versus leave it.
- runs `docs:test:seo-metadata` and `docs:test:crawlable-links` as
**fail-loud gates in Phase 7** before commit.
- documents all six build gates in the files/gates section.

So once this lands, a new page cannot ship without hand-written
metadata, and a title collision cannot reach production.

## Verified

All six verifiers pass:

```
Static route verification passed
Asset fingerprint verification passed
Hierarchical Skills nav verification passed
Skill source block verification passed (11 skill pages, 1 control page)
Crawlable-link contract passed: 40886 internal links across 194 pages resolve in one hop
Authored-SEO contract passed: 192 pages carry a unique, hand-written seo_title and seo_description
```

🤖 Generated with [Claude Code](https://claude.com/claude-code)

---------

Co-authored-by: Claude Opus 5 (1M context) <[email protected]>
2026-08-25 22:51:42 +02:00

4.8 KiB

paperclip_version, seo_title, seo_description
paperclip_version seo_title seo_description
v2026.609.0 Deploy Paperclip with Docker Run a self-contained instance without installing Node or pnpm on the host. Covers the quickstart image, manual builds, and configuration.

Docker

Use Docker when you want a self-contained Paperclip instance without installing Node or pnpm on the host machine.

This page covers the quickstart image, the manual image build, the cloud image variant, and what persists between container restarts.


Compose Quickstart

The recommended path is the compose-based quickstart:

docker compose -f docker/docker-compose.quickstart.yml up --build

Open the app at:

http://localhost:3100

Defaults:

  • host port 3100
  • data directory ./data/docker-paperclip

Override them with environment variables:

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 in docker/, so ../data/pc maps to data/pc in the repository root.


Manual Image Build

If you want a plain container run instead of compose, build and start the image manually:

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

Use this when you want tight control over the container lifecycle or are embedding Paperclip into a larger Docker workflow.

Note: Name the production stage explicitly. The Dockerfile declares a cloud stage after production, and a build with no --target picks the last stage in the file — so leaving it off would quietly give you the cloud variant instead. Paperclip's own published image is pinned the same way.


Cloud Image Variant

Paperclip publishes a second image alongside the regular one: the cloud variant. If you are self-hosting, you almost certainly want the plain tag. The cloud variant exists for managed deployments and carries extra weight you do not need.

The only difference is what is pre-built inside it. The cloud variant is the production image plus a small set of sandbox-provider plugins that have already been compiled. Managed instances receive a plugins.autoInstall list through PAPERCLIP_MANAGED_CONFIG and install those plugins from the bundled catalog at boot, which works only when each plugin's dist/ output is already present in the image. The default image ships the plugin source but not the build output, so auto-install skips those plugins and logs that the bundle is not present.

Cloud images are published under the same tag set as the regular image, each tag carrying a -cloud suffix:

  • sha-<short>-cloud
  • latest-cloud
  • <version>-cloud

You can build the variant yourself by naming the cloud stage:

docker build --target cloud -t paperclip-cloud .

Which plugins get built in is controlled by the CLOUD_BUNDLED_PLUGINS build argument — a space-separated list of directory names under packages/plugins/sandbox-providers. It defaults to daytona:

docker build --target cloud \
  --build-arg CLOUD_BUNDLED_PLUGINS="daytona" \
  -t paperclip-cloud .

Every name you add pulls that plugin's dependencies into the image, so keep the list to what your deployment actually auto-installs. An unknown directory name fails the build rather than shipping a variant that is quietly missing a plugin.


What Persists

All persistent data lives under the bind mount:

  • embedded PostgreSQL data
  • uploaded assets
  • the local secrets key
  • agent workspace data

If the bind mount is removed, the instance starts fresh on the next run.


LLM Adapter Support

The Docker image pre-installs the local CLI tools used by the built-in local adapters, so they work inside the container out of the box:

  • claude for claude_local
  • codex for codex_local
  • opencode for opencode_local
  • gemini for gemini_local

The image sets GEMINI_SANDBOX=false so the Gemini CLI runs safely inside the container without its own sandbox layer.

If you want those adapters to run inside the container, pass the relevant API keys:

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

Without those keys, the app still runs. The adapter environment test will simply report missing prerequisites for the relevant adapter.

Tip: If you are testing adapter behavior inside Docker, verify the bind mount first. Most surprising failures come from lost state, not the container image itself.