Skip to main content

Paperclip Deployment Runbook

Owner: Operations Status: active Audience: engineering / operations Last tested: 2026-04-08

Purpose

Step-by-step guide to deploy Paperclip (the agent orchestrator) on a fresh or existing Ubuntu droplet. This covers both greenfield deployments and replacing an existing service (e.g. replacing OpenClaw with Paperclip).

Architecture

Browser → Nginx (443/80) → Paperclip (Docker, port 3000)

Embedded PostgreSQL
Claude CLI / Codex / OpenCode (agent tools)
Project workspaces at /opt/cleo/paperclip/
ComponentRuns asPortData
PaperclipDocker container3000 (localhost)/opt/cleo/paperclip
NginxSystem service80/443/etc/nginx/sites-enabled/
CertbotOne-shot + cron/etc/letsencrypt/

Prerequisites

  • Ubuntu 24.04 droplet (minimum 2 vCPU / 4 GB RAM / 40 GB disk)
  • Docker and Docker Compose plugin installed
  • Nginx installed
  • Certbot installed
  • DNS A record pointing to the droplet IP
  • Access to ghcr.io/paperclip-ai/paperclip:latest (or a locally built image)

Reference deployments

InstanceDomainDroplet IPImage
RateMatchcleo.ratematch.nucleotto.com134.199.159.113local/paperclip:latest
TalentSupplyTBDTBDTBD

Part A — New droplet (greenfield)

Step 1 — System prerequisites

apt update && apt install -y docker.io docker-compose-plugin nginx certbot python3-certbot-nginx git curl ufw
systemctl enable docker nginx

Step 2 — Create the deploy user

adduser --disabled-password --gecos "" cleo
usermod -aG docker cleo

Step 3 — Configure UFW firewall

ufw allow OpenSSH
ufw allow 'Nginx Full'
ufw --force enable

Step 4 — Create directory structure

mkdir -p /opt/cleo/paperclip /opt/cleo/logs/paperclip /opt/cleo/config
chown -R cleo:cleo /opt/cleo

Step 5 — Generate secrets

APP_SECRET=$(openssl rand -hex 32)
SESSION_SECRET=$(openssl rand -hex 32)
AUTH_SECRET=$(openssl rand -hex 32)

echo "Generated secrets — save these:"
echo "APP_SECRET=$APP_SECRET"
echo "SESSION_SECRET=$SESSION_SECRET"
echo "AUTH_SECRET=$AUTH_SECRET"

Step 6 — Create Paperclip environment file

Replace <DOMAIN> with the full domain (e.g. cleo.talentsupply.nucleotto.com).

cat > /opt/cleo/config/paperclip.env << EOF
NODE_ENV=production
PORT=3000
APP_URL=https://<DOMAIN>
APP_SECRET='$APP_SECRET'
SESSION_SECRET='$SESSION_SECRET'
BETTER_AUTH_SECRET='$AUTH_SECRET'
BETTER_AUTH_BASE_URL='https://<DOMAIN>'
TRUSTED_ORIGINS='https://<DOMAIN>'
EOF

Step 7 — Pull and run Paperclip

Option A — Pre-built image (recommended):

docker pull ghcr.io/paperclip-ai/paperclip:latest

docker run -d \
--name paperclip \
--restart always \
-p 127.0.0.1:3000:3000 \
-v /opt/cleo/paperclip:/paperclip \
-v /opt/cleo/logs/paperclip:/app/logs \
--env-file /opt/cleo/config/paperclip.env \
-e HOST=0.0.0.0 \
-e SERVE_UI=true \
-e PAPERCLIP_HOME=/paperclip \
-e PAPERCLIP_INSTANCE_ID=default \
-e USER_UID=$(id -u cleo) \
-e USER_GID=$(id -g cleo) \
-e PAPERCLIP_CONFIG=/paperclip/instances/default/config.json \
-e PAPERCLIP_DEPLOYMENT_MODE=authenticated \
-e PAPERCLIP_DEPLOYMENT_EXPOSURE=private \
-e OPENCODE_ALLOW_ALL_MODELS=true \
ghcr.io/paperclip-ai/paperclip:latest

Option B — Local build:

If you have the Paperclip source (e.g. from nucleotto/paperclip-custom):

cd /opt/cleo/paperclip-src
docker build -t local/paperclip:latest .
# Then use the same docker run command above, replacing the image name

Step 8 — Verify Paperclip is running

sleep 10
docker ps | grep paperclip
curl -s http://127.0.0.1:3000/api/health

Expected response:

{"status":"ok","version":"0.3.1","deploymentMode":"authenticated",...}

Step 9 — Nginx reverse proxy

cat > /etc/nginx/sites-enabled/cleo << 'EOF'
server {
server_name <DOMAIN>;
client_max_body_size 25m;

location / {
proxy_pass http://127.0.0.1:3000;
proxy_http_version 1.1;
proxy_set_header Host $host;
proxy_set_header X-Real-IP $remote_addr;
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
proxy_set_header X-Forwarded-Proto $scheme;
proxy_set_header Upgrade $http_upgrade;
proxy_set_header Connection "upgrade";
proxy_read_timeout 300;
proxy_send_timeout 300;
}

listen 80;
listen [::]:80;
}
EOF

nginx -t && systemctl reload nginx

Step 10 — SSL certificate

certbot --nginx -d <DOMAIN> --non-interactive --agree-tos -m admin@nucleotto.com

Step 11 — Set up Claude CLI credentials (for agent tools)

su - cleo

# Install Node.js (needed for Claude CLI)
curl -o- https://raw.githubusercontent.com/nvm-sh/nvm/v0.40.1/install.sh | bash
source ~/.bashrc
nvm install --lts

# Install Claude CLI
npm install -g @anthropic-ai/claude-code

# Log in (this creates ~/.claude/.credentials.json)
claude

# Also install inside the container
docker exec -it paperclip npm install -g @anthropic-ai/claude-code

Step 12 — Verify the full stack

# From the host
curl -s https://<DOMAIN>/api/health

# Check logs
docker logs paperclip --tail 20

Navigate to https://<DOMAIN> and create your admin account.


Part B — Replacing OpenClaw on an existing droplet

Use this when a droplet already has OpenClaw running and you want to replace it with Paperclip.

Step 1 — Document what's running

# Record current state
docker ps
ss -tlnp | grep -E ':(3000|3100|8080|443|80) '
cat /etc/nginx/sites-enabled/*

Step 2 — Back up OpenClaw data (optional)

mkdir -p /opt/cleo/backups/$(date +%Y%m%d)

# If OpenClaw has a data volume
docker inspect openclaw --format '{{json .Mounts}}' | python3 -m json.tool
# Copy any mounted data directories
cp -r /opt/cleo/openclaw /opt/cleo/backups/$(date +%Y%m%d)/openclaw 2>/dev/null

Step 3 — Stop and remove OpenClaw

docker stop openclaw
docker rm openclaw

# Remove the OpenClaw image (free disk)
docker rmi $(docker images | grep openclaw | awk '{print $3}') 2>/dev/null

Step 4 — Clean up OpenClaw nginx config (if separate)

If OpenClaw had its own nginx site config, remove or repurpose it:

# Check if openclaw has a dedicated config
ls /etc/nginx/sites-enabled/

# If it was sharing the same domain/config as Paperclip, no change needed
# If it had its own, remove it:
rm /etc/nginx/sites-enabled/openclaw 2>/dev/null
nginx -t && systemctl reload nginx

Step 5 — Deploy Paperclip

Follow Part A, Steps 4–12 above. The existing nginx, certbot, Docker, and user account can be reused.

Step 6 — Verify and clean up

# Confirm Paperclip is healthy
curl -s http://127.0.0.1:3000/api/health

# Confirm OpenClaw is fully removed
docker ps -a | grep openclaw # Should return nothing
docker images | grep openclaw # Should return nothing

# Kill zombie processes from old services
ps aux | grep defunct | head -10
# If many zombies, reboot:
# reboot

Pitfalls & lessons learned

Port 3000 vs 3100

Paperclip's default internal port is 3100 (in the Dockerfile), but the RateMatch instance maps it as 3000:3000 with PORT=3000 env var. Be consistent — pick one and set PORT accordingly.

Secrets must be pre-generated

Paperclip requires APP_SECRET, SESSION_SECRET, and BETTER_AUTH_SECRET in the environment. If these are missing, the container will start but auth will fail silently.

TRUSTED_ORIGINS must match the domain exactly

TRUSTED_ORIGINS and BETTER_AUTH_BASE_URL must be https://<exact-domain> — no trailing slash, no port number. Mismatches cause CORS and auth failures.

Container user UID/GID

The Paperclip container runs as a node user whose UID/GID is dynamically adjusted via USER_UID and USER_GID env vars to match the host user. Set these to $(id -u cleo) and $(id -g cleo) so file permissions on the bind-mounted volumes work correctly.

Zombie processes after replacing services

Replacing Docker containers (especially ones that spawn child processes) can leave zombie processes. The RateMatch droplet had 159 zombies after initial setup. A reboot clears them:

# Check for zombies
ps aux | awk '{if ($8=="Z") print}' | wc -l

# Reboot if many (safe — Docker restart policies will bring containers back)
reboot

Claude CLI credentials inside the container

If Paperclip's agents need Claude CLI access, the credentials must exist both on the host (/home/cleo/.claude/.credentials.json) and inside the container. The bind mount at /paperclip maps to /opt/cleo/paperclip on the host — Claude looks for credentials at $HOME/.claude/, which inside the container is /paperclip/.claude/.


Maintenance

Restart Paperclip

docker restart paperclip

View logs

docker logs paperclip --tail 50
# or
tail -50 /opt/cleo/logs/paperclip/*.log

Update Paperclip

docker pull ghcr.io/paperclip-ai/paperclip:latest
docker stop paperclip && docker rm paperclip
# Re-run the docker run command from Step 7

Check health

curl -s http://127.0.0.1:3000/api/health | python3 -m json.tool
docker ps | grep paperclip

Full stack check (Paperclip + Otto)

docker ps
systemctl status hermes-gateway 2>/dev/null
ss -tlnp | grep -E ':(3000|8080|8642|443|80) '
curl -s http://127.0.0.1:3000/api/health
curl -s http://127.0.0.1:8642/v1/models -H "Authorization: Bearer <API_KEY>" 2>/dev/null