Skip to content

Hardening Guide

This guide covers security hardening measures for production Pinchy deployments. Pinchy is self-hosted — infrastructure security is your responsibility. These recommendations assume a Linux server with Docker, but most apply to any OS.

Never expose the Pinchy Web service directly. Use a reverse proxy with TLS termination.

pinchy.example.com {
reverse_proxy localhost:7777
}

Caddy automatically provisions and renews Let's Encrypt certificates.

server {
listen 443 ssl http2;
server_name pinchy.example.com;
ssl_certificate /etc/letsencrypt/live/pinchy.example.com/fullchain.pem;
ssl_certificate_key /etc/letsencrypt/live/pinchy.example.com/privkey.pem;
# TLS hardening
ssl_protocols TLSv1.2 TLSv1.3;
ssl_ciphers HIGH:!aNULL:!MD5;
ssl_prefer_server_ciphers on;
# Security headers
add_header Strict-Transport-Security "max-age=63072000; includeSubDomains" always;
add_header X-Content-Type-Options nosniff always;
add_header X-Frame-Options DENY always;
location / {
proxy_pass http://127.0.0.1:7777;
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;
# WebSocket support (required for agent communication)
proxy_http_version 1.1;
proxy_set_header Upgrade $http_upgrade;
proxy_set_header Connection "upgrade";
}
}
server {
listen 80;
server_name pinchy.example.com;
return 301 https://$host$request_uri;
}

Only port 443 (HTTPS) needs to be exposed. Block everything else.

Terminal window
ufw default deny incoming
ufw default allow outgoing
ufw allow ssh
ufw allow 443/tcp
ufw enable
Terminal window
iptables -A INPUT -p tcp --dport 22 -j ACCEPT
iptables -A INPUT -p tcp --dport 443 -j ACCEPT
iptables -A INPUT -m state --state ESTABLISHED,RELATED -j ACCEPT
iptables -A INPUT -i lo -j ACCEPT
iptables -A INPUT -j DROP

PostgreSQL stores all Pinchy data on disk — user accounts, agent configurations, audit logs, and encrypted API keys. Encrypt the underlying volume.

Terminal window
# Encrypt the partition (destructive — do this before deploying)
cryptsetup luksFormat /dev/sdX
cryptsetup open /dev/sdX pinchy-data
mkfs.ext4 /dev/mapper/pinchy-data
mount /dev/mapper/pinchy-data /var/lib/docker/volumes

Enable FileVault in System Settings → Privacy & Security. This encrypts the entire startup disk, including Docker volumes.

The Pinchy Docker image runs as a non-root user by default. Verify this is not overridden in your docker-compose.yml:

services:
pinchy:
# Do NOT add: user: root
security_opt:
- no-new-privileges:true

Mount the container filesystem as read-only and whitelist only the directories that need writes:

services:
pinchy:
read_only: true
tmpfs:
- /tmp
volumes:
- pinchy-data:/data

Prevent a single container from consuming all host resources:

services:
pinchy:
deploy:
resources:
limits:
cpus: "2.0"
memory: 2G
reservations:
memory: 512M
db:
deploy:
resources:
limits:
cpus: "1.0"
memory: 1G

The .env file contains secrets like DATABASE_URL, BETTER_AUTH_SECRET, and AUDIT_HMAC_SECRET. Protect it.

Terminal window
chmod 600 .env
chown root:root .env

If you use Docker Swarm, use secrets instead of environment variables:

services:
pinchy:
secrets:
- db_password
- auth_secret
- audit_hmac_secret
secrets:
db_password:
file: ./secrets/db_password.txt
auth_secret:
file: ./secrets/auth_secret.txt
audit_hmac_secret:
file: ./secrets/audit_hmac_secret.txt

Pinchy's Docker Compose setup uses an internal network. PostgreSQL and OpenClaw are not exposed to the host network.

Pinchy's default port binding is 127.0.0.1:7777, which means it only accepts connections from localhost. A reverse proxy (Caddy, nginx) running on the same host forwards traffic from ports 80/443 to Pinchy on 7777.

If you need to override the binding temporarily (e.g. for initial setup without a reverse proxy), set PINCHY_PORT in your .env:

Terminal window
# Expose on all interfaces — only for initial setup, remove before production
PINCHY_PORT=0.0.0.0:7777
Terminal window
# Check that only Pinchy Web publishes ports
docker compose ps --format "table {{.Name}}\t{{.Ports}}"

The pinchy service should show 127.0.0.1:7777->7777/tcp. The db and openclaw services should show no published ports.

For additional isolation, define an explicit internal network:

networks:
pinchy-internal:
internal: true
pinchy-external:
services:
pinchy:
networks:
- pinchy-internal
- pinchy-external
ports:
- "127.0.0.1:7777:7777"
db:
networks:
- pinchy-internal
openclaw:
networks:
- pinchy-internal

The internal: true flag prevents containers on that network from reaching the internet. The pinchy service bridges both networks — it can reach the database and OpenClaw internally, while also serving HTTP traffic.

See the VPS Deployment guide for the complete production setup walkthrough.

Pinchy ships with hardened, per-path rate limits on every credential-handling endpoint, configured at packages/web/src/lib/auth.ts. The values are explicit (not delegated to library defaults) so a future Better Auth upgrade cannot silently weaken them.

Rate limiting is enabled in production only (NODE_ENV === "production"). It is intentionally off in dev and in Playwright E2E runs that exercise login flows.

EndpointWindowMaxThreat addressed
/sign-in/* (incl. /email)60s5Credential stuffing, brute force
/sign-up/* (incl. /email)5 min3Account-creation spam
/forget-password[/*]10 min3Password-reset email spam (DOS against user inboxes)
/reset-password[/*]10 min5Reset-token brute force
/request-password-reset10 min3Reset email spam
/send-verification-email10 min3Verification email spam
/change-password10 min5Post-auth abuse (stolen session)
/change-email10 min3Account takeover via email change (stolen session)
All other auth endpoints10s100Generic flood protection

The /sign-in/* and /sign-up/* wildcard entries cover any future sub-path (OAuth callbacks, magic-link, passkey) so a new sign-in mechanism added in a later release inherits the hardened threshold instead of falling back to Better Auth's weaker /sign-in/* default.

  • Credential stuffing. 5 sign-in attempts per minute per IP (vs. Better Auth's default 18/min) makes leaked-password lists impractical to replay against a single IP.
  • Targeted brute force. Combined with the audit trail's auth.failed events, repeated failures are visible and rate-bounded.
  • Reset-flow abuse. Tight windows on /forget-password and /send-verification-email prevent a single IP from spamming a victim's inbox or burning through reset tokens.
  • Post-auth account takeover. If a session is stolen, /change-email and /change-password are throttled hard enough that an attacker cannot quickly pivot to permanent control.
  • IP rotation defeats per-IP limits. A residential-proxy pool can sidestep these thresholds. We don't ship per-account lockout yet — a planned brute-force lockout will trigger on N consecutive auth.failed audit events for the same email.
  • Storage is in-memory. Counters reset on container restart and are not shared across replicas. For single-replica self-hosted deployments this is acceptable; for multi-replica deployments switch to a shared store (Redis) — not currently exposed as a config option, file an issue if you need this.
  • Trust the X-Forwarded-For header from your reverse proxy only. Better Auth derives the client IP from headers. If your reverse proxy does not set X-Forwarded-For to the real client IP — or worse, blindly trusts the header from the client — rate limiting can be bypassed. The reverse-proxy snippets earlier in this guide are configured correctly.

The values are intentional defaults. If your CISO requires stricter limits (e.g. 3 sign-ins per minute), edit getAuthRateLimitConfig in packages/web/src/lib/auth.ts and rebuild your image. The auth-config-consistency.test.ts and auth.test.ts test suites pin the expected values so accidental loosening will fail CI.

Cross-Site Request Forgery (CSRF) protection

Section titled “Cross-Site Request Forgery (CSRF) protection”

Pinchy enforces an Origin/Referer check on every state-changing API request (POST/PUT/PATCH/DELETE under /api/). This prevents an attacker from triggering admin actions through a logged-in admin's browser by hosting a malicious page that auto-submits a cross-site form to your Pinchy instance.

For each state-changing request:

  1. The Origin header must match the request's own scheme + host (reconstructed from x-forwarded-host and x-forwarded-proto when behind a reverse proxy).
  2. If Origin is absent, the Referer header is checked against the same allow-list.
  3. If neither header matches, the request is rejected with 403 Forbidden and an auth.csrf_blocked audit log entry is written.

Two route prefixes are exempt:

  • /api/auth/* — Better Auth enforces its own trustedOrigins allow-list here.
  • /api/internal/* — OpenClaw plugins call these routes with Authorization: Bearer <gateway-token>. Bearer-token routes are not CSRF-able (browsers can't forge Authorization headers cross-origin), and the plugins are non-browser clients that don't send Origin/Referer. The same prefix is exempt from the domain-lock host check, for the same reason — plugins reach Pinchy on a Docker-internal hostname. A CI guard fails the build if any route under the prefix stops authenticating with the gateway token, so the exemption cannot outgrow its justification.

The Origin check relies on knowing the public-facing host and scheme. If your reverse proxy strips or rewrites these headers, the check will reject legitimate requests. Set both headers explicitly:

# nginx
proxy_set_header X-Forwarded-Host $host;
proxy_set_header X-Forwarded-Proto $scheme;
# Caddy sets X-Forwarded-Host and X-Forwarded-Proto by default — no extra config needed.
pinchy.example.com {
reverse_proxy localhost:7777
}

If your deployment chains multiple reverse proxies, X-Forwarded-Host may arrive as a comma-separated list (public.example.com, internal:7777). Both request gates read the first (outermost) hop — the public name the browser actually saw: the CSRF gate compares Origin against it, and the domain lock matches it against your configured domain. Each proxy in the chain should append, not overwrite, the existing header.

Blocked requests appear in the audit log as auth.csrf_blocked with outcome: failure. The detail payload captures method, pathname, origin, referer, and the offending remoteAddress, so probes show up in the audit log alongside failed logins. Use the audit dashboard (or GET /api/audit?eventType=auth.csrf_blocked) to review them.

The CSRF gate is layered with two other request-source defenses:

  • Domain lock (Settings → Security → Domain lock) — rejects any request whose Host header doesn't match the configured domain. Closes off requests that come in via direct IP, an unconfigured hostname, or a host header injection. A rejected /api/ request is audited as auth.host_blocked.
  • SameSite=Lax session cookie — Better Auth's default. Prevents cookies from being attached to most third-party requests, but is not sufficient on its own (top-level form posts under Lax can still attach cookies in some browsers).

Together they enforce: the request hit the right destination (domain lock), came from a legitimate source (CSRF gate), and the session cookie is unforgeable (Better Auth + SameSite).

The audit trail uses HMAC-SHA256 to sign every log entry. By default, Pinchy auto-generates a secret at startup. For production, set a persistent secret.

Terminal window
openssl rand -hex 32
.env
AUDIT_HMAC_SECRET=your-generated-hex-string

OpenClaw 2026.5.12+ refuses to bind on a non-loopback interface without a gateway.auth.token in openclaw.json. The gateway logs Refusing to bind gateway to lan without auth and exits, which surfaces in docker compose logs pinchy-openclaw as a restart loop until the wrapper's grace period kicks in.

Pinchy handles this automatically. Pinchy's boot-init step seeds gateway.auth.{mode: "token", token} into openclaw.json before the OpenClaw container starts, even on a fresh install before the setup wizard runs. The seeded token lives in the openclaw-config volume; OpenClaw and Pinchy share the same path.

This is a Pattern C bootstrap credential (see Secret Handling): the token is plaintext in openclaw.json by design. It is the trust root for the OpenClaw container and cannot itself be fetched through Pinchy's API. Rotate it by regenerating the config (any change in Settings → AI Provider that triggers a save) and restarting the OpenClaw container.

Failure mode to watch for. If boot-inits.ts cannot reach the database at startup (DB outage, mis-configured DATABASE_URL), the seed step logs a FATAL line and Pinchy refuses to start — better to fail fast than to leave OpenClaw stuck. If you see pinchy-openclaw restart-looping with Refusing to bind gateway to lan without auth and Pinchy itself is healthy, the seed wrote successfully but OpenClaw is reading from a different config path — verify the openclaw-config volume mount.

OpenClaw 2026.7.1 introduced workspace terminals — a PTY-backed interactive shell that authenticated admins can open inside an agent's workspace, controlled by gateway.terminal.enabled in openclaw.json. Fully sandboxed agents are blocked from it, but for anyone else it is a direct shell on the gateway host with the gateway process environment.

Pinchy pins this off (gateway.terminal.enabled: false) on every config it generates. An interactive shell is an uncontrolled side channel: it runs commands that never pass through Pinchy's agent-permission allow-lists and never land in the audit trail. Leaving it on would undercut the exact governance guarantees Pinchy exists to provide — every agent action being permission-checked and recorded.

This is enforced in three places so no code path can re-enable it by omission: the config Pinchy regenerates from the database, the pre-start seed that runs before OpenClaw boots, and the image's baked-in config/openclaw.json. OpenClaw's own default for this flag is also false, so Pinchy's setting is defense-in-depth against a future default change or a hand-edited config — not a correction of an unsafe default.

We expect to bring workspace terminals back as a first-class, RBAC-scoped and audited feature — gated behind a role grant and writing an audit row per session — rather than the ungoverned admin shell OpenClaw ships upstream. Until then, it stays off. If you have a specific need for it, open an issue describing the use case.

PostgreSQL's pgdata volume holds the bulk of Pinchy's state — users, agents, settings, encrypted API keys, and the audit log — and is the backup most guides (including this one) lead with. A full cold restore on a new host also needs three things that don't live in pgdata:

WhatWhy it matters if lost
Your .env secrets (DB_PASSWORD, BETTER_AUTH_SECRET, ENCRYPTION_KEY, AUDIT_HMAC_SECRET)If any of these were auto-generated rather than pinned, they're persisted as files in the pinchy-secrets volume, not in .env — back up that volume too (see below). Losing ENCRYPTION_KEY makes every stored LLM provider API key permanently unreadable. Losing AUDIT_HMAC_SECRET breaks integrity verification for every audit row signed under it. Losing BETTER_AUTH_SECRET invalidates every active session.
The pinchy-secrets volumeWhere Pinchy persists any of the four secrets above that you didn't pin explicitly in .env (see VPS Deployment → Pin your secrets). Same consequences as losing the .env values themselves.
The openclaw-config volumeHolds openclaw.json — the gateway's bootstrap auth token and the regenerated agent/provider configuration. Pinchy rebuilds most of this from the database on every start, so a lost volume mostly self-heals; the exception is the gateway.auth.token bootstrap credential (see Gateway-auth bootstrap), which regenerates automatically but invalidates the old value — harmless on a full restore where both pinchy and openclaw come up together, worth knowing if you ever restore this volume in isolation.

This is the sequence for restoring Pinchy onto a brand-new host — a full disaster-recovery scenario, not a routine upgrade rollback (for that, see Restoring from backup in the upgrading guide).

  1. Restore .env and docker-compose.yml

    Copy your backed-up .env and docker-compose.yml onto the new host, into /opt/pinchy (or wherever you run Pinchy from):

    Terminal window
    mkdir -p /opt/pinchy && cd /opt/pinchy
    # copy .env and docker-compose.yml here from your backup
    chmod 600 .env
  2. Restore the database

    Start only the db service, wait until PostgreSQL is actually accepting connections (up -d returns as soon as the container has started, not when the database is ready), then restore your most recent pg_dump:

    Terminal window
    docker compose up db -d
    until docker compose exec -T db pg_isready -U pinchy -q; do sleep 1; done
    gunzip < pinchy-backup-20260222.sql.gz | docker compose exec -T db psql -U pinchy pinchy
  3. Restore the openclaw-config and pinchy-secrets volumes

    If you backed up these named volumes (rather than relying on .env alone), restore them before starting the rest of the stack. Docker volumes live under /var/lib/docker/volumes/<project>_<volume-name>/_data — restore your archive into that path, or use a throwaway container to copy the data in:

    Terminal window
    docker run --rm -v pinchy_pinchy-secrets:/target -v "$(pwd)/backup/pinchy-secrets:/source:ro" \
    alpine sh -c "cp -a /source/. /target/"
    docker run --rm -v pinchy_openclaw-config:/target -v "$(pwd)/backup/openclaw-config:/source:ro" \
    alpine sh -c "cp -a /source/. /target/"

    If you always pin all four secrets in .env (see above), you can skip restoring pinchy-secrets — Pinchy reads the values from the environment instead. openclaw-config is safe to skip too; Pinchy regenerates it from the restored database on boot, at the cost of a fresh gateway bootstrap token.

  4. Start the rest of the stack

    Terminal window
    docker compose up -d

    Verify with curl -s http://localhost:7777/api/version and check docker compose logs pinchy --tail 50 for warnings, same as after a normal upgrade.

Create regular backups with pg_dump:

Terminal window
# Backup
docker compose exec db pg_dump -U pinchy pinchy | gzip > "pinchy-backup-$(date +%Y%m%d).sql.gz"
# Restore
gunzip < pinchy-backup-20260222.sql.gz | docker compose exec -T db psql -U pinchy pinchy

Encrypt backups before storing them off-site:

Terminal window
# Backup + encrypt with GPG
docker compose exec db pg_dump -U pinchy pinchy | gzip | gpg --symmetric --cipher-algo AES256 > "pinchy-backup-$(date +%Y%m%d).sql.gz.gpg"
# Decrypt + restore
gpg --decrypt pinchy-backup-20260222.sql.gz.gpg | gunzip | docker compose exec -T db psql -U pinchy pinchy

Set up a cron job for daily backups:

/etc/cron.d/pinchy-backup
0 3 * * * root cd /opt/pinchy && docker compose exec -T db pg_dump -U pinchy pinchy | gzip | gpg --symmetric --batch --passphrase-file /root/.pinchy-backup-passphrase --cipher-algo AES256 > /backups/pinchy-$(date +\%Y\%m\%d).sql.gz.gpg

Check for new Pinchy releases and update:

Terminal window
docker compose pull
docker compose up -d
docker image prune -f

Always read the release notes before updating. Back up the database first. The final docker image prune -f removes the previous image layer that pull left dangling — skip it and pinchy/pinchy-openclaw <none>:<none> layers slowly fill the root volume across upgrades. For a deeper sweep that also drops older pinned versions you no longer need for rollback, run docker image prune -a -f between upgrades (not as part of one).

Use tools like Trivy to scan your running images for known vulnerabilities:

Terminal window
trivy image ghcr.io/heypinchy/pinchy:latest

Consider integrating this into a CI pipeline or running it on a schedule. See the SBOM reference for information about Pinchy's software bill of materials.

Monitor the Pinchy Web service by polling its health endpoint:

Terminal window
curl -f https://pinchy.example.com/api/health || echo "Pinchy is down"

Add health checks to your docker-compose.yml:

services:
pinchy:
healthcheck:
test: ["CMD", "curl", "-f", "http://localhost:7777/api/health"]
interval: 30s
timeout: 10s
retries: 3
start_period: 30s
db:
healthcheck:
test: ["CMD-SHELL", "pg_isready -U pinchy"]
interval: 30s
timeout: 5s
retries: 3

Monitor Docker logs for errors and suspicious activity:

Terminal window
# Follow all service logs
docker compose logs -f
# Filter for errors
docker compose logs pinchy 2>&1 | grep -i error

For production, forward logs to a centralized logging system (e.g., Loki, Elasticsearch) and set up alerts for:

  • Authentication failures (auth.failed audit events)
  • CSRF-blocked write attempts (auth.csrf_blocked audit events)
  • Domain-lock rejections on API paths (auth.host_blocked audit events — recurring rows naming one internal host usually mean a component of your own deployment is being turned away, not an attack. Throttled to one row per minute, so read suppressedSinceLastEntry for the actual volume)
  • Password resets (auth.password_reset_completed — a successful reset revokes the target user's existing sessions)
  • Denied tool executions (tool.denied audit events)
  • Chat-runtime errors (chat.agent_error — umbrella event for every failure shape, group by detail->>'errorClass' to see what's degrading)
  • Invite blocks (user.invite_blocked — seat-cap or license guard refused an invite)
  • Container restarts (especially pinchy-openclaw — see Gateway-auth bootstrap below for a common cause)
  • High error rates

Use the audit trail API to monitor for security-relevant events programmatically:

Terminal window
# Check for failed logins in the last 24 hours
curl -b session_cookie "https://pinchy.example.com/api/audit?eventType=auth.failed&from=$(date -d '24 hours ago' -Iseconds)"

See Audit Trail for the full API reference.