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_keys on 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.

  1. On the box, clone a release tag of the repository (https://github.com/powder-sh/powder) to /opt/apps/powder/src, for example git 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.yml mounts /root/.ssh/id_ed25519 and /root/.ssh/id_ed25519.pub into both containers; if the key lives elsewhere, set SSH_KEY_PATH in 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.env and fill it in (keys below).

  2. Create the two Docker networks the compose file expects: docker network create web && docker network create internal.

  3. 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 your POWDER_DB_USER).

  4. 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"
    done

    Use the user and database from POWDER_DB_USER and POWDER_DB_NAME if they differ from admin and powder.

  5. 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_HOST is the SSH target of the box. The script rsyncs the checkout over POWDER_SRC on 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, set POWDER_ENV_FILE. To run it on the box itself, check out the tag there and set POWDER_HOST=root@localhost and POWDER_SRC to a different directory than the checkout (the box needs an SSH server and your key in authorized_keys). The script's header lists its other settings, including POWDER_DB_PSQL, which must use the same user and database as DATABASE_URL. --baseline is for this first run only: it records the files without the -- idempotent marker that shipped with 0.9.0 (a fixed list pinned in bin/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.

  6. 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.

VariableRequiredPurpose
DATABASE_URLyesPowder'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_NAMEyesCredentials 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_KEYyes64-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_SECRETyesSession signing and OAuth-token encryption. Generate with openssl rand -hex 32.
BETTER_AUTH_URLyesPublic origin of the dashboard.
GITHUB_CLIENT_ID, GITHUB_CLIENT_SECRETyesGitHub OAuth login.
POWDER_ADMIN_USER_IDSafter first loginComma-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_WORKERnotrue, 1, yes or on run deploys in the worker (recommended). Unset runs them inside the web process.
POWDER_ENV_VARS_DB_MODEnotrue renders app env vars from powder's database to the server at deploy time.
POWDER_SWAP_CHECKnooff disables the pre-swap candidate check for every app. Unset means on.
SSH_KEY_PATHnoPath 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_PORTnoHost port the dashboard listens on, bound to 127.0.0.1. Defaults to 3050. bin/self-deploy.sh health-checks this port.
POWDER_DATA_DIRnoParent directory of the Postgres data (<dir>/powder-pgdata). Defaults to /opt/apps.
POWDER_DASHBOARD_HOST, POWDER_DASHBOARD_HOST_ALIAS, POWDER_DASHBOARD_HOST_ALIAS2noPublic 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_HOSTnoComma-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_MODEnooff turns SSH host-key pinning off entirely (not recommended; logged at startup).
POWDER_PG_TOOL_IMAGEnoPostgres 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_SLOTSnoConcurrent builds on a dedicated builder host. Defaults to 2.
POWDER_SELF_SERVICE, POWDER_SELF_SERVER_IDnoSet 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_URLnoWebhook 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_BOOTnooff stops the powder container from applying pending migrations when it starts. Unset means on.
POWDER_MIGRATE_BASELINEno1 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_MAXnoEnables "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_CHECKnooff disables the daily GitHub release check.
NEXT_PUBLIC_POSTHOG_KEY, NEXT_PUBLIC_POSTHOG_HOST, NEXT_PUBLIC_POSTHOG_UI_HOSTnoPostHog 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 from public/ (e.g. llms.txt), /api/webhooks/*, /api/health and /ingest. Every other path returns a plain 404, no redirect.
  • A request counts as site traffic when its Host or any X-Forwarded-Host entry 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) use BETTER_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

  1. Start powder with GITHUB_CLIENT_ID and GITHUB_CLIENT_SECRET set and POWDER_ADMIN_USER_IDS empty.
  2. 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 restart powder and worker so it survives restarts.
  3. 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.sh applies pre-deploy files before it copies, builds or recreates anything, and post-deploy files right after the recreate. It then builds powder and worker and 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 powder container applies them every time it starts, so docker compose build && docker compose up -d also upgrades the schema. Pre-deploy files run before the server starts serving. Post-deploy files run in the background once /api/health answers 200, or on the next start. The worker container does not migrate: without the script, start powder first (docker compose up -d powder), wait for /api/health, then docker compose up -d worker.

The rules:

  1. Files in packages/db/drizzle/ run in filename order with psql -v ON_ERROR_STOP=1. Applied files are recorded.
  2. Only files with the exact line -- idempotent are run. Every migration from 0051 on carries it.
  3. 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.
  4. 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 powder names the file). Make it idempotent and add the marker, or apply it by hand and insert its file name into powder_deploy_migrations.
  5. Nothing is recorded as applied unless you ask: on the first run pass --baseline (or POWDER_MIGRATE_BASELINE=1 for 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).
  6. Each file runs in one transaction, except files that use CONCURRENTLY or ALTER 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=off turns checking off entirely; it is an explicit opt-out and powder logs a warning at startup.
  • A watchdog reaps deploys stuck in running or queued, 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).