README.md
Ployz Dashboard
Ployz Cloud: web UI, backend, and durable workflows. It authors each Deploy Intent and drives Clusters through Machine RPC; it is not runtime authority. The deployment engine and SDK live in core/.
Develop
Run commands from dashboard/. Install Node and pnpm versions from package.json,
plus Rust (rustup) for the locally linked SDK (see core).
| 1 | pnpm install --frozen-lockfile |
| 2 | cp .env.example .env |
| 3 | docker compose up -d |
| 4 | pnpm db:migrate |
| 5 | pnpm dev |
| 6 | # In a second terminal (rebuild after worker code changes): |
| 7 | pnpm exec vite build --config vite.worker.config.ts |
| 8 | PORT=3001 INNGEST_DEV=1 pnpm start:worker |
Configure .env before running migrations or starting the app. pnpm dev builds
the SDK and starts the app through Portless. pnpm pr:check runs CI's checks, for reproducing a CI failure locally.
Compose uses the stable project name ployz-cloud so moving the checkout keeps
the same database volume. If an existing local stack uses another project name,
retain it with docker compose -p <existing-name>.
Environment
Web and worker read the same variables and exit with a ConfigError naming
what is missing or invalid.
| Variable | Required | Notes |
|---|---|---|
DATABASE_URL | yes | Postgres URL |
APP_URL | yes | public URL of web |
BETTER_AUTH_SECRET | yes | auth session secret |
APP_ENCRYPTION_SECRET | yes | 32+ characters; encrypts stored credentials, keep it stable |
GITHUB_CLIENT_ID, GITHUB_CLIENT_SECRET | yes | GitHub OAuth App (sign-in) |
GITHUB_APP_ID, GITHUB_APP_SLUG, GITHUB_APP_PRIVATE_KEY, GITHUB_APP_CLIENT_ID, GITHUB_APP_CLIENT_SECRET, GITHUB_APP_WEBHOOK_SECRET | yes | GitHub App (repository access) |
INNGEST_EVENT_KEY, INNGEST_SIGNING_KEY | yes | shared with the Inngest server |
PLOYZ_HOSTED_DNS_URL | yes | Hosted DNS, which grants Cluster Domains: https://dns.ployz.app/ for Ployz-hosted and Self-hosted Cloud; tests and local dev use a closed port |
INNGEST_BASE_URL, INNGEST_CONNECT_GATEWAY_URL | no | Inngest API and Connect gateway; unset, they point at Inngest Cloud, so local and self-hosted setups set both |
BETTER_AUTH_TRUSTED_ORIGINS, PORT | no | PORT defaults to 3000 |
POLAR_ACCESS_TOKEN, POLAR_WEBHOOK_SECRET, POLAR_PRODUCT_ID, POLAR_SERVER | no | all or none; none disables billing and every Organization is unlimited |
UPTIME_ENABLED | no | true on the dashboard when an uptime checker (uptime/) runs; unset hides uptime |
ANALYTICS_ENABLED | no | true on a Self-hosted Cloud to issue analytics salts and store pageviews; Ployz-hosted Cloud is always on |
POSTHOG_KEY, POSTHOG_HOST | no | product analytics, Ployz-hosted only; no key disables it. POSTHOG_HOST defaults to PostHog US Cloud; pnpm posthog:backfill sends existing users and Organizations once |
Build and deploy
pnpm build compiles the native SDK and config WASM, then builds the web and
Connect worker into .output/. Dockerfile.cloud packages both in one image;
the root Dockerfile builds the same image from source, so Railway can deploy
any branch without CI:
| Process | Start command | Healthcheck |
|---|---|---|
| Web | npm start | /api/health |
| Worker | npm run start:worker | /ready |
Run one worker alongside the web service. Both use the same application variables
and database; only web needs public ingress. The worker uses INNGEST_BASE_URL
for the Inngest API and, for a self-hosted gateway, set
INNGEST_CONNECT_GATEWAY_URL=ws://inngest.railway.internal:8289/v0/connect.
The equivalent local dev defaults are ports 8288 and 8289. Use different PORT
values when running both processes directly on the same host.
Connect registers the ployz-cloud functions automatically; web exposes no
/api/inngest route. Both processes run the same image.
On SIGTERM/SIGINT, Connect stops accepting new steps and finishes active steps
before the worker disposes database and SDK resources. Set Railway worker
RAILWAY_DEPLOYMENT_DRAINING_SECONDS=1800 (or a longer operational allowance);
on Docker use the corresponding stop_grace_period. This is a shutdown grace
period, not a deployment execution timeout. A crash or forced kill can still
leave remote effects unknown; interrupted deployment steps are not blindly retried.
Run migrations once before a rollout, not independently on each process.
See DESIGN.md for product design and CONTEXT.md for the Dashboard glossary.
Cloud image deployment
The existing Cloud workflow reuses the matching SDK artifact (and the shared
kache/R2 compiler cache on misses), builds both entrypoints, and packages
Dockerfile.cloud. The final image smoke test loads the native SDK inside the
runtime container. CI uses Ubuntu 24.04; the runtime uses Node 24 on Debian Trixie
so its glibc supports that native artifact.
Railway builds web and worker from this repository's root Dockerfile on every
push to main, so both run the same commit within a build of each other. Web runs
migrations before it deploys; worker overrides the start command
(node .output/worker/index.mjs) and keeps its /ready healthcheck and
30-minute drain.
Each release tag also publishes ghcr.io/getployz/ployz-cloud:<tag> from the
same build, and attaches ployz-cloud-compose.yml (pinned to that tag) and
ployz-cloud.env.example to the GitHub release.
Self-host
Self-hosted Cloud runs the released image as web and worker beside Inngest,
Redis, and Postgres (self-host/compose.yml). You bring
your own GitHub apps; billing is off, so Organizations are unlimited. The relay,
Hosted DNS, installer, and release binaries remain Ployz-hosted.
| 1 | browser ──TLS proxy──► web :3000 ──┐ |
| 2 | GitHub webhooks ───────► web ├─► Postgres (ployz_cloud, inngest) |
| 3 | worker ◄───┤─► Inngest :8288 API / :8289 Connect ─► Redis |
The image is linux/amd64. Put a TLS proxy in front of web's WEB_PORT. Web is
published on WEB_BIND (default 127.0.0.1) because Docker-published ports skip UFW;
a proxy on another host needs a private WEB_BIND, never 0.0.0.0. Inngest's UI is
bound to 127.0.0.1:8288 for operator use only.
1. Create the GitHub apps
Replace https://cloud.example.com with your APP_URL.
OAuth App (sign-in) at GitHub → Settings → Developer settings → OAuth Apps:
| Field | Value |
|---|---|
| Homepage URL | https://cloud.example.com |
| Authorization callback URL | https://cloud.example.com/api/auth/callback/github |
Copy the Client ID to GITHUB_CLIENT_ID and a new client secret to GITHUB_CLIENT_SECRET.
GitHub App (repository access) at GitHub → Settings → Developer settings → GitHub Apps (or under your organization):
| Field | Value |
|---|---|
| Homepage URL | https://cloud.example.com |
| Callback URL | https://cloud.example.com/api/github/callback |
| Request user authorization (OAuth) during installation | Checked |
| Webhook URL | https://cloud.example.com/api/github/webhook (Active) |
| Webhook secret | openssl rand -hex 32, also GITHUB_APP_WEBHOOK_SECRET |
| Repository permissions | Contents: Read-only, Checks: Read and write, Metadata: Read-only, Actions: Read and write, Pull requests: Read-only |
| Subscribe to events | Push, Check suite, Workflow run, Pull request |
Actions: Read and write and the Workflow run event are for GitHub builds: Cloud checks for
the ployz-build.yml workflow and dispatches it. Installations created before you add
them keep building on servers until their owner approves the new permissions in GitHub.
Pull requests: Read-only, Checks: Read and write and the Pull request event are for PR Environments: Cloud makes one per pull request and posts a "Ployz · ready to merge" check. Add them only after upgrading to a release that handles pull request deliveries; older releases answer those deliveries with an error. Installations created before you add them get no PR Environments until their owner approves the new permissions in GitHub.
Installation events are delivered without subscribing. Copy the App ID to
GITHUB_APP_ID, the URL name from github.com/apps/<slug> to GITHUB_APP_SLUG,
and a generated private key (the whole PEM, quoted) to GITHUB_APP_PRIVATE_KEY.
Copy its Client ID and a new client secret to GITHUB_APP_CLIENT_ID and
GITHUB_APP_CLIENT_SECRET.
One App for both. The GitHub App can also serve sign-in in place of the OAuth
App: set GITHUB_CLIENT_ID/GITHUB_CLIENT_SECRET to its client ID and secret. It
then needs the account permission Email addresses: Read-only, for private emails at
sign-in, and both Callback URLs, /api/github/callback first and
/api/auth/callback/github second, because GitHub sends a fresh installation back
to the first one.
2. Configure and start
Download ployz-cloud-compose.yml as compose.yml and ployz-cloud.env.example
as .env from the release into one directory, then fill in .env. Every
uncommented variable is required; web and worker exit at boot, naming each
missing or malformed variable.
Leave all POLAR_* and POSTHOG_* variables unset.
| 1 | docker compose run --rm web npm run db:migrate # explicit, once per install/upgrade |
| 2 | docker compose up -d |
| 3 | docker compose ps # worker turns healthy once Connected |
Neither web nor worker migrates on boot. The worker's healthcheck is /ready:
200 while its Inngest Connect connection is active, 503 otherwise. It is not a
database, schema, or GitHub check. Compose gives the worker a 30-minute
stop_grace_period so active steps drain.
3. Sign in, then install the GitHub App
Sign in to Cloud with GitHub first, then install the GitHub App as that same
GitHub user (from Cloud or github.com/apps/<slug>). Installation webhooks are
only attached to a GitHub account already linked in Cloud.
Upgrade
Replace compose.yml with the asset from releases/download/<tag>/ (or bump
PLOYZ_CLOUD_VERSION), never releases/latest: that is the newest stable
release and silently downgrades a prerelease install. An image older than the
database refuses db:migrate, web and worker startup (scripts/check-schema.ts),
naming its version; PLOYZ_ALLOW_OLDER_IMAGE=1 starts it anyway, with a warning, for a
rollback. Hosted web runs the same check, so a Railway rollback past a migration needs that
variable on web. The user docs' upgrade snippet refuses a downgrade. Then:
| 1 | docker compose pull |
| 2 | docker compose run --rm web npm run db:migrate |
| 3 | docker compose up -d |