Docker Compose setup to self-host Webstudio, the open-source visual development platform.
Requirements: Linux server · Docker ≥ 24 · Compose v2 · 2 GB RAM · 10 GB disk
This repo provides two compose files:
docker-compose.yml: plain Docker Compose (any Linux server)docker-compose.coolify.yml: optimised for Coolify with Traefik and auto-generated secrets
| Service | Role |
|---|---|
app |
Webstudio builder (Remix app) |
db |
PostgreSQL 15 |
db-setup |
Grants PostgREST permissions on DB tables (runs once) |
postgrest |
PostgREST (REST API over the DB) |
migrate |
Runs Prisma migrations on startup |
minio |
S3-compatible object storage for assets |
nginx |
Serves published static sites |
publisher |
Generates static HTML when you click Publish |
git clone https://github.com/webstudio-community/webstudio-self-host.git
cd webstudio-self-host
cp .env.example .env
# Edit .env -> change every "change-me" valueGenerate the required secrets:
# POSTGRES_PASSWORD
openssl rand -hex 16
# AUTH_SECRET
openssl rand -hex 32
# PGRST_JWT_SECRET (must be ≥ 64 characters)
openssl rand -hex 64
# TRPC_SERVER_API_TOKEN (shared secret between builder and publisher)
openssl rand -hex 32Also set DEPLOYMENT_URL in your .env to your builder's public URL (required in production regardless of login mode):
DEPLOYMENT_URL=https://webstudio.your-domain.comThen start:
docker compose up -dThe builder is available at http://localhost:3000.
Coolify manages SSL certificates, the Traefik reverse proxy, and automatic restarts.
The setup uses three domain scopes, each with its own wildcard:
| Domain | Purpose | Service |
|---|---|---|
webstudio.your-domain.com |
Main builder UI | app (Coolify-managed) |
*.webstudio.your-domain.com |
Canvas preview iframes (one per project) | app (Traefik label) |
*.wstdwork.your-domain.com |
Published static sites | nginx (Traefik label) |
The first domain is configured via Coolify's UI. The other two require Traefik labels in the compose file. Coolify routes based on these labels but does not manage them through its UI.
Create these records in your DNS provider (no proxy/CDN. Orange cloud off if using Cloudflare):
| Type | Name | Content |
|---|---|---|
| A | webstudio.your-domain.com |
your server IP |
| A | *.webstudio.your-domain.com |
your server IP |
| A | *.wstdwork.your-domain.com |
your server IP |
wstdworkis the subdomain prefix for published sites. You can pick any name — just make sure it matchesPUBLISHER_HOSTin your env and the labels in the compose file.
Webstudio needs wildcard TLS certificates, which require a DNS challenge. Coolify uses Traefik as its reverse proxy — you need to add the Cloudflare resolver and the gzip middleware to Traefik's static configuration.
In Coolify: Server → Proxy → Configuration
Under the command: block, add these lines (replace with your own email and cert resolver name):
command:
# ... existing Coolify args ...
- "--certificatesresolvers.cloudflare.acme.email=you@your-domain.com"
- "--certificatesresolvers.cloudflare.acme.storage=/traefik/acme.json"
- "--certificatesresolvers.cloudflare.acme.dnschallenge=true"
- "--certificatesresolvers.cloudflare.acme.dnschallenge.provider=cloudflare"
- "--certificatesresolvers.cloudflare.acme.dnschallenge.resolvers=1.1.1.1:53,1.0.0.1:53"Under the environment: block, add your Cloudflare API token:
environment:
- CF_DNS_API_TOKEN=your-cloudflare-api-tokenTo create a Cloudflare API token: Cloudflare dashboard → My Profile → API Tokens → Create Token → use the "Edit zone DNS" template → restrict it to your domain.
Also add a dynamic config file so Traefik knows about the gzip middleware (referenced
in the compose labels). In Coolify: Server → Proxy → Dynamic Configuration, create
a new file webstudio.yaml with:
http:
middlewares:
gzip:
compress: {}Important: The resolver name
cloudflareused in--certificatesresolvers.cloudflare.*must exactly match the value intls.certresolver=cloudflarein the Traefik labels of the compose file. If you change one, change the other.
Coolify does not interpolate environment variables inside Traefik labels. You must
edit docker-compose.coolify.yml directly and replace the placeholder domains with
your actual values before deploying.
In the app service, replace webstudio.example.com with your builder domain:
# Before
- "traefik.http.routers.ws-canvas.rule=HostRegexp(`^.+[.]webstudio[.]example[.]com`)"
- "traefik.http.routers.ws-canvas.tls.domains[0].main=*.webstudio.example.com"
- "traefik.http.routers.ws-canvas.tls.domains[0].sans=webstudio.example.com"
# After (example)
- "traefik.http.routers.ws-canvas.rule=HostRegexp(`^.+[.]webstudio[.]your-domain[.]com`)"
- "traefik.http.routers.ws-canvas.tls.domains[0].main=*.webstudio.your-domain.com"
- "traefik.http.routers.ws-canvas.tls.domains[0].sans=webstudio.your-domain.com"In the nginx service, replace wstdwork.example.com with your publish domain
(must match PUBLISHER_HOST):
# Before
- "traefik.http.routers.ws-publisher.rule=HostRegexp(`^.+[.]wstdwork[.]example[.]com`)"
- "traefik.http.routers.ws-publisher.tls.domains[0].main=*.wstdwork.example.com"
- "traefik.http.routers.ws-publisher.tls.domains[0].sans=wstdwork.example.com"
# After (example)
- "traefik.http.routers.ws-publisher.rule=HostRegexp(`^.+[.]wstdwork[.]your-domain[.]com`)"
- "traefik.http.routers.ws-publisher.tls.domains[0].main=*.wstdwork.your-domain.com"
- "traefik.http.routers.ws-publisher.tls.domains[0].sans=wstdwork.your-domain.com"In your Coolify project: New resource → Docker Compose → From a Git repository
- Repository:
https://github.com/webstudio-community/webstudio-self-host - Compose file:
docker-compose.coolify.yml
Coolify auto-generates these variables — leave them as-is:
SERVICE_PASSWORD_DB,SERVICE_PASSWORD_AUTH,SERVICE_BASE64_64_PGRST,SERVICE_BASE64_64_TRPCSERVICE_FQDN_APP_3000,SERVICE_FQDN_MINIO_9000,SERVICE_URL_APP
Set these manually in the Coolify environment:
# Builder domain (used to construct canvas iframe URLs)
APP_FQDN=webstudio.your-domain.com
# Published sites domain suffix (must match the nginx Traefik label)
PUBLISHER_HOST=wstdwork.your-domain.com
# How users log in — choose one:
# Option A: simple password login (password = AUTH_SECRET value, shown in Coolify)
DEV_LOGIN=true
DEV_LOGIN_EMAIL=admin@example.com
# Option B: GitHub OAuth
# GH_CLIENT_ID=...
# GH_CLIENT_SECRET=...
# Note: DEPLOYMENT_URL is automatically set from SERVICE_URL_APP by the compose fileClick Deploy in Coolify. First deploy takes 2–3 min (pulls images, runs DB migrations).
# Docker Compose
docker compose pull
docker compose up -d
# Coolify: click "Redeploy" in the UIDB migrations run automatically on every restart.
Three login modes are available. Choose one and set the corresponding variables.
No external provider needed. Set in .env (plain Compose) or Coolify environment:
DEV_LOGIN=true
DEV_LOGIN_EMAIL=admin@example.comThe password is the value of AUTH_SECRET (visible in Coolify under the service's environment variables).
Also set DEPLOYMENT_URL in your .env (plain Compose — Coolify sets it automatically): the compose file always passes the variable through, and the app refuses to start if it's empty. With Coolify this is set automatically from SERVICE_URL_APP; with plain Compose, set it to your builder's URL (e.g. http://localhost:3000 for local testing, see Testing locally over plain HTTP).
- Go to github.com → Settings → Developer Settings → OAuth Apps → New OAuth App
- Fill in:
- Homepage URL:
https://webstudio.your-domain.com - Authorization callback URL:
https://webstudio.your-domain.com/auth/github/callback
- Homepage URL:
- Copy the Client ID and generate a Client Secret
- Remove
DEV_LOGINfrom your env, then add:
GH_CLIENT_ID=your-client-id
GH_CLIENT_SECRET=your-client-secret
DEPLOYMENT_URL=https://webstudio.your-domain.comWith Coolify:
DEPLOYMENT_URLis automatically set fromSERVICE_URL_APPby the compose file — no need to add it manually.
- Go to console.cloud.google.com → APIs & Services → Credentials → Create OAuth 2.0 Client ID
- Application type: Web application
- Under Authorized redirect URIs, add:
https://webstudio.your-domain.com/auth/google/callback - Copy the Client ID and Client Secret
- Remove
DEV_LOGINfrom your env, then add:
GOOGLE_CLIENT_ID=your-client-id
GOOGLE_CLIENT_SECRET=your-client-secret
DEPLOYMENT_URL=https://webstudio.your-domain.comWith Coolify:
DEPLOYMENT_URLis automatically set fromSERVICE_URL_APPby the compose file — no need to add it manually.
Session cookies use the __Host- prefix, which requires HTTPS. If you're running docker-compose.yml on your own machine (http://localhost, no reverse proxy/TLS in front), browsers silently refuse to set these cookies — both dev login and OAuth fail (OAuth shows "Missing state on session"). To test locally over plain HTTP, set in .env:
DEPLOYMENT_ENVIRONMENT=development
DEPLOYMENT_URL=http://localhost:3000
ALLOW_INSECURE_COOKIES=trueDEPLOYMENT_URL is required here too, even for dev login: the compose file always passes it through, and the app refuses to start on an empty value.
Never set DEPLOYMENT_ENVIRONMENT/ALLOW_INSECURE_COOKIES like this on a real deployment — ALLOW_INSECURE_COOKIES is refused at startup when DEPLOYMENT_ENVIRONMENT=production (the default).
Once a project is published you can attach any custom domain — including an apex (root) domain like example.com — via the Publish panel in the builder.
Standard setup: create a CNAME record pointing to your server hostname. The builder UI will show you the exact CNAME target and TXT verification record to add.
Apex domains cannot use a CNAME record with most DNS providers. The builder detects this and shows an A record instruction instead.
DNS records to create:
| Type | Name | Value |
|---|---|---|
| A | @ |
your server IP |
| TXT | _webstudio_is |
the token shown in the Publish panel |
Steps:
- In the builder, open Publish → Add a new domain and enter
example.com - The panel shows the A record and TXT record to create. Copy the TXT value
- In your DNS provider, create both records
- Wait for DNS propagation (a few minutes to an hour), then click Verify
- Publish. The site is served from
example.comwith an automatic Let's Encrypt certificate
Each apex domain is independent: multiple projects can each have their own apex domain pointing to the same server.
The Publish panel has two selectors (shown when PUBLISHER_HOST is set), remembered per project:
- Rendering —
Static (SSG)(prerendered HTML) orDynamic (SSR)(a Node app, one Docker container per site) - Hosting — where the result is served
| Rendering | Hosting | What it does | Served by |
|---|---|---|---|
| Static / Dynamic | This Webstudio instance | served locally: static files, or a docker run container |
publisher proxy / nginx |
| Static | Cloudflare Pages | wrangler pages deploy (needs CLOUDFLARE_API_TOKEN + CLOUDFLARE_ACCOUNT_ID on the publisher) |
Cloudflare |
| Static | Remote server (SSH) | prerenders, then rsync to a server you own |
your server |
| Dynamic | Coolify (remote) | builds the SSR image, pushes it to a registry, then triggers a deploy webhook | your (or a client's) Coolify |
Unavailable combinations are greyed out in the panel (e.g. Dynamic + Cloudflare, or a hosting option the publisher isn't configured for).
Deploy the static build to any server you control over rsync/SSH. TLS and the web-server config on that server are your responsibility — the publisher only ships files.
On the target server: install rsync, and configure a web server (nginx, Caddy…) to serve the deploy directory for your domain.
Generate a deploy key and authorize it:
ssh-keygen -t ed25519 -f ./ws_deploy -N ''
ssh-copy-id -i ./ws_deploy.pub deploy@your-serverRegister the target with the publisher (once per site — domain is the project slug). The publisher's build API listens on port 4000 and is reachable only from inside the compose network (it has no authentication and the private key travels over it in plaintext — never expose port 4000 publicly), so run this from the server hosting the stack:
docker compose exec publisher \
curl -sS -X POST http://localhost:4000/targets/ssh-setup \
-H 'content-type: application/json' \
-d "$(jq -n --arg key "$(cat ./ws_deploy)" '{
domain:"my-project", sshHost:"your-server", sshUser:"deploy",
sshPath:"/var/www/my-project", sshPort:22, sshPrivateKey:$key,
publicUrl:"https://my-project.com" }')"(jq builds the JSON so the multi-line private key is escaped correctly. curl and jq are both present in the publisher image.)
sshHost, sshUser and sshPath must be plain values (hostname/IP, a normal user name, an absolute path) — they are used as rsync/ssh arguments and the endpoint rejects anything with shell metacharacters or spaces.
The private key is stored (chmod 600) on the publisher-work volume and never leaves the publisher; ssh-keyscan pre-seeds known_hosts at setup. publicUrl (optional) is the origin baked into og: tags and sitemap.xml; it defaults to the project's first custom domain.
Then pick Static (SSG) + Remote server (SSH) in the Publish panel and publish. Each publish runs rsync -az --delete, so the target directory mirrors the build exactly. Unpublishing or switching targets forgets the site locally but leaves the remote files in place.
Host a Dynamic (SSR) or Static (SSG) site on any Coolify instance — yours, or a different one per client, potentially on another server. The publisher builds a Docker image (the SSR app, or dist/client wrapped in nginx:alpine) and pushes it to a registry; the target Coolify pulls it and redeploys when its deploy webhook is triggered.
1. Give the publisher a registry. Set on the publisher (.env / Coolify env):
REGISTRY_URL=ghcr.io/my-org # a registry every target Coolify can pull from
REGISTRY_USER=my-org # omit both if the images are public
REGISTRY_TOKEN=ghp_… # a write tokenUse a public registry (ghcr.io, Docker Hub) — simplest, clients need no credentials — or run your own (registry:2 behind Traefik; not bundled in this repo). The publisher also needs the Docker socket — uncomment the /var/run/docker.sock mount in the publisher service.
2. On the target Coolify, the site owner creates a Docker Image application:
- Image:
${REGISTRY_URL}/ws-<project-slug>— the slug is the project id shown in the Publish panel URL; the tag islatest - Port:
3000for SSR (the image setsEXPOSE 3000andIPX_HTTP_ALLOW_ALL_DOMAINS=true),80for SSG (thenginx:alpineimage) - SSR only: optional persistent storage at
/var/cache/ipxso optimized images survive restarts - Set the app's domain(s) and let Coolify handle TLS
Then they copy the app's deploy webhook URL (Settings → Webhooks, or https://<coolify>/api/v1/deploy?uuid=<app-uuid> + an API token scoped to deploy).
3. In the Publish panel, pick Dynamic (SSR) or Static (SSG) + Coolify (remote), paste the webhook URL (and token, if the URL needs one). Publish is blocked until the URL is filled. For SSG, publish with a custom domain selected so the og: / sitemap.xml URLs use it.
Each publish: docker build → docker push ${REGISTRY_URL}/ws-<slug>:latest → POST the webhook. The publisher never calls the Coolify API itself and doesn't wait for the deploy to finish — a 2xx from the webhook means it's queued. Unpublishing forgets the site locally; the Coolify app and the registry images are left in place (they belong to the site owner). The webhook URL must be https to a public host.
| Variable | Required | Default | Description |
|---|---|---|---|
POSTGRES_PASSWORD |
✅ | - | PostgreSQL password |
PGRST_JWT_SECRET |
✅ | - | Secret for PostgREST JWT auth (≥ 64 chars) |
AUTH_SECRET |
✅ | - | Session cookie signing secret |
APP_FQDN |
✅ (Coolify) | - | Builder public domain (e.g. webstudio.your-domain.com) |
DEV_LOGIN |
- | - | true = password login using AUTH_SECRET as the password. |
DEV_LOGIN_EMAIL |
- | admin@example.com |
Email used for dev login |
GH_CLIENT_ID / GH_CLIENT_SECRET |
- | - | GitHub OAuth |
GOOGLE_CLIENT_ID / GOOGLE_CLIENT_SECRET |
- | - | Google OAuth |
DEPLOYMENT_URL |
✅ | - | Builder's public URL with protocol (e.g. https://webstudio.your-domain.com). Required in production for all login modes. Auto-set from SERVICE_URL_APP in Coolify. |
DEPLOYMENT_ENVIRONMENT |
- | production |
Set to production automatically by both compose files. Override to development in .env (plain Compose only) for local HTTP testing. |
ALLOW_INSECURE_COOKIES |
- | - | true = drop the __Host-/Secure cookie requirements, for local testing over plain HTTP. Refused when DEPLOYMENT_ENVIRONMENT=production. Never use on a real deployment. |
AUTH_WS_CLIENT_ID |
✅ (prod) | - | OAuth server credential for the webstudio CLI. Any non-empty value. Auto-set in Coolify. |
AUTH_WS_CLIENT_SECRET |
✅ (prod) | - | OAuth server secret for the webstudio CLI. Any strong random value. Auto-set in Coolify. |
PUBLISHER_HOST |
- | wstd.work |
Domain suffix for published project URLs |
TRPC_SERVER_API_TOKEN |
- | - | Service token shared between builder and publisher |
SELF_HOSTED_PUBLISHER_URL |
- | http://publisher:4000 |
Internal publisher URL |
FEATURES |
- | * |
Feature flags (* = all enabled) |
USER_PLAN |
- | pro |
Plan level for all users |
MAX_ASSETS_PER_PROJECT |
- | 50 |
Asset upload limit per project |
S3_ENDPOINT / S3_REGION / S3_ACCESS_KEY_ID / S3_SECRET_ACCESS_KEY / S3_BUCKET |
- | MinIO defaults | S3-compatible storage |
ENTRI_APPLICATION_ID / ENTRI_SECRET |
- | - | Entri automatic DNS setup (optional) |
CLOUDFLARE_API_TOKEN / CLOUDFLARE_ACCOUNT_ID |
- | - | Enables the "Cloudflare Pages" hosting option |
REGISTRY_URL |
- | - | Registry the publisher pushes images to — enables "Coolify (remote)" hosting for SSR and SSG (details) |
REGISTRY_USER / REGISTRY_TOKEN |
- | - | Registry write credentials (omit for public images) |
BUILDER_IMAGE |
- | ghcr.io/webstudio-community/builder:latest |
Builder Docker image |
PUBLISHER_IMAGE |
- | ghcr.io/webstudio-community/webstudio-publisher:latest |
Publisher Docker image |
- webstudio-fork: builder with self-hosting patches, Docker image CI
- webstudio-publisher: publisher service source and Docker image CI