Docs
Self-hosting
Install powder on one Linux box with Docker Compose, set the env file, sign in as the first admin and add servers over SSH.
Powder is single-tenant: one install per person or team, for their own servers. There is no signup. The first GitHub login on an empty install becomes the admin.
Powder is one Next.js app (powder, the dashboard and API), a worker container (worker, which executes deploys) and a Postgres database. It reaches your servers over SSH with a key you control.
Requirements
- A Linux box for powder itself. 2 GB of RAM is enough: the builds run on your app servers, not on this box.
- Docker with the Compose plugin.
- Postgres 15 or newer. The reference compose file bundles
pgvector/pgvector:pg16, digest-pinned. - An SSH keypair whose public key is in
authorized_keyson every server you want powder to manage. Create it in step 1 below. The private key is mounted into the powder container. - A GitHub OAuth app, used for dashboard login and the first-run admin.
Install on one box
Steps 1 to 4 run on the box that will run powder. Step 5 runs from a workstation.
On the box, clone a release tag of the repository (https://github.com/powder-sh/powder) to
/opt/apps/powder/src, for examplegit clone --branch vX.Y.Z https://github.com/powder-sh/powder /opt/apps/powder/src(use the latest tag from the releases page). Create the SSH key powder uses to reach your servers, if the box does not have one yet:ssh-keygen -t ed25519 -N '' -f /root/.ssh/id_ed25519.docker-compose.ymlmounts/root/.ssh/id_ed25519and/root/.ssh/id_ed25519.pubinto both containers; if the key lives elsewhere, setSSH_KEY_PATHin the env file. Without the file Docker creates a directory at that path and the mount breaks. Then create the env file:cp docker/powder.env.example /opt/apps/powder.env && chmod 600 /opt/apps/powder.envand fill it in (keys below).Create the two Docker networks the compose file expects:
docker network create web && docker network create internal.Start the database:
docker compose --env-file /opt/apps/powder.env up -d postgres, then wait for it:until docker compose --env-file /opt/apps/powder.env exec postgres pg_isready -U admin; do sleep 1; done(use yourPOWDER_DB_USER).Create the schema. Apply every file in
packages/db/drizzle/once, in filename order. The loop stops at the first error:cd /opt/apps/powder/src set -e for f in $(ls packages/db/drizzle/*.sql | sort); do docker exec -i -e PGOPTIONS='-c client_min_messages=warning' postgres psql -q -v ON_ERROR_STOP=1 -U admin -d powder < "$f" doneUse the user and database from
POWDER_DB_USERandPOWDER_DB_NAMEif they differ fromadminandpowder.Build and start powder and the worker with the deploy script, run from a workstation that has a checkout of the same tag (a clean tree; the script ships
git archive HEAD):POWDER_HOST=root@<box> bin/self-deploy.sh --baseline.POWDER_HOSTis the SSH target of the box. The script rsyncs the checkout overPOWDER_SRCon the box (default/opt/apps/powder/src,--delete), so the clone from step 1 is replaced by the workstation tree. To use another env file path on the box, setPOWDER_ENV_FILE. To run it on the box itself, check out the tag there and setPOWDER_HOST=root@localhostandPOWDER_SRCto a different directory than the checkout (the box needs an SSH server and your key inauthorized_keys). The script's header lists its other settings, includingPOWDER_DB_PSQL, which must use the same user and database asDATABASE_URL.--baselineis for this first run only: it records the files without the-- idempotentmarker that shipped with 0.9.0 (a fixed list pinned inbin/lib-migrations.sh) as already applied, because step 4 applied them, and nothing else. The marked ones run again, which is safe because they are idempotent. Later runs omit--baseline.Open the dashboard and click "continue with github".
After the first install the schema is kept up to date for you: by bin/self-deploy.sh, and by the powder container itself every time it starts (see "Upgrading powder"). If you start powder with docker compose up -d instead of step 5, set POWDER_MIGRATE_BASELINE=1 in the env file for that first start (it does what --baseline does) and remove it afterwards.
The env file
All config lives in one file, /opt/apps/powder.env. The services read it through env_file:, and the deploy scripts also pass --env-file /opt/apps/powder.env to every docker compose call, so compose-time values such as POWDER_DB_* come from the same file. Do not keep config in a .env inside the source directory.
In production (NODE_ENV=production) powder refuses to start when BETTER_AUTH_SECRET is missing or a placeholder (anything containing change-me), when POWDER_ENCRYPTION_KEY is missing or not 64 hex characters, or when POWDER_DB_PASSWORD or DATABASE_URL still contains CHANGE_ME. The container exits with the reason in its log.
| Variable | Required | Purpose |
|---|---|---|
DATABASE_URL | yes | Powder's own Postgres. Must use the same user and database as POWDER_DB_USER and POWDER_DB_NAME below. |
POWDER_DB_USER, POWDER_DB_PASSWORD, POWDER_DB_NAME | yes | Credentials for the bundled Postgres service, read by docker-compose.yml when it creates the database. The user and database must match DATABASE_URL and the POWDER_DB_PSQL setting of bin/self-deploy.sh. |
POWDER_ENCRYPTION_KEY | yes | 64-character hex key, used for AES-256-GCM on deploy keys, webhook secrets and env vars. Generate with openssl rand -hex 32. Losing it makes every encrypted row unreadable. |
BETTER_AUTH_SECRET | yes | Session signing and OAuth-token encryption. Generate with openssl rand -hex 32. |
BETTER_AUTH_URL | yes | Public origin of the dashboard. |
GITHUB_CLIENT_ID, GITHUB_CLIENT_SECRET | yes | GitHub OAuth login. |
POWDER_ADMIN_USER_IDS | after first login | Comma-separated user ids with admin rights. Leave it empty on a fresh install. Once any id is set, no new user can be created through login. |
POWDER_DEPLOY_WORKER | no | true, 1, yes or on run deploys in the worker (recommended). Unset runs them inside the web process. |
POWDER_ENV_VARS_DB_MODE | no | true renders app env vars from powder's database to the server at deploy time. |
POWDER_SWAP_CHECK | no | off disables the pre-swap candidate check for every app. Unset means on. |
SSH_KEY_PATH | no | Path of the private key. docker-compose.yml mounts it, and <path>.pub, at the same path in both containers. Defaults to /root/.ssh/id_ed25519. |
POWDER_PORT | no | Host port the dashboard listens on, bound to 127.0.0.1. Defaults to 3050. bin/self-deploy.sh health-checks this port. |
POWDER_DATA_DIR | no | Parent directory of the Postgres data (<dir>/powder-pgdata). Defaults to /opt/apps. |
POWDER_DASHBOARD_HOST, POWDER_DASHBOARD_HOST_ALIAS, POWDER_DASHBOARD_HOST_ALIAS2 | no | Public hostname of the dashboard, plus up to two more hosts on the same router. When set, docker-compose.yml gives the powder container a Traefik router (websecure, le cert resolver). Unset: no Traefik labels, front powder some other way. |
POWDER_SITE_HOST | no | Comma-separated public-site hosts. When set, those hosts serve only the site, docs, webhooks, /api/health and /ingest; the dashboard lives at BETTER_AUTH_URL. See "Public site on its own host". Unset: one host serves everything. |
POWDER_SSH_HOSTKEY_MODE | no | off turns SSH host-key pinning off entirely (not recommended; logged at startup). |
POWDER_PG_TOOL_IMAGE | no | Postgres client image used for backups (pg_dump) and the database size probe, which receive DB passwords. Defaults to a digest-pinned postgres:17-alpine@sha256:.... Override with another pinned reference (name:tag@sha256:<64 hex>); an invalid value is ignored. |
POWDER_BUILD_SLOTS | no | Concurrent builds on a dedicated builder host. Defaults to 2. |
POWDER_SELF_SERVICE, POWDER_SELF_SERVER_ID | no | Set when powder deploys itself through powder: the compose service name of powder and, optionally, the server id it runs on. A deploy of that service then detaches the recreate so it survives the SSH session ending. |
POWDER_NOTIFY_WEBHOOK_URL | no | Webhook for deploy failures and app-down alerts. Powder POSTs JSON {event, app, server, message, url, at}; Discord and Slack incoming webhooks work. This is the normal way to get alerts. |
POWDER_MIGRATE_ON_BOOT | no | off stops the powder container from applying pending migrations when it starts. Unset means on. |
POWDER_MIGRATE_BASELINE | no | 1 runs the start-up migration step with --baseline. Only for the first start after creating the schema by hand; remove it afterwards. |
ANTHROPIC_API_KEY, POWDER_LLM_DAILY_MAX | no | Enables "diagnose with AI" on a failed CI run. At most POWDER_LLM_DAILY_MAX model calls per UTC day (default 50, 0 = off). |
POWDER_UPDATE_CHECK | no | off disables the daily GitHub release check. |
NEXT_PUBLIC_POSTHOG_KEY, NEXT_PUBLIC_POSTHOG_HOST, NEXT_PUBLIC_POSTHOG_UI_HOST | no | PostHog analytics, read when the image is built. Off when unset. |
docker/powder.env.example lists every one of these with generation commands. The POWDER_HQ_* keys are only for an external ops hub, see "Optional integrations" below.
| POWDER_TRAEFIK_DYNAMIC_DIR | no | Traefik file-provider directory on the managed boxes (default /opt/infra/traefik/dynamic). Custom-domain DNS verification uses servers.ingress_ip, else the server's host. |
| BACKUP_S3_ENDPOINT, BACKUP_S3_REGION, BACKUP_S3_BUCKET, BACKUP_S3_ACCESS_KEY_ID, BACKUP_S3_SECRET_ACCESS_KEY, POWDER_BACKUP_KEY | no | S3-compatible backup target and the passphrase that encrypts dumps. See Backups. |
| BACKUP_KEEP_PER_APP | no | Retention floor per app. Defaults to 7. |
Public site on its own host (optional)
By default one host serves everything: landing page, docs, login, dashboard and APIs. To put the public site on one host and the dashboard on another, set POWDER_SITE_HOST and point BETTER_AUTH_URL at the dashboard host. Leave POWDER_SITE_HOST unset and nothing changes.
POWDER_DASHBOARD_HOST=deploy.example.com
POWDER_DASHBOARD_HOST_ALIAS=example.com
POWDER_DASHBOARD_HOST_ALIAS2=www.example.com
BETTER_AUTH_URL=https://deploy.example.com
POWDER_SITE_HOST=example.com,www.example.com
- On a site host powder serves only
/,/docs/*, Next assets (/_next/*, icons,robots.txt,sitemap.xml), root-level files frompublic/(e.g.llms.txt),/api/webhooks/*,/api/healthand/ingest. Every other path returns a plain 404, no redirect. - A request counts as site traffic when its
Hostor anyX-Forwarded-Hostentry is a site host, so a forged header cannot reach the dashboard there. - New GitHub push webhooks use the first site host, so they match hooks installed before the split. Existing hooks keep working.
- Dashboard links (deploy statuses, alerts, env file headers,
DEPLOYMENT.md) useBETTER_AUTH_URL. - The GitHub OAuth app needs the callback
<BETTER_AUTH_URL>/api/auth/callback/github. - Sessions are host-only cookies: after moving the dashboard to a new host everyone signs in again there. API tokens keep working against the new host.
- The public pages show no sign-in link when split.
First login
- Start powder with
GITHUB_CLIENT_IDandGITHUB_CLIENT_SECRETset andPOWDER_ADMIN_USER_IDSempty. - Sign in with GitHub. You are the admin. The log prints a line like
[first-run] ... set POWDER_ADMIN_USER_IDS=<id>. Put that id in the env file and restartpowderandworkerso it survives restarts. - If the first login errors, restart powder and try again.
To add a second admin, create the user at /admin/users in the dashboard and add their id to POWDER_ADMIN_USER_IDS.
Add a server
Register a server in the dashboard with an id, its public IP and the SSH user and port. First install powder's public key on the box:
cat id_ed25519.pub | ssh root@<server> 'cat >> /root/.ssh/authorized_keys'
Open the server's page to set its routing and base domain, then click "check server". It is read-only and reports Docker, the compose plugin, the web and internal networks, /opt/apps, ufw state, free disk and memory, plus a Traefik container with the file provider on servers routed by Traefik (on nginx servers that part is informational). "fix missing pieces" lists the exact commands first and only adds what is missing: networks, directories, Docker 29.4 when Docker is absent, and a Traefik container only on a Traefik server with no Traefik container, no nginx installed or enabled, and nothing listening on or bound to ports 80 and 443. It never changes an existing container, Traefik or nginx and never runs ufw. Installing Docker changes iptables, and ports Docker publishes bypass ufw. To run the same check on the box itself, use sudo bin/server-bootstrap.sh --proxy <traefik|nginx|nginx-container> (add --apply to run the plan; --apply requires --proxy). The dashboard home shows a setup checklist until everything is green.
Then import an app at /new: repository URL, branch, server and container name. Powder generates a read-only deploy key and a webhook for the repository. After that, git push deploys. See Deploys.
The app layout on a server
/opt/apps/<app>/
├── docker-compose.yml # runtime compose, server-local, written by powder
├── .deploy-key # read-only deploy key (mode 0600)
└── ... # the git checkout of the app repo (has the Dockerfile)
The compose service carries both image: <tag> and build: { context: ., dockerfile: <path> }. The build: key is what makes deploys build on the server. Every deploy force-syncs the checkout to GitHub (keeping the runtime compose), so the copy on the server is disposable and the repository is the source of truth.
Upgrading powder: schema applied automatically
Get the new version in your workstation checkout, then run bin/self-deploy.sh again. On a tag checkout (detached HEAD, as in the install steps) git pull does nothing: run git fetch --tags && git checkout vX.Y.Z. Only a checkout of the main branch follows it with git pull (the public history is linear, so a pull fast-forwards).
There is no SQL to apply by hand. Two paths bring the schema up to date, with the same rules from one file, bin/lib-migrations.sh, and one table, powder_deploy_migrations:
bin/self-deploy.shapplies pre-deploy files before it copies, builds or recreates anything, and post-deploy files right after the recreate. It then buildspowderandworkerand recreates both, because deploys execute in the worker. It refuses to run while deploys are in flight and health-checks before it reports success.- The
powdercontainer applies them every time it starts, sodocker compose build && docker compose up -dalso upgrades the schema. Pre-deploy files run before the server starts serving. Post-deploy files run in the background once/api/healthanswers 200, or on the next start. Theworkercontainer does not migrate: without the script, startpowderfirst (docker compose up -d powder), wait for/api/health, thendocker compose up -d worker.
The rules:
- Files in
packages/db/drizzle/run in filename order withpsql -v ON_ERROR_STOP=1. Applied files are recorded. - Only files with the exact line
-- idempotentare run. Every migration from 0051 on carries it. - Files that also carry the exact line
-- post-deploy(the table-dropping migrations) run only after the new code is up, so the old code still sees its tables while the new image builds. - An unrecorded file without the marker stops everything: the script aborts the deploy, and the container exits before serving with the same message (
docker logs powdernames the file). Make it idempotent and add the marker, or apply it by hand and insert its file name intopowder_deploy_migrations. - Nothing is recorded as applied unless you ask: on the first run pass
--baseline(orPOWDER_MIGRATE_BASELINE=1for one container start), which records only the pinned list of 0.9.0 files without the marker (they match a database created by the install steps above). - Each file runs in one transaction, except files that use
CONCURRENTLYorALTER TYPE ... ADD VALUE.
For emergencies only: bin/self-deploy.sh --skip-migrations skips the schema step, both its own and the boot-time step of the powder container it recreates (it passes POWDER_SKIP_BOOT_MIGRATIONS=1 through docker-compose.yml), so a failing file cannot crash-loop the new container. The next run without the flag turns the boot-time step back on. Without the script, POWDER_MIGRATE_ON_BOOT=off in the env file skips the boot-time step.
health_url may be a path (/api/health) or an absolute http(s) URL. A path is used by both the deploy health gate (against the new container) and the uptime checker (joined with the app's live domain, else the server's default host); an absolute URL is only used as-is by uptime, and only its path by the deploy gate.
Rolling back below this release
The schema migrations of this release drop tables (ship posts, the LLM watchdog's diagnoses, notification preferences and the unused multi-tenant tables). Older powder builds still query some of them, so going back to an earlier release needs a database restore from before the upgrade: take a pg_dump of powder's database first. Also note that POWDER_NOTIFY_WEBHOOK_URL is now the only alert channel powder has (no email); set it if you want to be told about failed deploys and downtime.
Knowing when a release is out
The running version is shown at the bottom of the sidebar and as version in /api/health. Once a day the server asks the public GitHub releases API for the latest release (one request with a User-Agent of powder/<version>, nothing else is sent). When a newer version exists, the dashboard shows a quiet "powder vX.Y.Z is available" notice linking to the release and to this section. Set POWDER_UPDATE_CHECK=off in the env file to disable the check. Release notes are in CHANGELOG.md.
Operational notes
- Builds are serialized per box with a lock and are memory-gated. Do not build by hand outside the lock.
- Deploy logs can contain environment banners. Keep the dashboard behind HTTPS.
- Powder pins each box's SSH host key on first connect (per host and port, in
ssh_host_keys). If powder cannot read or write that table it refuses the connection instead of connecting unverified. After you rebuild a box, delete its row:DELETE FROM ssh_host_keys WHERE host = '<ip>';(use'<ip>:<port>'for a non-22 port).POWDER_SSH_HOSTKEY_MODE=offturns checking off entirely; it is an explicit opt-out and powder logs a warning at startup. - A watchdog reaps deploys stuck in
runningorqueued, so a dead SSH session cannot hold an app's deploy lock forever.
Optional integrations
HQ reporting
The POWDER_HQ_INGEST_TOKEN, POWDER_HQ_INGEST_URL, POWDER_HQ_GENERIC_URL, POWDER_HQ_CRON_URL and POWDER_HQ_PROJECT keys are for operators who run a separate ops hub that collects events from many systems. Powder then POSTs deploy results, degraded-deploy warnings and cron results to it with the bearer token. Nothing is sent unless the token and at least one URL are set. A normal install leaves them unset and gets alerts through POWDER_NOTIFY_WEBHOOK_URL. POWDER_HQ_PROJECT names the project in those events (default powder).