The angle no earlier round probed: which artifacts a human or agent CONSUMES should have changed and did not. Five, and the pattern is the one my own record keeps naming — the caveat existed in the artifacts I was editing and not in the ones that get read. - .env.example had neither new variable and still described PAD_SSE_MAX_CONNECTIONS with its old single-endpoint meaning. It is the file an operator copies; docs/deployment.md being right does not help someone who never opens it. - docs/deployment.md called the readiness endpoint /health/ready. The route is /api/v1/health/ready, so every instruction to go read the new redis block pointed at a 404. Corrected there and in four code comments, and the Health Check section now actually shows the three endpoints, the healthy payload, and the degraded one — it previously demonstrated only /api/v1/health, which is the build-info endpoint and says nothing about readiness. - CLAUDE.md listed /api/v1/events and not /api/v1/events/stream at all, so the endpoint this unit bounds was undocumented in the file agents read first. Added, with the limits and the 429 contract. - `pad watch --stream --help` said silence means "no workspace linked or padd unreachable". A capacity refusal now produces the same silence through the same backoff, so the help was enumerating a set that had quietly grown. - The plugin skill told agents "silence means nothing changed" — now false in the same way, and worse, because an agent repeats it to a user as though the quiet were evidence. Rewritten to say what silence does and does not prove. The plugin monitor description had the same enumeration and got the same fix. Checked rather than assumed: there are two SKILL.md files, and only the plugin copy carries a notifications section — the embedded one has no monitor guidance to correct. NOT changed, and raised with the lead instead: deploy/k8s/deployment.yaml points both probes at /api/v1/health, so the readiness endpoint is never consumed. Fixing it is right but it changes rollout behaviour for anyone using the shipped manifest (a database blip would start pulling pods from the load balancer), which is a deployment-posture call rather than part of this unit. Claude-Session: https://claude.ai/code/session_01JVDBKbgn3Xt7ndW1YoYd8X
26 KiB
Deployment Guide
Pad is a single Go binary with an embedded web UI. It supports SQLite (default) for single-node deployments and PostgreSQL + Redis for production multi-node setups.
Architecture
┌─────────────────┐
│ Reverse Proxy │
│ (Caddy/nginx) │
└────────┬────────┘
│ :443
┌────────▼────────┐
│ Pad │
│ Go binary │
│ (web UI + API) │
└──┬──────────┬───┘
│ │
┌────────▼──┐ ┌───▼────────┐
│ PostgreSQL │ │ Redis │
│ (storage) │ │ (pub/sub) │
└────────────┘ └────────────┘
- Pad serves the REST API and embedded SvelteKit web UI on a single port (default: 7777)
- PostgreSQL stores all data (workspaces, items, users, activity). SQLite works for single-node.
- Redis carries real-time events, watch/push notifications, and the shared session-presence registry across multiple Pad instances. Optional for single-node.
Quick Start with Docker Compose
# Clone the repo
git clone https://github.com/PerpetualSoftware/pad.git
cd pad
# Start everything (Pad + PostgreSQL + Redis)
docker compose up -d
# Check status
docker compose ps
# View logs
docker compose logs -f pad
Access the web UI at http://localhost:7777. On first visit, you'll be prompted to create an admin account.
Production Docker Compose
# Use the production overlay for resource limits and secure settings
docker compose -f docker-compose.yml -f docker-compose.prod.yml up -d
Edit docker-compose.prod.yml to set your domain, email credentials, and database password.
Environment Variables
All configuration is via environment variables or a config file (~/.pad/config.toml / /data/config.toml).
Core
| Variable | Default | Description |
|---|---|---|
PAD_HOST |
127.0.0.1 |
Listen address (0.0.0.0 for Docker/production) |
PAD_PORT |
7777 |
Listen port |
PAD_URL |
— | Public-facing base URL (e.g., https://pad.example.com). Used for invitation, password-reset, and share-link emails. Required when PAD_HOST=0.0.0.0 — otherwise emailed links point at http://0.0.0.0:port and are unreachable to recipients. |
PUBLIC_URL |
— | Alternative to PAD_URL using the generic env-var convention. Server-side only — does not affect CLI mode, does not influence the CLI's API endpoint, and is not persisted to config.toml. Precedence: PAD_URL > PUBLIC_URL > constructed http://host:port. |
PAD_DATA_DIR |
~/.pad |
Data directory for SQLite DB, logs, and config |
PAD_LOG_LEVEL |
info |
Log level: debug, info, warn, error |
PAD_MODE |
local |
Mode: local, remote, cloud |
Database
| Variable | Default | Description |
|---|---|---|
PAD_DB_DRIVER |
sqlite |
Database driver: sqlite or postgres |
PAD_DB_PATH |
~/.pad/pad.db |
SQLite database path (ignored when using PostgreSQL) |
PAD_DATABASE_URL |
— | PostgreSQL connection string (required when PAD_DB_DRIVER=postgres) |
Real-time Events
| Variable | Default | Description |
|---|---|---|
PAD_REDIS_URL |
— | Redis URL for cross-instance pub/sub and the session-presence registry. Without Redis, SSE events, watch notifications, and session presence are all in-process only. |
PAD_REDIS_NAMESPACE |
— | Scopes every Redis key and channel to one installation. Set it when two Pad installations share a Redis endpoint. Unset means the historical names; a whitespace-only value is rejected at startup rather than treated as unset. |
PAD_SSE_MAX_CONNECTIONS |
1000 |
Maximum streaming connections per instance, across both /api/v1/events and /api/v1/events/stream |
PAD_SSE_MAX_PER_WORKSPACE |
100 |
Per-workspace maximum connections on /api/v1/events, per instance |
PAD_SSE_MAX_PER_USER |
50 |
Per-user maximum streaming connections across both endpoints, per instance |
Streaming connection limits
Pad has two SSE endpoints and they share one budget. /api/v1/events is
workspace-scoped (the web UI's activity stream); /api/v1/events/stream is
user-scoped (agent watch notifications, pad watch --stream). A held
connection costs the same goroutine, subscription and — with Redis — presence
registration whichever one opened it, so PAD_SSE_MAX_CONNECTIONS and
PAD_SSE_MAX_PER_USER bound them together. Only PAD_SSE_MAX_PER_WORKSPACE
is endpoint-specific, because the watch stream has no workspace to count
against.
Upgrading:
PAD_SSE_MAX_CONNECTIONSpreviously bounded/api/v1/eventsalone. It now covers both, so a tuned value may be reached sooner than before. The server logs the effective limits at startup (Stream connection limits)./api/v1/events/streamhad no limit at all before this change; if you run many agent sessions per user, checkPAD_SSE_MAX_PER_USERagainst your fleet size.
A refused connection is 429 with code sse_limit_exceeded. The CLI monitor
treats it like any other non-200 and backs off (5s, growing linearly, capped at
5 minutes), so refusal does not produce a reconnect storm.
The per-user limit applies to every caller, including ones with no user account: a legacy workspace-scoped token or the fresh-install no-auth window is bounded per workspace instead, at the same number. Two legacy tokens for the same workspace therefore share a bucket.
All three limits are PER INSTANCE, not deployment-wide. They are enforced
in-process; there is no shared counter. A three-replica deployment with
PAD_SSE_MAX_CONNECTIONS=1000 admits up to 3000 connections in total, and a
single user can hold PAD_SSE_MAX_PER_USER connections on each replica. Size
them per pod and multiply by replica count for the deployment ceiling. Watch
pad_stream_connections_active (per instance) rather than inferring the total
from the configured number.
Redis configuration notes
One namespace per installation, or one endpoint per installation. Every
Redis key and channel Pad uses carries PAD_REDIS_NAMESPACE when it is set:
pad:<namespace>:events:…, pad:<namespace>:watchevents…,
pad:<namespace>:session:…. When it is unset the names are the historical flat
ones, so upgrading changes nothing.
Two installations sharing one Redis endpoint without distinct namespaces cross-feed notifications and merge their session-presence registries. Selecting different logical DB numbers does not help — Redis pub/sub is not namespaced by DB at all. The practical exposure is a cloned database (a staging environment restored from a production dump), because delivery is filtered on user id and user ids are per-installation UUIDs; for that case it is a genuine cross-tenant leak.
Changing the namespace on a running deployment is a cutover, not a tweak. Set it before going multi-installation rather than after, and take a brief maintenance window if you can. Three things to know:
- It partitions a rolling upgrade. Replicas with the namespace set and replicas without it do not share pub/sub channels, counters, or the presence registry — they behave as two separate installations for as long as the rollout takes.
GET /api/v1/sessionsanswers differently depending on which replica handles it, and a session-targeted push aimed across the partition is skipped (reported honestly asdelivered_sessions: 0, but not delivered). Roll all replicas together, or accept a split for the duration.- Rolling BACK re-creates the split unless the namespace is unset at the same time. The env var and the binary version have to move together in both directions.
- Client resync is honest on the watch stream and silent on the activity stream.
pad:*watchevents_epochmakes the notification stream detect the changed id space and answer resumes withsync_required. The workspace activity stream (/api/v1/events) has no equivalent: a client reconnecting with aLast-Event-IDfrom the old keyspace against a fresh replay buffer is treated as caught up, so it silently misses whatever happened during the cutover until its next full page load. That is a pre-existing property of any cold replay buffer (a replica restart does the same), not something the namespace introduced — it is called out here because a namespace change is the one case an operator triggers deliberately.Session-presence entries are transient — 90s TTL — and cost nothing either way.
Pad's Redis integration assumes a single Redis node — redis://…, not a
cluster. Key names carry no hash tags and Pad dials a non-cluster client, so a
user's presence index and their session entries would hash to different slots
and the Lua scripts would fail CROSSSLOT. Pointing Pad at a Redis Cluster is
not supported.
Redis health and metrics
/api/v1/health/ready reports Redis in its payload but does not gate readiness on
it — the REST API, the web UI and every write path work with Redis down, so
failing readiness over a Redis blip would pull healthy replicas out of the load
balancer and turn a degraded feature into an outage. When Redis is unreachable
the payload carries redis.reachable: false, the probe error, and a degrades
list naming what is lost. Note what that list says about activity events: they
stop for all clients, not only across instances — the activity bus does not
fall back to a local fan-out when its publish fails. The block is absent entirely when no Redis is
configured.
Alert on these instead:
| Metric | Meaning |
|---|---|
pad_redis_up |
0 when the last probe (every 15s) failed. Exported only when Redis is configured — absence means "no Redis", not "down" |
pad_stream_connections_active |
Held streaming connections on this instance, across both SSE endpoints — the population the limits bound |
pad_watchevents_sequence_gaps_total |
This instance missed notifications — a delivery fault |
pad_watchevents_resume_gaps_total |
Resumes this instance could not serve; each sends a client sync_required. The user-visible one |
pad_watchevents_notifications_missed_total |
How many notifications those gaps spanned |
pad_watchevents_notifications_dropped_total |
Received but not delivered to a local subscriber |
pad_watchevents_sequence_resets_total |
The Redis counter or epoch changed; replay buffers dropped |
pad_watchevents_receive_loop_exits_total |
Non-zero outside shutdown means an instance publishes but receives nothing |
pad_session_presence_failures_total |
Presence operations failing — read the op label, the consequences differ: register/renew under-report (a live session is unlisted and untargetable), deregister over-reports (a dead session stays listed and a push aimed at it reaches nobody), list returns a 503, prune is benign |
Avoid an evicting maxmemory-policy for Pad's Redis.
docker-compose.prod.yml sets noeviction for this reason; the plain
docker-compose.yml keeps allkeys-lru on its 64 MB dev instance, where the
consequence below is a momentary annoyance rather than a lost instruction —
change it too if you run that file in anger.
Under an evicting policy Redis may drop live session-presence entries under
memory pressure. Nothing can distinguish that from a TTL lapsing, so a
connected agent session briefly disappears from the picker and a push targeted
at it reports
delivered_sessions: 0. It self-repairs on the session's next 30-second
renewal, and Pad's keyspace is small — a few hundred bytes per connected
session plus two counters — so there is nothing to gain by evicting it.
If push stops finding a session (on-call)
The most likely Redis-related symptom is a transient write failure while registering a session. The agent's event stream stays up — the connection is never refused over a registry problem — but the session is absent from the shared registry, so:
- it does not appear in
GET /api/v1/sessionsor the web picker, and - a push targeted at it returns
200 pushed:truewithdelivered_sessions: 0and skips publication, so the instruction is not delivered.
What you'll see: session presence: failed to register session or failed to renew session entry warnings (rate-limited to one per minute, carrying
failures_since_last_log — a large count means the replica, a small one means
a single session), and the session missing from the listing.
What to do: restore Redis connectivity, capacity, or ACLs. Registration self-heals — each session's renewal re-writes its full entry, so an affected session reappears within ~30 seconds without reconnecting. Confirm it is listed again before re-sending anything.
What NOT to do: do not blindly re-send. A targeted push reporting
delivered_sessions: 0 is safe to resend, because the server skipped the
publish. A broadcast is always published, and a 502 push_unconfirmed means
the outcome is unknown — re-sending either can deliver a second instruction the
agent acts on twice. Only re-send what the server told you it skipped.
Upgrading a multi-instance deployment
PAD_REDIS_URL now also backs the session-presence registry — the list of
which agent sessions are connected, which pad push and the web UI's "Push to
agent" picker read to decide where a push goes. Previously that registry was
per-process even when Redis was configured, so a push aimed at a session held
by another replica was silently dropped.
During a rolling upgrade, old and new replicas disagree about presence. An
old replica has only its own connections in view, so a push it answers cannot
see a session held on a new replica, and GET /api/v1/sessions returns a
different list depending on which replica answers. A TARGETED push reports this
honestly — delivered_sessions: 0, and the publish is skipped, so nothing was
sent — but the instruction is not delivered.
This is the same behaviour every replica had before this build, so the rollout is not a regression; it is a window in which the fix is only partly in effect. Two ways to avoid the window:
- Blue/green — bring up the new replicas, cut traffic over, retire the old ones. No mixed period.
- Drain first — scale old replicas out of the load balancer and let agent
monitors reconnect (
pad watch --streamreconnects on its own) before serving pushes from the new set.
If neither is practical, a rolling upgrade is still safe: nothing is corrupted
and no migration is needed. Targeted pushes may report delivered_sessions: 0
and go undelivered until every replica runs the new build; those are safe to
re-send once the rollout completes, because a targeted miss skips the publish
entirely.
That safety does not extend to broadcasts. A broadcast push is always
published, on old and new replicas alike, and the shared notification bus
carries it across instances regardless of which registry the answering replica
used — so a broadcast reporting 0 during the rollout may well have been
delivered. Re-sending one is a second instruction the receiving agent will act
on twice. Only re-send a push the server told you it skipped. There is no Redis or database migration; the
registry's keys are transient and expire on their own TTL.
Security
| Variable | Default | Description |
|---|---|---|
PAD_SECURE_COOKIES |
false |
Set Secure flag on session cookies (requires TLS) |
PAD_CORS_ORIGINS |
— | Comma-separated allowed CORS origins |
Email (Optional)
Email enables sending workspace invitation links. Without it, users can still join via CLI invite codes.
| Variable | Default | Description |
|---|---|---|
PAD_MAILEROO_API_KEY |
— | Maileroo sending API key |
PAD_EMAIL_FROM |
noreply@getpad.dev |
Sender email address |
PAD_EMAIL_FROM_NAME |
Pad |
Sender display name |
Password recovery (when email is not configured)
Without an email provider, the web "Forgot password" flow can't send a reset
link — the page says so and points users at the host-side recovery below.
Recover a locked-out account from the server host (the same trust model
as pad auth setup — shell access to the box):
# Print a single-use reset link (open it in a browser to choose a new password)
pad auth reset-password admin@example.com
# Or set a random temporary password, printed to the terminal (headless boxes).
# Log in with it, then change it immediately — all existing sessions are signed out.
pad auth reset-password admin@example.com --temp-password
This calls a loopback-only endpoint (POST /api/v1/auth/local-reset): it
needs no login (you're locked out, after all), but it only works for a
direct request from the server itself — proxied or remote requests are
refused, and it's disabled entirely in cloud mode.
Alternatively, if a user submits the web reset form, the server logs the reset path on a non-cloud instance with no email configured:
password reset generated (email not configured) ... reset_path=/reset-password/<token>
Open <base-url>/reset-password/<token> to finish the reset by hand.
Deployment Options
Single Binary (SQLite)
The simplest deployment — one binary, one file for the database.
# Download or build
make build
# Run directly
PAD_HOST=0.0.0.0 ./pad server start
# Or install as a systemd service (see below)
Best for: single-user, small teams, evaluations.
Docker Compose (PostgreSQL + Redis)
See Quick Start above. This is the recommended setup for teams.
Kubernetes
Manifests are in deploy/k8s/. Apply them in order:
# Create namespace
kubectl apply -f deploy/k8s/namespace.yaml
# Configure secrets (edit first!)
kubectl apply -f deploy/k8s/secret.yaml
# Deploy
kubectl apply -f deploy/k8s/configmap.yaml
kubectl apply -f deploy/k8s/deployment.yaml
kubectl apply -f deploy/k8s/service.yaml
kubectl apply -f deploy/k8s/ingress.yaml
kubectl apply -f deploy/k8s/hpa.yaml
Prerequisites:
- External PostgreSQL (e.g., AWS RDS, Cloud SQL, managed PG)
- External Redis (e.g., ElastiCache, Memorystore)
- Ingress controller (nginx-ingress or similar)
- TLS certificates (cert-manager recommended)
Systemd Service
# /etc/systemd/system/pad.service
[Unit]
Description=Pad
After=network.target postgresql.service redis.service
[Service]
Type=simple
User=pad
Group=pad
ExecStart=/usr/local/bin/pad server start
Environment=PAD_HOST=0.0.0.0
Environment=PAD_DATA_DIR=/var/lib/pad
Environment=PAD_DB_DRIVER=postgres
Environment=PAD_DATABASE_URL=postgres://pad:secret@localhost:5432/pad
Environment=PAD_REDIS_URL=redis://localhost:6379
Restart=always
RestartSec=5
[Install]
WantedBy=multi-user.target
sudo systemctl daemon-reload
sudo systemctl enable --now pad
Reverse Proxy
Pad needs a reverse proxy for TLS termination. SSE connections require specific proxy settings to avoid buffering.
Caddy (Recommended)
Caddy handles TLS automatically. See deploy/Caddyfile:
pad.example.com {
reverse_proxy pad:7777 {
flush_interval -1
}
}
nginx
See deploy/nginx.conf. Critical settings for SSE:
location /api/v1/events {
proxy_buffering off;
proxy_cache off;
proxy_read_timeout 86400s;
proxy_http_version 1.1;
proxy_set_header Connection "";
}
Monitoring
Pad exposes Prometheus metrics at /metrics (unauthenticated). Key metrics:
| Metric | Type | Description |
|---|---|---|
pad_http_requests_total |
counter | Total HTTP requests by method, path, status |
pad_http_request_duration_seconds |
histogram | Request latency |
pad_http_response_size_bytes |
histogram | Response body sizes |
pad_sse_connections_active |
gauge | Connections on the workspace activity stream (/api/v1/events) only |
pad_stream_connections_active |
gauge | Held connections across both SSE endpoints — the population the limits bound |
pad_eventbus_publish_total |
counter | Events published |
pad_eventbus_subscribers |
gauge | Active event subscribers |
pad_db_open_connections |
gauge | Database connection pool stats |
Redis-specific metrics are listed under Redis health and metrics.
Health Check
Three endpoints, and they answer different questions:
# Liveness — is the process up? Kubernetes restarts the pod when this fails.
curl http://localhost:7777/api/v1/health/live
# {"status":"ok"}
# Readiness — can it serve traffic? Gated on the DATABASE only.
# Kubernetes should point its readinessProbe here.
curl -s http://localhost:7777/api/v1/health/ready
# {
# "status": "ready",
# "db": {"open_connections": 2, "in_use": 0, "idle": 2, "driver": "sqlite"},
# "redis": {"reachable": true, "probed": true, "last_check": "2026-08-22T01:00:00Z"}
# }
# Build info.
curl http://localhost:7777/api/v1/health
# {"status":"ok","version":"...","commit":"..."}
With Redis configured but unreachable, readiness stays 200 and the redis
block carries the failure — Pad serves everything except cross-instance
delivery without it, so readiness deliberately does not fail:
{
"status": "ready",
"redis": {
"reachable": false,
"probed": true,
"error": "dial tcp ...: connect: connection refused",
"degrades": [
"all activity events, including to clients on this instance",
"watch notifications",
"session presence and session-targeted push"
]
}
}
The redis block is absent entirely when no Redis is configured — "not
applicable" rather than "down".
Upgrading
Pad releases a new binary roughly weekly. Migrations run automatically at startup — only the ones your database is missing are applied, and each one commits atomically, so a failed migration rolls back cleanly and is retried on the next boot.
Only ever move forward. A newer binary can migrate an older database; an older binary cannot understand a newer schema. Pad enforces this with a schema-ahead guard: if the binary finds a database that carries migrations it doesn't ship (the signature of a downgrade — a rolled-back brew formula, an older Docker tag, a redeployed prior binary), it refuses to start instead of silently running old code against a newer schema and corrupting data.
database schema is newer than this pad binary: the database has N migration(s)
this binary doesn't ship (...) ... This almost always means the binary was
DOWNGRADED (e.g. brew/docker rollback) ... Upgrade pad back to a build that
includes those migrations, or ... re-run with `pad start --force`.
- Recover by reinstalling the newer binary (
brew upgrade pad, pull the newer Docker tag, redeploy the newer image). - Override — only if you have intentionally downgraded and accept the
data-corruption risk — with
pad start --forceorPAD_ALLOW_SCHEMA_AHEAD=1.
Pre-migration snapshot (SQLite)
When a SQLite-backed instance has pending migrations, Pad copies the database
file to pad.db.pre-<version> (next to the DB) before applying them. If an
upgrade goes wrong, stop the server and copy that file back over pad.db. It
is a convenience net, not a substitute for backups — take a real backup first
(see backup.md). The copy is best-effort: if it can't be written
(read-only volume, full disk) the server logs a warning and proceeds, so keep
your own backups regardless.
PostgreSQL is not snapshotted this way — take a pg_dump or provider snapshot
before upgrading (see backup.md).
Recommended flow
# 1. Back up (SQLite shown; pg_dump for Postgres — see backup.md)
pad db backup -o pad-backup-$(date +%Y%m%d).db
# 2. Stop, install the new binary, restart. Migrations + the pre-migration
# snapshot run automatically on start.
brew upgrade pad # or: docker pull, binary download, systemctl restart pad
# 3. Verify
pad --version
curl -s http://localhost:7777/api/v1/health # {"status":"ok"}
Production Checklist
- Database: PostgreSQL configured with
PAD_DB_DRIVER=postgres - Redis: Connected for multi-instance events, notifications, and session presence (
PAD_REDIS_URL), on a non-evictingmaxmemory-policy, single node (not a cluster) - Redis namespace:
PAD_REDIS_NAMESPACEset if this endpoint is shared with another Pad installation - Streaming limits:
PAD_SSE_MAX_CONNECTIONS/PAD_SSE_MAX_PER_USERsized for your fleet (both cover both SSE endpoints) - Redis alerting:
pad_redis_upandpad_watchevents_sequence_gaps_totalwired to alerts - TLS: Reverse proxy with valid certificates
- Secure cookies:
PAD_SECURE_COOKIES=true(requires TLS) - Public URL:
PAD_URLset to your public-facing domain - CORS:
PAD_CORS_ORIGINSset if serving from a different domain - Backups: PostgreSQL backup strategy in place (see
docs/backup.md) - Monitoring: Prometheus scraping
/metrics - Admin account: Created via
pad auth setupor web UI on first visit - Email (optional): Maileroo configured for invitation emails
- Resource limits: Set in Docker Compose or K8s manifests
- Log level:
PAD_LOG_LEVEL=info(usedebugonly for troubleshooting)