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.
Docker Compose
Section titled “Docker Compose”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_PASSWORDis 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 to0.0.0.0. TRUST_PROXY_HEADERSdefaults to false, which is correct for the default loopback binding. Enable it behind a proxy only with an explicitTRUSTED_PROXY_CIDRSallowlist. 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.
Container hardening
Section titled “Container hardening”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: RuntimeDefaultandrunAsNonRoot: true(plusallowPrivilegeEscalation: false). The restricted Pod Security Standard reads the manifest, never the image, so the distrolessnonrootUSER does not satisfyrunAsNonRooton its own. - Compose-only:
security_opt: [no-new-privileges:true]on both services, and log rotation via thejson-filedriver (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.
Log rotation
Section titled “Log rotation”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.
The image
Section titled “The image”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.
The bare binary
Section titled “The bare binary”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.
Getting TRUST_PROXY_HEADERS right
Section titled “Getting TRUST_PROXY_HEADERS right”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.
Public exposure
Section titled “Public exposure”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.
Before you expose it
Section titled “Before you expose it”| Capability | Status | What to do instead |
|---|---|---|
| TLS | Not built | Parley 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 reality | Built in | It drives the WebSocket origin check, the cookie Secure flag and the OIDC redirect. Getting it wrong is the most common deployment failure. |
| Database backups | Not built | Nothing in Parley backs itself up. Set up pg_dump before you have data worth keeping. |
| Resource limits | Not built | The k8s manifest sets requests but no limits, on purpose. Add them once you have measured your own usage. |
| Horizontal scaling | Built in | Fanout, 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 control | Not built | Open mode is trusted-network-only. Public instances need a passcode or external SSO/auth proxy plus ingress abuse controls. |
Then walk the hardening checklist.