Self-hosted publisher service for Webstudio.
When a user clicks Publish in the Webstudio builder, this service:
- Fetches build data from the builder via its REST API
- Runs
webstudio sync+webstudio build --template ssgto generate a static site - Runs
vite buildto produce static HTML files - Writes the output to
/var/publish/<domain>/so Nginx can serve it
Images are published automatically on every push to main and on releases:
| Registry | Image |
|---|---|
| GitHub Container Registry | ghcr.io/webstudio-community/webstudio-publisher:latest |
| Docker Hub | webstudio-community/webstudio-publisher:latest |
This service is meant to be used alongside the Webstudio builder. See webstudio-self-host for the full Docker Compose setup.
| Variable | Default | Description |
|---|---|---|
TRPC_SERVER_API_TOKEN |
— | Service token to authenticate with the builder app |
BUILDER_INTERNAL_URL |
http://app:3000 |
Internal Docker URL for the builder (avoids Traefik/TLS) |
PUBLISHER_HOST |
— | Domain suffix for slug-based URLs (e.g. example.com → myproject.example.com) |
PORT |
4000 |
HTTP port |
REGISTRY_URL |
— | Registry to push images to for host: "coolify" (e.g. ghcr.io/my-org) — enables the ssr:coolify and ssg:coolify targets |
REGISTRY_USER / REGISTRY_TOKEN |
— | Registry write credentials (if private) |
docker build -t webstudio-publisher .| Method | Path | Description |
|---|---|---|
POST |
/publish |
Trigger a publish. Body: { "buildId", "builderOrigin", "renderMode": "ssg" | "ssr", "host": "local" | "cloudflare" | "ssh" | "coolify" } (+ coolifyWebhookUrl / coolifyWebhookToken? for host: "coolify"; legacy "buildMode" still accepted) |
POST |
/targets/ssh-setup |
Configure the SSH target for a domain (host: "ssh"). Body: { "domain", "sshHost", "sshUser", "sshPath", "sshPort"?, "sshPrivateKey", "publicUrl"? } |
POST |
/unpublish |
Take a hostname down. Body: { "domain": "..." } |
GET |
/capabilities |
Publisher capabilities — { "cloudflare": bool, "coolify": bool, "ssh": bool, "targets": ["ssg:local", …] } |
GET |
/health |
Health check — returns ok |
Configure the target once, then publish with renderMode: "ssg", host: "ssh":
# 1. generate a keypair and authorize it on the remote server
ssh-keygen -t ed25519 -f ./ws_deploy -N ''
ssh-copy-id -i ./ws_deploy.pub deploy@my-server
# 2. register the target with the publisher (once per site)
curl -X POST http://publisher:4000/targets/ssh-setup \
-H 'content-type: application/json' \
-d "{\"domain\":\"my-site\",\"sshHost\":\"my-server\",\"sshUser\":\"deploy\",\"sshPath\":\"/var/www/my-site\",\"sshPrivateKey\":\"$(awk '{printf "%s\\n", $0}' ./ws_deploy)\",\"publicUrl\":\"https://my-site.com\"}"Each publish runs rsync -az --delete from the freshly built dist/client/ to
sshUser@sshHost:sshPath/. TLS and web-server config on the remote host are the
user's responsibility; the publisher does not serve the site or manage its domain.
Set REGISTRY_URL (+ REGISTRY_USER / REGISTRY_TOKEN if private) on the
publisher. On the target Coolify, the site owner creates a Docker Image
application pulling ${REGISTRY_URL}/ws-<project-slug> (port 3000 for SSR,
80 for SSG), and copies its deploy webhook URL.
Each publish then:
- SSR —
docker buildthe react-router image - SSG — prerender, then wrap
dist/clientin a tinynginx:alpineimage
→ push ${REGISTRY_URL}/ws-<slug>:latest (+ :<buildId>) → POST the webhook
(with a Bearer token if given) so Coolify pulls the new image and redeploys.
The publisher never talks to the Coolify API directly and never polls — a 2xx
from the webhook means the deploy is queued. unpublish forgets the site
locally; the Coolify app and the registry images are left in place.