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.
Reverse proxy with TLS
Section titled “Reverse proxy with TLS”Never expose the Pinchy Web service directly. Use a reverse proxy with TLS termination.
Caddy (recommended — automatic HTTPS)
Section titled “Caddy (recommended — automatic HTTPS)”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;}Firewall rules
Section titled “Firewall rules”Only port 443 (HTTPS) needs to be exposed. Block everything else.
UFW (Ubuntu/Debian)
Section titled “UFW (Ubuntu/Debian)”ufw default deny incomingufw default allow outgoingufw allow sshufw allow 443/tcpufw enableiptables
Section titled “iptables”iptables -A INPUT -p tcp --dport 22 -j ACCEPTiptables -A INPUT -p tcp --dport 443 -j ACCEPTiptables -A INPUT -m state --state ESTABLISHED,RELATED -j ACCEPTiptables -A INPUT -i lo -j ACCEPTiptables -A INPUT -j DROPDisk encryption
Section titled “Disk encryption”PostgreSQL stores all Pinchy data on disk — user accounts, agent configurations, audit logs, and encrypted API keys. Encrypt the underlying volume.
Linux (LUKS)
Section titled “Linux (LUKS)”# Encrypt the partition (destructive — do this before deploying)cryptsetup luksFormat /dev/sdXcryptsetup open /dev/sdX pinchy-datamkfs.ext4 /dev/mapper/pinchy-datamount /dev/mapper/pinchy-data /var/lib/docker/volumesmacOS (FileVault)
Section titled “macOS (FileVault)”Enable FileVault in System Settings → Privacy & Security. This encrypts the entire startup disk, including Docker volumes.
Docker security
Section titled “Docker security”Run as non-root
Section titled “Run as non-root”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:trueRead-only filesystem
Section titled “Read-only filesystem”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:/dataResource limits
Section titled “Resource limits”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: 1GEnvironment variable protection
Section titled “Environment variable protection”The .env file contains secrets like DATABASE_URL, BETTER_AUTH_SECRET, and AUDIT_HMAC_SECRET. Protect it.
File permissions
Section titled “File permissions”chmod 600 .envchown root:root .envDocker Secrets (Swarm mode)
Section titled “Docker Secrets (Swarm mode)”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.txtNetwork isolation
Section titled “Network isolation”Pinchy's Docker Compose setup uses an internal network. PostgreSQL and OpenClaw are not exposed to the host network.
Localhost binding (default)
Section titled “Localhost binding (default)”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:
# Expose on all interfaces — only for initial setup, remove before productionPINCHY_PORT=0.0.0.0:7777Verify network isolation
Section titled “Verify network isolation”# Check that only Pinchy Web publishes portsdocker 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.
Custom Docker network
Section titled “Custom Docker network”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-internalThe 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.
Authentication rate limiting
Section titled “Authentication rate limiting”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.
Per-path thresholds (per IP)
Section titled “Per-path thresholds (per IP)”| Endpoint | Window | Max | Threat addressed |
|---|---|---|---|
/sign-in/* (incl. /email) | 60s | 5 | Credential stuffing, brute force |
/sign-up/* (incl. /email) | 5 min | 3 | Account-creation spam |
/forget-password[/*] | 10 min | 3 | Password-reset email spam (DOS against user inboxes) |
/reset-password[/*] | 10 min | 5 | Reset-token brute force |
/request-password-reset | 10 min | 3 | Reset email spam |
/send-verification-email | 10 min | 3 | Verification email spam |
/change-password | 10 min | 5 | Post-auth abuse (stolen session) |
/change-email | 10 min | 3 | Account takeover via email change (stolen session) |
| All other auth endpoints | 10s | 100 | Generic 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.
What this protects against
Section titled “What this protects against”- 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.failedevents, repeated failures are visible and rate-bounded. - Reset-flow abuse. Tight windows on
/forget-passwordand/send-verification-emailprevent a single IP from spamming a victim's inbox or burning through reset tokens. - Post-auth account takeover. If a session is stolen,
/change-emailand/change-passwordare throttled hard enough that an attacker cannot quickly pivot to permanent control.
Limits of IP-based rate limiting
Section titled “Limits of IP-based rate limiting”- 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.failedaudit 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-Forto 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.
Customizing the thresholds
Section titled “Customizing the thresholds”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.
How it works
Section titled “How it works”For each state-changing request:
- The
Originheader must match the request's own scheme + host (reconstructed fromx-forwarded-hostandx-forwarded-protowhen behind a reverse proxy). - If
Originis absent, theRefererheader is checked against the same allow-list. - If neither header matches, the request is rejected with
403 Forbiddenand anauth.csrf_blockedaudit log entry is written.
Two route prefixes are exempt:
/api/auth/*— Better Auth enforces its owntrustedOriginsallow-list here./api/internal/*— OpenClaw plugins call these routes withAuthorization: Bearer <gateway-token>. Bearer-token routes are not CSRF-able (browsers can't forgeAuthorizationheaders cross-origin), and the plugins are non-browser clients that don't sendOrigin/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.
Reverse proxy configuration
Section titled “Reverse proxy configuration”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:
# nginxproxy_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.
Auditing CSRF probes
Section titled “Auditing CSRF probes”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.
Defense in depth
Section titled “Defense in depth”The CSRF gate is layered with two other request-source defenses:
- Domain lock (
Settings → Security → Domain lock) — rejects any request whoseHostheader 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 asauth.host_blocked. SameSite=Laxsession 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).
AUDIT_HMAC_SECRET configuration
Section titled “AUDIT_HMAC_SECRET configuration”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.
Generate a secret
Section titled “Generate a secret”openssl rand -hex 32Set it in your environment
Section titled “Set it in your environment”AUDIT_HMAC_SECRET=your-generated-hex-stringGateway-auth bootstrap
Section titled “Gateway-auth bootstrap”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.
Workspace terminals (disabled)
Section titled “Workspace terminals (disabled)”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.
Backup & recovery
Section titled “Backup & recovery”What to back up beyond pgdata
Section titled “What to back up beyond pgdata”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:
| What | Why 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 volume | Where 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 volume | Holds 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. |
Cold-restore walkthrough (fresh host)
Section titled “Cold-restore walkthrough (fresh host)”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).
-
Restore
.envanddocker-compose.ymlCopy your backed-up
.envanddocker-compose.ymlonto 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 backupchmod 600 .env -
Restore the database
Start only the
dbservice, wait until PostgreSQL is actually accepting connections (up -dreturns as soon as the container has started, not when the database is ready), then restore your most recentpg_dump:Terminal window docker compose up db -duntil docker compose exec -T db pg_isready -U pinchy -q; do sleep 1; donegunzip < pinchy-backup-20260222.sql.gz | docker compose exec -T db psql -U pinchy pinchy -
Restore the
openclaw-configandpinchy-secretsvolumesIf you backed up these named volumes (rather than relying on
.envalone), 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 restoringpinchy-secrets— Pinchy reads the values from the environment instead.openclaw-configis safe to skip too; Pinchy regenerates it from the restored database on boot, at the cost of a fresh gateway bootstrap token. -
Start the rest of the stack
Terminal window docker compose up -dVerify with
curl -s http://localhost:7777/api/versionand checkdocker compose logs pinchy --tail 50for warnings, same as after a normal upgrade.
PostgreSQL backups
Section titled “PostgreSQL backups”Create regular backups with pg_dump:
# Backupdocker compose exec db pg_dump -U pinchy pinchy | gzip > "pinchy-backup-$(date +%Y%m%d).sql.gz"
# Restoregunzip < pinchy-backup-20260222.sql.gz | docker compose exec -T db psql -U pinchy pinchyEncrypted backups
Section titled “Encrypted backups”Encrypt backups before storing them off-site:
# Backup + encrypt with GPGdocker compose exec db pg_dump -U pinchy pinchy | gzip | gpg --symmetric --cipher-algo AES256 > "pinchy-backup-$(date +%Y%m%d).sql.gz.gpg"
# Decrypt + restoregpg --decrypt pinchy-backup-20260222.sql.gz.gpg | gunzip | docker compose exec -T db psql -U pinchy pinchyBackup schedule
Section titled “Backup schedule”Set up a cron job for daily backups:
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.gpgUpdate strategy
Section titled “Update strategy”Docker image updates
Section titled “Docker image updates”Check for new Pinchy releases and update:
docker compose pulldocker compose up -ddocker image prune -fAlways 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).
Dependency scanning
Section titled “Dependency scanning”Use tools like Trivy to scan your running images for known vulnerabilities:
trivy image ghcr.io/heypinchy/pinchy:latestConsider 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.
Monitoring & alerting
Section titled “Monitoring & alerting”Health endpoint
Section titled “Health endpoint”Monitor the Pinchy Web service by polling its health endpoint:
curl -f https://pinchy.example.com/api/health || echo "Pinchy is down"Docker health checks
Section titled “Docker health checks”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: 3Log monitoring
Section titled “Log monitoring”Monitor Docker logs for errors and suspicious activity:
# Follow all service logsdocker compose logs -f
# Filter for errorsdocker compose logs pinchy 2>&1 | grep -i errorFor production, forward logs to a centralized logging system (e.g., Loki, Elasticsearch) and set up alerts for:
- Authentication failures (
auth.failedaudit events) - CSRF-blocked write attempts (
auth.csrf_blockedaudit events) - Domain-lock rejections on API paths (
auth.host_blockedaudit 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 readsuppressedSinceLastEntryfor the actual volume) - Password resets (
auth.password_reset_completed— a successful reset revokes the target user's existing sessions) - Denied tool executions (
tool.deniedaudit events) - Chat-runtime errors (
chat.agent_error— umbrella event for every failure shape, group bydetail->>'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
Audit log monitoring
Section titled “Audit log monitoring”Use the audit trail API to monitor for security-relevant events programmatically:
# Check for failed logins in the last 24 hourscurl -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.