Deployment
Docker (recommended)
Quick start
docker pull phenx/piwitests-server:latest
mkdir -p .data && chown -R 1001:1001 .data # the container runs as non-root UID 1001
docker run -p 3000:3000 -v $(pwd)/.data:/app/.data phenx/piwitests-server:latestdocker pull phenx/piwitests-server:latest
docker run -p 3000:3000 -v ${PWD}/.data:/app/.data phenx/piwitests-server:latestThe dashboard will be available at http://localhost:3000.
Linux hosts: without the
chown, Docker auto-creates.dataowned byrootand the container (non-root UID 1001) can't write to it. Docker Desktop on Windows/macOS handles this automatically. See Permission issues with volumes if you hit a permission error.
Registries
The same multi-arch image is published to two registries — use whichever your organization prefers:
docker pull phenx/piwitests-server:latestdocker pull ghcr.io/piwitests/platform:latestGHCR additionally carries an edge tag rebuilt from every push to main. It's useful for trying an unreleased fix; don't run it in production — it has had no release testing and can change under you.
Available tags
| Tag | Description | Docker Hub | GHCR |
|---|---|---|---|
latest | Latest stable release | ✅ | ✅ |
MAJOR.MINOR.PATCH | One exact release (e.g. 0.25.0) | ✅ | ✅ |
MAJOR.MINOR | Latest patch of that minor (e.g. 0.25) | ✅ | ✅ |
MAJOR | Latest release of that major (e.g. 0) | ✅ | ✅ |
edge | Built from main, unreleased | — | ✅ |
Pin a specific version in production — and read Upgrading before you bump it, because migrations are forward-only. Browse the published tags on Docker Hub or GHCR.
TIP
GHCR also lists buildcache-linux-amd64 / buildcache-linux-arm64. Those are BuildKit layer caches for the build itself, not runnable images — ignore them.
Image details
| Property | Value |
|---|---|
| Base image | node:24-alpine |
| Build type | Multistage (builder + production stages) |
| Image size | ~400 MB |
| Platforms | linux/amd64, linux/arm64 |
| Registries | phenx/piwitests-server, ghcr.io/piwitests/platform |
Volumes
Mount a volume to persist data:
docker run -p 3000:3000 -v /path/to/data:/app/.data phenx/piwitests-server:latestOn Windows, use a host path like
C:\piwi\data(PowerShell) in place of/path/to/data.
The .data directory contains:
piwi.db— SQLite databasestorage/— HTML reports and trace files
Environment variables
| Variable | Default | Description |
|---|---|---|
NODE_ENV | production | Set automatically |
HOST | 0.0.0.0 | Listen on all interfaces |
PORT | 3000 | Application port |
PIWI_SECRET_KEY | — | Master key for encrypting secrets in the database (AI API keys, SCM tokens). Recommended in all deployments. Generate with node -e "console.log(require('node:crypto').randomBytes(32).toString('hex'))" (or openssl rand -hex 32). |
PIWI_AUTH_ENABLED | — | Enable authentication |
PIWI_AUTH_SECRET | — | Secret for encrypting session cookies (required if auth enabled). Generate with node -e "console.log(require('node:crypto').randomBytes(32).toString('hex'))" (or openssl rand -hex 32). |
PIWI_STORAGE_TYPE | local | Storage backend (local or s3) |
PIWI_DATABASE_URL | — | PostgreSQL connection string (e.g. postgresql://user:pass@host:5432/db). When set, PostgreSQL is used instead of SQLite. |
PIWI_DATABASE_PATH | .data/piwi.db | SQLite database path (ignored when PIWI_DATABASE_URL is set) |
One-click deploy
If you don't have a server to run Docker on, these templates stand up an instance on a hosting provider instead. They all provision the same thing — one container, one persistent volume mounted at /app/.data, authentication on, and PIWI_SECRET_KEY / PIWI_AUTH_SECRET generated by the platform — so the only real difference is the provider's own volume and pricing model.
The manifests are generated from the same variable registry as this site's configuration reference, so they can't drift from what the app actually reads. Regenerate them with npm run app:generate:deploy --workspace=apps/application.
| Provider | How | Persistent volume | Notes |
|---|---|---|---|
| Railway | Button | Yes | Managed PostgreSQL available alongside |
| Render | Button | Yes, on a paid instance | Fully repo-driven — nothing to configure by hand |
| Fly.io | fly launch | Yes | One command rather than a button; cheapest always-on option |
| Koyeb | Button | Volume attached afterwards | Fastest to a running URL, but see the warning below |
| Coolify / Dokploy | Paste a Compose file | Yes | Onto a server you already own |
Not supported as one-click: Vercel, Netlify and DigitalOcean App Platform have no persistent disk, so Piwi would need PostgreSQL and a bring-your-own S3 bucket — at which point it isn't one click. Deploy it to a machine with Docker instead.
Railway
Railway publishes templates from its own dashboard rather than from a file in this repository, so the template's exact contents live in deploy/railway/template.md and the repo ships a railway.json pinning the health check to /api/health and the replica count to 1.
Button pending. The published template URL will appear here. Until then, create a service from the
phenx/piwitests-server:latestimage, add a volume mounted at/app/.data, and copy the variables fromdeploy/railway/template.md.
Railway injects PORT, which the image honors.
Render
Render reads render.yaml straight from the repository, so the button is genuinely one click:
The blueprint asks for a paid instance type, deliberately: Render's free web services have no disk, and without one the database and every stored trace are discarded on each redeploy. It also sets autoDeploy: false so pushes to this repository don't redeploy your instance.
Set PIWI_SITE_URL to the service's public URL after the first deploy, so email links and OAuth callbacks point at the right origin.
Fly.io
Fly has no browser button, but fly.toml makes it one command plus the secrets:
fly launch --no-deploy # accepts the committed fly.toml
fly secrets set PIWI_SECRET_KEY=$(openssl rand -hex 32) PIWI_AUTH_SECRET=$(openssl rand -hex 32)
fly deployIf fly launch picks a different app name because yours is taken, update PIWI_SITE_URL in fly.toml to match. Autoscaling is deliberately off (auto_stop_machines = false, min_machines_running = 1) — the notification and retention sweeps run in-process, so a suspended machine silently stops doing scheduled work.
Koyeb
Koyeb carries the whole service definition in the URL, so nothing needs to be in the repository:
The button alone gives you an ephemeral disk. Koyeb volumes can't be attached from a deploy URL, so until you attach one, the database and every stored trace are lost on redeploy. Volumes are region-scoped and only standard (not free or eco) instances can mount them:
bashkoyeb volumes create piwi-data --region fra --size 10 koyeb service update piwi/piwi --volumes piwi-data:/app/.data koyeb service update piwi/piwi --checks 3000:http:/api/health
Set PIWI_SECRET_KEY, PIWI_AUTH_SECRET and PIWI_SITE_URL in the Koyeb console after the first deploy — the URL can't generate secrets. The full command list is kept in deploy/koyeb-deploy-url.txt.
Coolify / Dokploy
Both consume a Compose file, so one stack covers them. In Coolify: Add a new resource → Service → paste deploy/coolify/docker-compose.yml. It uses Coolify's magic variables, so the domain and both secrets are generated for you and Traefik routing and TLS are wired up automatically.
Before you rely on any of them
- One instance, always. Don't scale the service past a single replica. SQLite requires it, and even on PostgreSQL the SSE event bus and the cron sweeps are in-process.
- Budget disk by retention. Traces and HTML reports dominate — roughly 50–200 MB per run with traces enabled. The templates ask for 10 GB; raise it, or set
PIWI_RETENTION_DAYS, before you fill it. - Check the platform's request body limit. Trace and report uploads reach hundreds of MB, and a proxy that caps request size below that will fail ingest.
- Check that responses aren't buffered. Live runs and browser notifications use long-lived
text/event-streamresponses. - Finish the auth setup. These templates set
PIWI_AUTH_ENABLED=true, so the first visit shows a Create the first admin account form — complete it before sharing the URL (or provision the admin viaPOST /api/auth/setup, see Authentication). - Pin a version. The templates track
latest. Pin a tag before you depend on the instance, and read Upgrading first: migrations are forward-only.
Building locally
Build from the repository root (the Dockerfile lives there):
docker build -t piwi-dashboard:local .
docker run -p 3000:3000 -v $(pwd)/.data:/app/.data piwi-dashboard:localdocker build -t piwi-dashboard:local .
docker run -p 3000:3000 -v ${PWD}/.data:/app/.data piwi-dashboard:localPass --build-arg PIWI_BUILD_SHA=$(git rev-parse HEAD) to stamp the image with a commit SHA, shown on Settings → About and returned by GET /api/version. The published images set it from CI; it's optional for a local build.
Docker Compose
The repository ships a ready-to-use docker-compose.yml with commented options (secret key, auth, PostgreSQL). Minimal version:
services:
piwi-dashboard:
image: phenx/piwitests-server:latest
ports:
- "3000:3000"
volumes:
- ./.data:/app/.data
restart: unless-stoppedRun with:
docker compose up -dDocker Compose with PostgreSQL
For production deployments requiring a robust relational database:
services:
postgres:
image: postgres:17-alpine
environment:
POSTGRES_USER: piwi
POSTGRES_PASSWORD: change-me
POSTGRES_DB: piwi
volumes:
- pg-data:/var/lib/postgresql/data
restart: unless-stopped
piwi-dashboard:
image: phenx/piwitests-server:latest
ports:
- "3000:3000"
volumes:
- ./.data:/app/.data # still used for report/trace file storage
environment:
- PIWI_DATABASE_URL=postgresql://piwi:change-me@postgres:5432/piwi
depends_on:
- postgres
restart: unless-stopped
volumes:
pg-data:Run with:
docker compose up -dKubernetes
Example deployment manifest:
apiVersion: apps/v1
kind: Deployment
metadata:
name: piwi-dashboard
spec:
replicas: 1
selector:
matchLabels:
app: piwi-dashboard
template:
metadata:
labels:
app: piwi-dashboard
spec:
containers:
- name: piwi-dashboard
image: phenx/piwitests-server:latest
ports:
- containerPort: 3000
readinessProbe:
httpGet:
path: /api/health
port: 3000
initialDelaySeconds: 10
periodSeconds: 10
livenessProbe:
httpGet:
path: /api/health
port: 3000
initialDelaySeconds: 20
periodSeconds: 30
volumeMounts:
- name: data
mountPath: /app/.data
volumes:
- name: data
persistentVolumeClaim:
claimName: piwi-dashboard-data
---
apiVersion: v1
kind: Service
metadata:
name: piwi-dashboard
spec:
selector:
app: piwi-dashboard
ports:
- port: 80
targetPort: 3000
type: LoadBalancernpm / npx (quick local run)
For a quick local run without Docker, the server is published to npm as @piwitests/server. It bundles the prebuilt server and needs only Node.js 22+:
npx @piwitests/serverThe dashboard will be available at http://localhost:3000.
The server creates a .data/ directory in the current working directory for the SQLite database (.data/piwi.db) and file storage (.data/storage/) — the same data layout the Docker image mounts at /app/.data. Run the command from the same directory each time to keep your data.
Configuration uses the same environment variables as the Docker image (see the table above and the configuration reference). For example, to change the port:
PORT=8080 npx @piwitests/server$env:PORT='8080'; npx @piwitests/serverDocker remains the recommended path for production — it ships a pinned Node runtime, runs as a non-root user, and isolates the environment. The npm package is best for a quick local trial or environments where Docker isn't available.
Production build from source
cd apps/application
npm install
npm run app:build
npm run app:preview # preview the production build locallyHealth checks
GET /api/health verifies database connectivity and returns 200 {"status":"ok"} when healthy, 503 otherwise — use it for load-balancer targets, uptime monitors, and container orchestration. The Docker image ships a built-in HEALTHCHECK against it, so docker ps shows healthy/unhealthy out of the box. GET /api/version additionally reports the running version and database backend.
Reverse proxy (HTTPS)
Always put a TLS-terminating reverse proxy in front of the dashboard for anything beyond localhost. Two working examples — mind the upload size (trace/report uploads can reach hundreds of MB) and SSE streaming (live runs and browser notifications use long-lived text/event-stream responses that must not be buffered).
Caddy (automatic HTTPS):
piwi.example.com {
reverse_proxy localhost:3000
}nginx:
server {
listen 443 ssl;
server_name piwi.example.com;
# ssl_certificate /etc/letsencrypt/live/piwi.example.com/fullchain.pem;
# ssl_certificate_key /etc/letsencrypt/live/piwi.example.com/privkey.pem;
client_max_body_size 500m; # trace + report uploads
location / {
proxy_pass http://127.0.0.1:3000;
proxy_http_version 1.1;
proxy_set_header Host $host;
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
proxy_set_header X-Forwarded-Proto $scheme;
# Live streaming + notifications use Server-Sent Events:
proxy_buffering off;
proxy_read_timeout 1h;
}
}When auth is enabled, set PIWI_SITE_URL to the public HTTPS URL so email links and OAuth callbacks point at the right origin, and set PIWI_TRUST_PROXY=true so the per-address rate limits on the auth endpoints key on the client address your proxy appends to X-Forwarded-For instead of on the proxy's own address — see the configuration reference.
Backups
Everything lives in two places — back both up:
- The database — SQLite file
.data/piwi.db(default) or your PostgreSQL database (pg_dump). - File storage —
.data/storage/(HTML reports, traces, attachments), unless you use S3 storage.
With the default SQLite + local storage setup, a consistent backup is simply a copy of .data/ while the container is stopped — or use SQLite's online backup to avoid downtime:
# Online, consistent SQLite backup + storage copy
sqlite3 .data/piwi.db ".backup '.data/piwi-backup.db'"
tar czf piwi-backup.tar.gz -C .data piwi-backup.db storage# Stop the container first for a consistent copy, then:
Compress-Archive -Path .data -DestinationPath piwi-backup.zipWith PostgreSQL: pg_dump the database and copy .data/storage/ (or rely on your S3 bucket's own durability/versioning).
Take one before every version bump: migrations are forward-only, so a backup is the only rollback path. See Upgrading.
Resource requirements
Piwi is a single Node.js process and runs comfortably on small machines:
| Deployment | Guideline |
|---|---|
| RAM | ~300 MB idle; 1 GB is comfortable headroom for large uploads and AI diagnosis |
| CPU | 1 vCPU is enough for a team; ingest is I/O-bound |
| Disk | The real variable — traces and HTML reports dominate. Budget by retention: e.g. ~50–200 MB per run with traces enabled. Prune old runs from Settings → Storage |
| Scaling | Run a single replica. SQLite requires it; with PostgreSQL the SSE event bus is still in-process, so keep one instance |
Security
The container runs as a non-root user (nodejs:nodejs, UID/GID 1001).
Security best practices:
- Always use HTTPS in production
- Mount
.data/on a persistent volume - Set a strong
PIWI_SECRET_KEY(node -e "console.log(require('node:crypto').randomBytes(32).toString('hex'))", oropenssl rand -hex 32) to encrypt secrets at rest - Set a strong
PIWI_AUTH_SECRETand enable authentication for multi-user deployments
Troubleshooting
Permission issues with volumes
On Linux hosts, the bind-mounted directory must be writable by the container's UID 1001:
mkdir -p .data
chmod 777 .data
docker run -p 3000:3000 -v $(pwd)/.data:/app/.data phenx/piwitests-server:latestOn Windows and macOS, Docker Desktop manages volume permissions automatically — no
chmodis needed. Just run the container with-v ${PWD}/.data:/app/.data(PowerShell).
Database locked
SQLite doesn't support concurrent writes well. For high-concurrency deployments, run a single instance or switch to PostgreSQL by setting PIWI_DATABASE_URL.
Port already in use
Map to a different host port:
docker run -p 8080:3000 -v $(pwd)/.data:/app/.data phenx/piwitests-server:latestdocker run -p 8080:3000 -v ${PWD}/.data:/app/.data phenx/piwitests-server:latestThe dashboard will be available at http://localhost:8080.