# Pad — local production setup with PostgreSQL + Redis # Usage: # 1. cp .env.example .env # generates a POSTGRES_PASSWORD if missing # 2. docker compose up -d # # Starts Pad with PostgreSQL for storage and Redis for real-time events, # notifications, and the shared session-presence registry. # The web UI binds to 127.0.0.1:7777 on the host by default — LAN/internet # access requires explicit opt-in (see PAD_BIND_ADDR below or the prod override). # First-time setup: visit the UI or run `pad auth setup` from a local CLI. services: pad: build: context: . dockerfile: Dockerfile ports: # Bind to loopback only by default. A fresh install exposes the bootstrap # endpoint until the first admin is created, so defaulting to 0.0.0.0 # would hand control of the instance to anyone who can route to the host. # Override by setting PAD_BIND_ADDR=0.0.0.0 (or a specific LAN IP) in .env. - "${PAD_BIND_ADDR:-127.0.0.1}:7777:7777" environment: # Container UID/GID for the pad process. Default to 1000/1000 for # backward compat with existing compose deploys whose volumes were # created under the previous USER pad (uid 1000) image. Override to # match your host volume's ownership if different. # # ${VAR-default} (no colon), not ${VAR:-default}. Compose follows # shell-like semantics — colon form would silently convert PUID= # or PGID= in .env to 1000, masking operator typos. Colon-less lets # explicit-empty values reach the entrypoint, which rejects them # with a clear error (matches docker-entrypoint.sh's own # ${VAR-99} convention). PUID: "${PUID-1000}" PGID: "${PGID-1000}" PAD_HOST: "0.0.0.0" PAD_PORT: "7777" PAD_DB_DRIVER: "postgres" # POSTGRES_PASSWORD is REQUIRED — docker compose refuses to start when unset. # Use libpq's keyword=value DSN (not the URI form) so passwords with # reserved URI characters like '/', '+', ':', '@' — common in # `openssl rand -base64` output — don't need percent-encoding and # can't break the connection string by accident. PAD_DATABASE_URL: "host=postgres port=5432 user=pad password=${POSTGRES_PASSWORD:?POSTGRES_PASSWORD is required — see .env.example} dbname=pad sslmode=disable" # PAD_ENCRYPTION_KEY is REQUIRED on Postgres — Pad won't auto-generate # one because a multi-replica deploy would generate a different key # per replica and fail cross-instance decryption. Generate with # `openssl rand -hex 32` and keep it stable across restarts. PAD_ENCRYPTION_KEY: "${PAD_ENCRYPTION_KEY:?PAD_ENCRYPTION_KEY is required — see .env.example}" PAD_REDIS_URL: "redis://redis:6379" PAD_DATA_DIR: "/data" PAD_LOG_LEVEL: "info" # Cloud-mode wiring — safe to keep enabled on self-host deploys # because every var defaults to empty and pad treats empty as # "not in cloud mode". Populate from .env when co-deploying with # pad-cloud so the reverse sidecar can cascade Stripe cancels on # account delete (TASK-690). PAD_CLOUD: "${PAD_CLOUD:-}" PAD_CLOUD_SECRET: "${PAD_CLOUD_SECRET:-}" PAD_CLOUD_SIDECAR_URL: "${PAD_CLOUD_SIDECAR_URL:-}" PAD_CLOUD_OUTBOUND_SECRET: "${PAD_CLOUD_OUTBOUND_SECRET:-}" volumes: - pad-data:/data depends_on: postgres: condition: service_healthy redis: condition: service_healthy restart: unless-stopped healthcheck: # Conditional: root → su-exec to pad; non-root → direct wget. # Mirrors the Dockerfile HEALTHCHECK so `docker run --user 1234` # pass-through still reports healthy. # # NOTE: $$(id -u), not $(id -u) — Compose performs $-interpolation # on YAML strings BEFORE the value reaches the container. Single # `$` would be parsed as a Compose variable reference; `$$` escapes # to a literal `$` so the shell inside the container sees `$(id -u)`. test: ["CMD-SHELL", "if [ \"$$(id -u)\" = \"0\" ]; then exec su-exec pad wget -q --spider http://localhost:7777/api/v1/health; fi; exec wget -q --spider http://localhost:7777/api/v1/health"] interval: 10s timeout: 5s retries: 3 # Bumped 10s → 60s. The always-chown-R policy in the entrypoint # means a user restoring a backup with a large attachment store # can spend tens of seconds before pad starts listening — 10s # would mark the container unhealthy mid-startup. start_period: 60s postgres: image: postgres:17-alpine environment: POSTGRES_USER: pad # Required. Fail-fast with the helpful error below when unset. POSTGRES_PASSWORD: "${POSTGRES_PASSWORD:?POSTGRES_PASSWORD is required — see .env.example}" POSTGRES_DB: pad volumes: - pg-data:/var/lib/postgresql/data healthcheck: test: ["CMD-SHELL", "pg_isready -U pad"] interval: 5s timeout: 3s retries: 5 restart: unless-stopped redis: image: redis:7-alpine command: redis-server --maxmemory 64mb --maxmemory-policy allkeys-lru volumes: - redis-data:/data healthcheck: test: ["CMD", "redis-cli", "ping"] interval: 5s timeout: 3s retries: 5 restart: unless-stopped volumes: pad-data: pg-data: redis-data: