Self-hosting partyline

partyline.sh runs on two boxes, and this page describes the same stack they run — not a self-host-only variant. Seven containers behind Caddy on one machine: Postgres, PostgREST, Redis, the web app, the relay, a ticker, and Caddy itself. There is no hosted dependency in the data path.

Everything on this page is fetchable without installing anything first: docker-compose.yml, the Caddyfile, the Postgres init script, the configuration script and an annotated reference listing every variable are all served from this site — copied, by a generator, from the ones our own boxes run.

Licence and terms

The server you are about to install is under the Elastic License 2.0. The ptln CLI is separate and stays MIT — it is a client, and nothing here restricts it. The split is deliberate: use the client however you like; run the server for yourself rather than reselling it.

The short version of ELv2, which is the part that matters to you:

Run it for free, for as many people as you like. There is no seat limit, no licence key, no trial, and no phone-home that gates anything. A team of three and a team of three hundred are the same to this licence.

Three limits, and they are the whole list:

  • Do not offer partyline to third parties as a hosted or managed service. Run it for your own organisation, not as a product you sell to others.
  • Do not remove or obscure the licensing and copyright notices.
  • Do not work around licence-key functionality, if any ever exists. Today none does.

There is no warranty and no support obligation on a self-hosted instance. If you want someone on the hook when it breaks, that is what the hosted service is for — and that, rather than a seat count, is the difference between the two.

This is a summary written for people, not a substitute for the licence. The full text is at /self-host/LICENSE and it governs.

Read this before you start

Nothing blocks a stranger any more. The images are on Docker Hub and anonymously pullable, the schema is published as an archive, and the stack files are served from this site — all three are fetchable before you have installed anything, which is the whole point.

Two honest limits, neither of which stops an install:

  • Joining a shared terminal session across instances is pinned to one trust root. See joining sessions.
  • There is no warranty and no support obligation on a self-hosted instance — see Licence and terms. If something breaks at 3am, it is yours.

If you get stuck, say so on the repo. A self-hoster who cannot finish is the most useful bug report this page can produce.

Prerequisites

  • One Linux box. 2 vCPU / 4 GB is what our staging box runs; Postgres is tuned for exactly that in the published compose file (shared_buffers=384MB).
  • Docker Engine 20.10+ with the Compose v2 plugin (docker compose version — not docker-compose).
  • A hostname pointed at the box, with ports 80 and 443 reachable. Caddy gets a certificate automatically on first boot; that requires inbound 80.
  • Port 2222 (or 22) if you want the relay, which is what carries shared terminal sessions to people outside your network. A single-box install with everyone on the same network can skip it.
  • openssl and python3env-bootstrap.sh uses both (random secrets, and minting the two PostgREST JWTs). Present on every mainstream distro.
  • Roughly 10 GB of free disk for images and the database.

Get the stack files

$ mkdir -p /opt/partyline/init && cd /opt/partyline
curl -fsSLO https://partyline.sh/self-host/docker-compose.yml
curl -fsSL  https://partyline.sh/self-host/Caddyfile        -o Caddyfile
curl -fsSL  https://partyline.sh/self-host/00-bootstrap.sh  -o init/00-bootstrap.sh
curl -fsSL  https://partyline.sh/self-host/env-bootstrap.sh -o env-bootstrap.sh
curl -fsSLO https://partyline.sh/self-host/env.example      # reference only — never copy it to .env
chmod +x init/00-bootstrap.sh env-bootstrap.sh

Those files are generated from the ones our own boxes run, on every commit, by make surface-gen. If the stack changes and the published copy does not, our CI fails — that is the mechanism that keeps this page from describing a stack nobody runs any more.

/opt/partyline is the path the compose file and our scripts assume. Anywhere works, but the paths in the rest of this page are that one.

Put your hostname in the Caddyfile

This is the one edit you must make by hand, and nothing works until you do. Caddy matches on the site address at the top of the file, which ships as the placeholder partyline.example.com — left as-is it answers nothing on your hostname and never requests a certificate for it.

$ sed -i "s|partyline.example.com|your-host.example.com|" Caddyfile
grep -m1 "{$" Caddyfile   # confirm the site address is now YOUR hostname

While you are in there, delete the header X-Robots-Tag "noindex, nofollow, noarchive" line at the bottom of the block unless you want your instance kept out of search results — it is there because the file is copied from staging, where being indexed would be a bug.

Everything else in that file is genuinely generic: handle_path /rest/v1/* to PostgREST, a catch-all to web, and the retry settings that let a request ride out a container swap instead of returning 502.

Get the images

$ docker pull partyline/partyline-web:latest
docker pull partyline/partyline-relay:latest

No docker login, no account, no token. These are on Docker Hub and anonymously pullable.

Tags. :latest is the image production is running — the exact digest, promoted after it passed through staging, not a rebuild. prod-<sha> is immutable and is what to pin if you want a deploy you can reproduce; a floating tag means your next pull silently changes the software. The compose file reads WEB_TAG and RELAY_TAG from .env, so pin like this:

WEB_TAG=prod-abc1234 RELAY_TAG=prod-abc1234

Why Docker Hub and not GHCR. Our own boxes pull these images from GitHub Container Registry, which they authenticate to. Making those packages anonymously pullable turned out to be an organisation setting reachable only through a browser — there is no API for it — so it could never be part of a deploy. Docker Hub carries the same digests, published by the same promotion, and needs no account to pull. One caveat worth knowing: Docker Hub rate-limits anonymous pulls (roughly 100 per six hours per IP). Invisible on a home connection; real behind a corporate NAT, where a docker login with any free account raises the limit.

The images themselves are environment-agnostic by construction: nothing about a hostname is compiled in, PGRST_URL is read at runtime, and that is deliberate, because our own production deploy promotes staging's exact bytes rather than rebuilding.

The exception is every NEXT_PUBLIC_ variable. Next inlines those at build time, into the server bundle as well as the browser one, so their values are fixed in the image you pulled and putting them in .env changes nothing. That is why the configuration table below lists them under "do not set these" and names the runtime variable to use instead — SITE_URL for the site origin, PGRST_URL and PGRST_ANON_KEY for PostgREST. Nothing about data access depends on the build-time names.

What each service does

ServiceImageWhat it doesExposed
postgrespostgres:16 (stock)Your data. Every tenancy rule is a Postgres RLS policy, so the database is the security boundary, not the app.no
postgrestpostgrest/postgrest:v12.2.3The REST API the app queries. Upstream OSS, predates Supabase. It verifies the session JWT the app mints and sets the claims RLS reads.no
redisredis:7-alpineEphemeral pub/sub. Nothing durable lives here.no
webdocker.io/partyline/partyline-webThe Next.js app: UI, /api/v1, auth callback, session minting.via Caddy
relaydocker.io/partyline/partyline-relayThe blind relay for shared terminal sessions. End-to-end encrypted — it sees ciphertext, never terminal contents or joiner names.RELAY_PORT, default 2222
tickerbusybox:1.36A 60-second loop that POSTs /api/v1/tick — reaps stale sessions, resumes rate-limited runs. Replaces pg_cron.no
caddycaddy:2-alpineTLS and routing. /rest/v1/* goes to PostgREST, everything else to the app.80, 443

The relay is the one service you can do without — it is what lets someone outside your network join a shared terminal session. It starts and serves with no configuration at all; RELAY_ID / RELAY_SECRET only control whether it registers itself with your control plane (see below). If you do not want it, docker compose up -d --scale relay=0.

One other leftover of the file being ours: the web service pins NEXT_PUBLIC_PARTYLINE_ENV=staging in its environment: block. It is a label, nothing more — it is the environment name attached to Sentry and PostHog events — so it changes no behaviour, but edit it if you wire up either of those and would rather not read your own errors as "staging".

There is no GoTrue, no Supabase Storage, no Realtime, and no Kong. Auth is code in web; the app mints its own session token. Realtime was deleted outright — the app polls, and the reconcilers that polling drives were already load-bearing.

Configure

Configuration lives in one file, /opt/partyline/.env, chmod 600. Most of it the box can work out for itself, so run env-bootstrap.sh rather than assembling it by hand:

$ ./env-bootstrap.sh https://your-host.example.com

It is idempotent — it only ever adds what is missing, so it is safe to re-run on a live or half-configured box — and it does three things:

  1. Generates every random secret (POSTGRES_PASSWORD, AUTHENTICATOR_PASSWORD, SESSION_JWT_SECRET, TICK_SECRET, RELAY_SECRET, …) with openssl rand.
  2. Mints PGRST_ANON_KEY and SUPABASE_SERVICE_ROLE_KEY. These are HS256 JWTs signed with your SESSION_JWT_SECRET, which is why they cannot be copied from anywhere or handed out in a template: a key minted against a different secret is meaningless to your PostgREST.
  3. Derives what follows from your hostname — SITE_URL, PGRST_URL, WORKOS_REDIRECT_URI, RELAY_API.

PGRST_URL is your site URL, not http://postgrest:3000: the client appends /rest/v1, and Caddy routes that prefix to PostgREST. That is the job Kong does in Supabase's own stack.

It then prints what it could not know — the real third-party credentials. Append those to .env yourself: WorkOS (below), your S3 endpoint, bucket and keys (below), and any feature you want out of the table further down.

Do not seed .env from env.example first. That file is a reference — every variable, with the reason it exists — and the published copy deliberately carries no values for exactly this reason: idempotence cuts both ways. env-bootstrap.sh adds only what is missing, so a variable that is already present is left alone permanently, and a wrong value pasted in beforehand is never corrected by re-running it. Bootstrap first, read env.example to understand what you are looking at, and add credentials by hand afterwards.

Two rules that are worth more than they look:

  • SESSION_JWT_SECRET must be byte-identical to what PostgREST verifies with, and must have no trailing newline. The compose file already wires it to PGRST_JWT_SECRET, so the first half is handled. The second half is not theoretical: a trailing \n does not throw anywhere. The app happily mints tokens PostgREST then rejects, so you are "logged in" and every query returns nothing, with no error in any log. Use printf rather than echo, or trim it.
  • SUPABASE_SERVICE_ROLE_KEY bypasses RLS. Treat it as a root password. (The name is a leftover; there is no Supabase behind it.)

The table below is generated from the code, from the same declaration ptln server doctor and the env reference read. It cannot drift from what the app actually looks up.

Read the groups carefully: "the app reads this" and "you set this" are different claims, and the table keeps them apart. Required is the first one — five variables, all of which env-bootstrap.sh writes for you. The variables in Do not set these are read by the app but supplied by something else (Next sets some itself; the NEXT_PUBLIC_ ones are compiled into the image), so putting them in .env accomplishes nothing — the runtime name to use instead is given per row.

Required — you set these in .env

The box is not correctly configured until every one of these has a value. env-bootstrap.sh writes all of them: it generates the secrets, mints the two PostgREST JWTs, and derives the rest from your hostname.

VariableWhat it is
PGRST_ANON_KEYthe anon JWT the server-side PostgREST client presents
PGRST_URLPostgREST's origin, read at RUNTIME so one image can serve both boxes
SESSION_JWT_SECRETsigns our session JWTs; must be byte-identical to PGRST_JWT_SECRET and TRIMMED — a trailing newline mints tokens PostgREST silently rejects
SITE_URLthe canonical origin every server-rendered link and email is built from
SUPABASE_SERVICE_ROLE_KEYthe admin bypass every adminClient() call uses — treat as root

Do not set these — something else supplies them

The app reads them, so they belong on a list of what a box needs, but they are not yours to set: Next supplies some itself, and the NEXT_PUBLIC_ ones are inlined into the image at build time, so a value in .env is read by nothing. Where there is a runtime variable to set instead, it is named below.

VariableWho sets it, and what to set instead
NEXT_PUBLIC_PARTYLINE_ENVinlined into the image at build time, and the compose file sets it for the web container — a value here reaches nothing
NEXT_PUBLIC_SITE_URLinlined into the image at build time; set SITE_URL instead, which is read at runtime
NEXT_PUBLIC_SUPABASE_ANON_KEYthe build-time name for the anon key. Set PGRST_ANON_KEY — that is the one read at runtime
NEXT_PUBLIC_SUPABASE_URLthe build-time name for PostgREST's origin. Set PGRST_URL — that is the one read at runtime
NEXT_RUNTIMENext sets this itself, per request, to pick the server vs edge Sentry config
NODE_ENVNext sets this itself; the production image is already production

Read by the stack, not by the app

docker compose and the Postgres init script read these; application code never does. They still go in the same .env, and you still set them.

VariableWhat it is
AUTHENTICATOR_PASSWORDPostgREST's login role; must match the bootstrap in deploy/stack/init/
MINIO_REPLICASset 0 to stop running the bundled MinIO on a box that points S3_* at R2 or S3; unset = 1
MINIO_ROOT_PASSWORDthe bundled MinIO's root password, generated by scripts/env-bootstrap.sh — never a default
MINIO_ROOT_USERthe bundled MinIO's root user, generated by scripts/env-bootstrap.sh
POSTGRES_PASSWORDthe database superuser password (openssl rand -base64 32)
RELAY_IMAGEoverrides the WHOLE relay image reference; the only way to pin a digest, since @ is illegal in a tag. Unset = whatever RELAY_TAG names
RELAY_TAGthe relay image tag compose runs; upserted by the deploy workflow alongside WEB_TAG
WEB_IMAGEoverrides the WHOLE web image reference, e.g. ghcr.io/partyline-sh/partyline-web@sha256:… or your own mirror. Unset = whatever WEB_TAG names
WEB_TAGthe image tag compose runs; upserted by the deploy workflow, set by hand to pin or roll back. The images are public — see deploy/stack/README.md for pinning

Optional

A supported unset state with a documented default.

VariableWhat it is
AUTH_PROVIDERwhich sign-in adapter to use; unset = oidc when OIDC_ISSUER is set, otherwise workos, which is what partyline.sh runs
OIDC_REDIRECT_URIthe generic OIDC callback; falls back to SITE_URL + /api/auth/callback
OIDC_SCOPESscopes requested from the OIDC issuer; unset = "openid email profile", which is what the profile mapping needs
PARTYLINE_CLI_NOTICEone-line notice shown to CLIs on their update check; empty = none
PARTYLINE_MIN_CLIminimum supported CLI version; unset = 0.0.0, nothing is gated
PARTYLINE_RELEASErelease tag attached to Sentry events; unset = untagged
PARTYLINE_REVIEW_PORTlocal port the review host listens on (127.0.0.1 only); unset = 7391
PARTYLINE_SCRIBE_MODELoverrides the scribe's default model
PARTYLINE_SCRIBE_PROVIDERoverrides the scribe's default provider (anthropic)
POSTHOG_HOSTPostHog region; unset = US cloud. Set https://eu.i.posthog.com for EU
R2_ACCESS_KEY_IDlegacy name still honoured as a fallback for S3_ACCESS_KEY_ID; do not set on a new box
R2_BUCKETlegacy name still honoured as a fallback for S3_BUCKET; do not set on a new box
R2_ENDPOINTlegacy name still honoured as a fallback for S3_ENDPOINT; do not set on a new box
R2_SECRET_ACCESS_KEYlegacy name still honoured as a fallback for S3_SECRET_ACCESS_KEY; do not set on a new box
S3_FORCE_PATH_STYLEoverrides object-storage addressing; unset = path style for a bare host (MinIO), virtual-hosted otherwise
S3_REGIONobject-storage region; unset = auto, which R2 and MinIO accept. Real AWS S3 needs the bucket's region
SLACK_STATE_SECRETsigns the Slack OAuth CSRF state; falls back to SESSION_JWT_SECRET, which is the #175 footgun — set it to finish the split
SUPABASE_JWT_SECRETlegacy name still honoured as a fallback for SLACK_STATE_SECRET; do not set on a new box
WORKOS_REDIRECT_URIOAuth callback; falls back to SITE_URL + /api/auth/callback

Features — all-or-nothing, one block at a time

A feature is configured when every variable in its block is set, and not configured otherwise. Two states, no middle: leaving a block empty is a supported choice and that feature simply stays dark. ptln server doctor reports each one and names the variables a not-configured feature is missing.

FeatureVariables (all of them, or none)
Operator consolePARTYLINE_ADMIN_EMAILS
Billing (Stripe)STRIPE_PRICE_ANNUAL STRIPE_PRICE_MONTHLY STRIPE_SECRET_KEY STRIPE_WEBHOOK_SECRET
Discord botDISCORD_BOT_TOKEN DISCORD_PUBLIC_KEY
Transactional email (Resend)RESEND_API_KEY RESEND_FROM
GitHub AppGITHUB_APP_ID GITHUB_APP_PRIVATE_KEY_B64 GITHUB_APP_SLUG
GitHub webhook (board reconciliation)GITHUB_WEBHOOK_SECRET
Invite-only join assertionsPARTYLINE_ASSERT_KEY
Marketing audience (Loops)LOOPS_API_KEY
Authentication (generic OIDC)OIDC_CLIENT_ID OIDC_CLIENT_SECRET OIDC_ISSUER
Operator signup alerts (Slack webhook)OPERATOR_SLACK_WEBHOOK
RedisREDIS_URL
Relay (pppp.sh)RELAY_ID RELAY_SECRET
Scribe (server-side distillation)PARTYLINE_SCRIBE_KEY
Error reporting (Sentry)SENTRY_DSN
Session key encryption at restSESSION_KEY_WRAP
Signup webhook (internal Slack channel)SLACK_WEBHOOK_URL
Slack appSLACK_CLIENT_ID SLACK_CLIENT_SECRET SLACK_SIGNING_SECRET
Object storage (S3 API — MinIO ships in the stack; R2/S3 by changing these four)S3_ACCESS_KEY_ID S3_BUCKET S3_ENDPOINT S3_SECRET_ACCESS_KEY
Telegram botTELEGRAM_BOT_TOKEN TELEGRAM_WEBHOOK_SECRET
Product telemetry (PostHog)POSTHOG_KEY
Ticker (scheduled sweeps)TICK_SECRET
Authentication (WorkOS)WORKOS_API_KEY WORKOS_CLIENT_ID

Bring the stack up and apply migrations

Fetch the schema first. It is one archive, and the SQL inside is byte-for-byte what runs against partyline.sh's own database — no annotations, no repackaging:

curl -fsSLO https://partyline.sh/self-host/migrations.tar.gz
tar xzf migrations.tar.gz          # produces ./migrations/

apply-migrations.sh reads that directory. It is idempotent: every file is recorded in a schema_migrations ledger as it is applied, so re-running it is safe and only ever applies what is missing. The BASELINE marker in the archive is what tells it that a database which ALREADY has a schema should record history rather than replay it — which matters, because a handful of the older migrations can no longer run against a modern schema and were never meant to.

$ cd /opt/partyline
docker compose up -d postgres
docker compose logs -f postgres   # wait for "database system is ready to accept connections"

init/00-bootstrap.sh runs once, on an empty data directory, before anything else. It creates the anon / authenticated / service_role / authenticator roles, the auth schema and the auth.users table the schema's foreign keys point at. Without it the first migration fails on line 11 with "schema auth does not exist" and every migration behind it cascade-fails. It is mounted read-only at /docker-entrypoint-initdb.d, and it reads AUTHENTICATOR_PASSWORD from the environment — if that variable is unset the bootstrap aborts and the database comes up with no roles at all.

Then apply the migrations, in filename order, recording each one:

$ psql() { docker compose exec -T postgres psql -U postgres -d partyline -v ON_ERROR_STOP=1 "$@" </dev/null; }

psql -c "create table if not exists schema_migrations (
         version text primary key, applied_at timestamptz not null default now());"

for f in migrations/*.sql; do
v="$(basename "$f")"
[ -n "$(psql -tAc "select 1 from schema_migrations where version = '$v'")" ] && continue
docker compose exec -T postgres psql -U postgres -d partyline -v ON_ERROR_STOP=1 < "$f"
psql -c "insert into schema_migrations(version) values ('$v')"
done

That is the shape of deploy/stack/apply-migrations.sh, which is what our deploys run — plain psql, no vendor CLI, no db push. You need the migration files, and they are not published yet (see the top of this page). The </dev/null on the query helper is not decoration: docker compose exec -T reads stdin, and without it a heredoc-driven script gets eaten by the first psql call — which once reported three successful deploys against a database with one table.

Migrations are additive and backward-compatible by contract, so applying them before swapping the app is safe, and a failed migration should stop you rather than be worked around.

Now start the rest:

$ docker compose up -d
curl -fsS https://your-host.example.com/api/health

/api/health touches no dependency — it answers whether the web container is serving, and nothing more.

First sign-in

Today the identity provider is WorkOS, and you bring your own keys. A free-tier account works. Create an application, then set WORKOS_API_KEY and WORKOS_CLIENT_ID, and add https://your-host.example.com/api/auth/callback as a redirect URI in the WorkOS dashboard. WORKOS_REDIRECT_URI is optional — unset, it is derived from SITE_URL.

Sign in at /login. On the first callback the app creates your auth.users row, and a database trigger creates your profile and your org with it. The first person to sign in is the owner of a new, empty instance — there is no separate bootstrap step and no seed account. After that callback WorkOS is not involved in a single request until your next login: partyline owns identity storage and mints its own session token.

Add your address to PARTYLINE_ADMIN_EMAILS if you want the operator console.

A generic OIDC adapter is the decided answer here and it is not built. The seam exists — the app already mints its own sessions, so WorkOS is one adapter behind it — but there is no configuration today that points partyline at Keycloak or Authentik. Do not plan around it yet.

Object storage

Attachments (party uploads, work-item files, skill bundles) go to an S3-compatible endpoint. Anything speaking the S3 API works; Cloudflare R2 is what we run. Four variables, all required together: R2_ENDPOINT, R2_BUCKET, R2_ACCESS_KEY_ID, R2_SECRET_ACCESS_KEY.

The bucket should be private. Every byte is served through the app's own authenticated routes after an RLS check; nothing needs public read.

MinIO in the compose stack is decided and not built. The wrapper is plain S3 and would work against a MinIO container, but nothing ships one, generates its credentials, or creates the bucket. Until that lands, self-hosting still needs an S3 endpoint from somewhere — which for a fully local install means running MinIO yourself and pointing the four R2_* variables at it.

Connect GitHub, so the board matches reality

Optional, and the single most worthwhile optional thing on this page.

What it does. partyline knows what it did — it dispatched a run, a worker built a branch, a pull request was opened. It does not know what you did. Merging happens on GitHub, so without this the one event that finishes a piece of work is invisible: the card sits in Review until somebody clicks Accept, which is pure bookkeeping once the pull request is already merged.

Why it matters more than it sounds. That gap accumulates silently. On our own instance it reached 33 finished runs sitting in Review and 26 of them additionally blocked behind merge conflicts with pull requests that had themselves already merged — a conflict that cannot exist, because a merged pull request has no branch left to conflict with. None of it was hard work. It was unobserved work, and it is invisible until somebody wonders why the board looks nothing like the repository.

With the webhook connected, a merged pull request moves its card to Shipped by itself, and stops blocking everyone else's merge gate.

Set it up. Generate a secret, put the same value in two places, and subscribe to one event.

openssl rand -hex 32

Put it in your .env as GITHUB_WEBHOOK_SECRET and restart the web service. Then, in GitHub — either your organisation's Settings → Webhooks, or your GitHub App's webhook settings if you use one — add a webhook:

FieldValue
Payload URLhttps://<your host>/api/v1/git-hosts/github/webhook
Content typeapplication/json — not form-encoded, or every delivery fails to parse
Secretthe value you generated
EventsPull requests only

Check it took. An unsigned request should be refused:

curl -s -o /dev/null -w '%{http_code}\n' -X POST \
  https://<your host>/api/v1/git-hosts/github/webhook \
  -H 'x-github-event: ping' -d '{}'
  • 401 — correct. The secret is configured and it rejected an unsigned request.
  • 503 — the secret is not set, or the service has not restarted.

On safety. The endpoint is public and unauthenticated, so the signature is the whole security model: every delivery is verified with HMAC-SHA256 over the raw body, compared in constant time. If no secret is configured it returns 503 rather than trusting the body — an endpoint that mutates run state on an unsigned POST would be a stranger's write access to your board. Leaving it unset is a supported choice; the board simply keeps drifting, exactly as it does without the webhook at all.

One coverage note: an organisation webhook covers repositories in that organisation. If your projects span several owners, use the GitHub App's webhook instead — it fires for every repository the App is installed on.

Point a CLI at your instance

The CLI is one binary and talks to whichever control plane PARTYLINE_API names:

$ brew install partyline-sh/tap/partyline    # or: curl -fsSL https://partyline.sh/install.sh | sh

export PARTYLINE_API=https://your-host.example.com
ptln login
ptln whoami

Credentials are stored per control plane — production keeps ~/.partyline/, everything else gets ~/.partyline/envs/<host>/ — so pointing the same binary at your instance cannot overwrite a login to another one, and switching is just the environment variable.

PARTYLINE_RELAY does the same for the relay host, if you are running one.

Turn off telemetry and update checks yourself. The CLI sends an anonymous daily ping and checks our release channel for a newer version. Neither is automatic-off for a self-hosted instance yet — that is the unbuilt slice named at the top — so set it explicitly on machines that talk to your box:

$ export DO_NOT_TRACK=1            # or PARTYLINE_TELEMETRY=0
export PARTYLINE_NO_UPDATE_CHECK=1

Registering your relay

The control plane assigns a relay to each session from a pool table, and that pool is seeded by hand — there is no CLI or API command for it. Insert one row per relay:

$ docker compose exec -T postgres psql -U postgres -d partyline -c \
"insert into relays (id, endpoint, region) values
   ('self-1', 'your-host.example.com:2222', 'self');"

Then set three things in .env: RELAY_ID=self-1, a RELAY_SECRET shared by the web and relay containers (the relay presents it on every heartbeat and the app compares it in constant time), and RELAY_API=https://your-host.example.com. That last one matters more than it looks: it defaults to https://partyline.sh, so a relay with an id and secret but no RELAY_API heartbeats at our control plane instead of yours.

A relay that has never heartbeated is treated as healthy, so a freshly-seeded row works immediately; one that goes quiet for 90 seconds stops receiving new sessions. If the pool is empty, session creation does not fail — the host falls back to whatever relay its CLI is configured for, which is pppp.sh unless PARTYLINE_RELAY says otherwise.

Joining sessions: the one real limitation

When someone joins a shared terminal session, the control plane signs a short-lived assertion ("partyline says this is alice@acme.com, joining this code") and the host's CLI verifies it against a public key compiled into the binary. Your instance signs with its own PARTYLINE_ASSERT_KEY, and the released ptln does not know that key.

Concretely, on a self-hosted instance today: a joiner is accepted as an unverified guest rather than a named, verified one, and a session marked invite-only refuses them outright. Sessions still work; the identity badge does not. A configurable trust root with pin-on-first-login is designed — one trusted instance at a time, fingerprint printed, a changed key refused rather than warned about — and is not built.

Verify

ptln server doctor is the only ptln server subcommand that exists today. It reads its own process environment, so run it on the box with .env loaded — the values live in the containers, not in your login shell:

$ set -a; . /opt/partyline/.env; set +a
ptln server doctor
ptln server doctor --json   # the same report, machine-readable

It reads the same feature registry this page's table is generated from and reports every feature as configured or not configured, naming the variables a not-configured one is missing. Two states, no middle — a box deliberately running without Stripe is correctly configured, so it exits 0 either way.

It prints names and set/unset only, never a value, which is what makes its output safe to paste into an issue or a chat thread.

Then check the stack itself:

$ docker compose ps                                   # seven services up
curl -fsS https://your-host.example.com/api/health   # the app is serving
docker compose exec -T postgres psql -U postgres -d partyline \
-c "select count(*) from schema_migrations;"       # the schema is applied

If TLS never comes up — a browser timeout, or Caddy logging that it has no site for your host — the Caddyfile is still on the placeholder hostname. grep -m1 "{$" Caddyfile says which. Certificates are requested per site address, so Caddy cannot get one for a name it was never told about.

If the app renders but every page is empty while you appear logged in, you are almost certainly looking at one of two things, and both fail silently: a trailing newline on SESSION_JWT_SECRET, or missing table grants (42501 permission denied). init/00-bootstrap.sh grants authenticated and service_role everything in public, but it runs before any migration, so grant all on all tables matches nothing that exists yet. On a fresh box, check grants first.

Backups are yours

Nothing in this stack backs itself up. One Postgres volume holds everything:

$ docker compose exec -T postgres pg_dump -U postgres partyline | gzip > partyline-$(date +%F).sql.gz

Put that on a timer and off the box. We run the same command by hand on production, which is an honest statement of how much automation exists here: none.