Skip to content

Deployment

Verified against v0.10.0 · docker-compose.yml, Dockerfile, deploy/k8s/deployment.yaml, .github/workflows/release.yml, cmd/parley/main.go

Three supported shapes: Compose, Kubernetes, or the binary on its own. All three are the same process with the same configuration — see the configuration reference for every variable.

For a first run, start at the Quickstart.

The shipped docker-compose.yml runs Parley plus a Postgres 16, with a named volume for the data.

Three of its defaults are deliberate and worth knowing before you change them:

  • The password has no default. POSTGRES_PASSWORD is written ${POSTGRES_PASSWORD:?...}, so Compose refuses to start without one rather than shipping a guessable default into production. Self-hosters paste compose files verbatim; this one will not let you paste a weak secret you did not choose.
  • The port is bound to loopback, 127.0.0.1:8080:8080. Nothing is exposed to your network until you decide to expose it. Prefer putting a reverse proxy in front over changing this to 0.0.0.0.
  • TRUST_PROXY_HEADERS defaults to false, which is correct for the default loopback binding. Enable it behind a proxy only with an explicit TRUSTED_PROXY_CIDRS allowlist. See below.
  • Postgres is pinned to a major version. An unpinned postgres: tag that crosses a major release leaves the data directory unreadable. See Upgrading.

Compose and the Kubernetes manifest do not carry the same hardening. Shared keys are cap_drop: [ALL] (both Compose services) and a read-only root on the app — Parley never writes to disk itself (the frontend and migrations are embedded, exports stream to the response), so no tmpfs mount is needed for it. The rest is per-runtime:

  • Kubernetes-only: seccompProfile: RuntimeDefault and runAsNonRoot: true (plus allowPrivilegeEscalation: false). The restricted Pod Security Standard reads the manifest, never the image, so the distroless nonroot USER does not satisfy runAsNonRoot on its own.
  • Compose-only: security_opt: [no-new-privileges:true] on both services, and log rotation via the json-file driver (max-size: 10m, max-file: 3). Kubernetes leaves container-log rotation to the node agent.

db stays writable. The official postgres image’s entrypoint starts as root to chown the data and socket directories, then drops privileges to the postgres user; that handoff needs CAP_CHOWN, CAP_SETUID, CAP_SETGID and CAP_DAC_OVERRIDE, which the compose file adds back with cap_add after the blanket drop. read_only: true on db is possible with tmpfs mounts for /tmp, /var/run/postgresql and /var/run on top of the already-writable data volume, but the compose file leaves it commented out rather than shipping a tmpfs list unverified against every distro that image ships for — uncomment it once you have confirmed it against your postgres:16-alpine pull.

Both services set logging: { driver: json-file, options: { max-size: 10m, max-file: 3 } }. Docker’s default json-file driver is unbounded — on a single self-hosted server nothing else rotates it, and an unbounded log will eventually fill the disk. max-size/max-file cap it at 30 MB per service.

Podman does not use json-file by default — it logs to journald instead, and these two options are silently ignored there. Rotation on Podman is controlled by journald’s own SystemMaxUse (see journald.conf), not by anything in this compose file.

ghcr.io/lets-parley/parley, multi-arch for linux/amd64 and linux/arm64, published from exact semantic-version GitHub Releases and anonymously pullable.

It is built in three stages and the final one is gcr.io/distroless/static-debian12:nonroot — no shell, no package manager, no libc to patch, running as a non-root user. The Go binary is static (CGO_ENABLED=0). The FIPS variant is a fourth, separate target (--target fips) so a default build does not compile it.

The image declares its own HEALTHCHECK, which runs parley -healthcheck against /readyz every 15 seconds. That probe uses loopback when BIND_ADDR is empty, 127.0.0.1, or localhost; otherwise it probes BIND_ADDR. docker-compose.yml repeats the same probe on the app service so docker compose up --wait waits for a healthy container, not merely a running one.

Pin a version rather than tracking latest if you want reproducible deploys. latest exists for quick evaluation only — it moves with every release, so using it in a durable install means an unplanned upgrade on the next pull, with no way to tell which version is actually running.

A -fips tag is published beside every version (0.10.0-fips, and latest-fips beside latest). It is the same binary built with GOFIPS140=v1.0.0 and run with GODEBUG=fips140=on (only panics on the RFC 6455 WebSocket SHA-1). The chart refuses latest-fips the same way it refuses latest; pin the versioned tag. See Cryptography for the inventory, the CMVP status of the module, and what that image does not cover (browser TLS is the proxy’s). Beginning with the first release produced by the hardened workflow, release assets include digest-qualified Compose and Kubernetes manifests, an image and a source SPDX SBOM, and verifiable provenance for the published digest. Releases v0.1.0 through v0.2.1 were not backfilled; see Supply chain.

go build ./cmd/parley produces a single file with the frontend inside it. Give it a DATABASE_URL and it runs.

PORT sets the port. Set BIND_ADDR to a bare host or IP address (no port) when the bare binary should listen on one interface — 127.0.0.1 keeps it on loopback. Empty, the default, still listens on every interface. Kubernetes pods should leave it unset; NetworkPolicy is the control.

This setting is wrong in both directions and both failures are real.

  • A client-reachable network is trusted — a caller can supply a forged X-Forwarded-For, defeating address-based identity and room-code controls.
  • Behind a proxy, set to false — every request appears to come from the proxy, so the whole internet shares one throttle bucket. Eight wrong guesses by anyone locks everyone out of that space.

Set it to true if and only if a proxy you control sets X-Forwarded-For itself. TRUSTED_PROXY_CIDRS is then required and must list every immediate and intermediate proxy hop, never client networks. Parley walks the chain right-to-left, ignores malformed headers and headers from untrusted immediate peers, and uses the first untrusted address. Both shipped manifests default proxy trust to false.

AUTH_MODE=open is for a trusted network. A public deployment needs a space passcode or an external SSO/authentication proxy, plus ingress controls for request rate, concurrent connections, and bandwidth. Built-in quotas and identity-creation throttles limit persistent growth but do not make anonymous public access an authentication boundary.

CapabilityStatusWhat to do instead
TLSNot builtParley never terminates TLS. Put Caddy or nginx in front — and it must be TLS, because BASE_URL being https is what sets the session cookie's Secure flag.
BASE_URL matching realityBuilt inIt drives the WebSocket origin check, the cookie Secure flag and the OIDC redirect. Getting it wrong is the most common deployment failure.
Database backupsNot builtNothing in Parley backs itself up. Set up pg_dump before you have data worth keeping.
Resource limitsNot builtThe k8s manifest sets requests but no limits, on purpose. Add them once you have measured your own usage.
Horizontal scalingBuilt inFanout, presence and the room-code throttle all go through Postgres, and simultaneous boots serialize their migrations, so replicas can be added. Size max_connections for replicas × 11 — 12 per pod briefly at boot, while it holds the migration lock on its own connection. See Scaling and limits.
Public access controlNot builtOpen mode is trusted-network-only. Public instances need a passcode or external SSO/auth proxy plus ingress abuse controls.

Then walk the hardening checklist.