Conversation
… rsync
New publish target for self-hosted sites: build the site as SSG, then rsync
the output to a remote server over SSH instead of serving it locally.
- POST /targets/ssh-setup stores the per-domain SSH target (host/user/path/port
+ private key) under /var/work/<domain>/ — the key is chmod 600 and never
logged, and is not re-sent on every publish.
- publishBuildSsh(): reads target.json, stops any local runtime for the site,
runs the shared buildSsgOutput() pipeline, then `rsync -az --delete` to
sshUser@sshHost:sshPath/. Records state.json { mode: "ssh", ... }.
- The publisher does not serve the hostname and writes no Traefik config — TLS
and routing on the remote host are the user's responsibility. Unpublish and
mode switches leave the remote files in place.
- Refactor: extract buildSsgOutput() (shared by publishBuild + publishBuildSsh)
and hoist transformOutputFiles() / qualifyPublishDomain() to module scope.
- Dockerfile: add rsync + openssh-client.
- GET /capabilities now returns { cloudflare, ssh }.
Closes webstudio-community/webstudio-self-host#7
Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01VtPSP1i7NsfKY2DigXV53m
This was referenced Sep 7, 2026
Contributor
Author
vianmora
added a commit
that referenced
this pull request
Sep 8, 2026
* chore: remove dead legacy SSR subprocess code The react-router-serve subprocess SSR path (state.json mode "ssr") was superseded by one-container-per-site Docker (mode "docker") in #8, and nothing has written mode "ssr" since. Remove the orphaned machinery: ssrProcesses / ssrDomainPort / ssrHostPort maps, allocateSsrPort (already uncalled), startSsrProcess, stopSsrForDomain, the SSR_PORT_BASE env var, the "ssr" branches in restoreSsrProcesses (→ restoreTargets), the three publish pipelines' transition blocks, teardownSite, and the proxy's 127.0.0.1 subprocess branch. A stale mode:"ssr" state.json (pre-#8 publisher) now simply isn't restored — the site 404s until republished, and unpublish still tears it down via the generic hostname cleanup. Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_017rF3HMB1Z7V3LLsYaUrELs * refactor: extract buildSsgOutput() shared by the SSG and remote pipelines Pull the sync → generate → vike-pin → vite-build → URL-rewrite half of publishBuild into buildSsgOutput({ buildId, domain, workDir, publicOrigin }) returning the dist dir. publishBuild keeps only the transition handling and the copy-to-/var/publish (staging + per-custom-domain). A remote pipeline (SSH, and later Coolify SSG) can now reuse the exact same static build and just ship the directory elsewhere. Also hoist the recursive .html/.xml rewriter to a module-level transformOutputFiles() and factor the repeated `slug-without-dot → slug.PUBLISHER_HOST` into qualifyPublishDomain(), both used by all three pipelines. No behaviour change. Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_017rF3HMB1Z7V3LLsYaUrELs * feat: add ssg:ssh publish target (rsync SSG output to a remote server) New host "ssh": build the site with the shared buildSsgOutput() pipeline, then `rsync -az --delete` dist/client/ to a remote server over SSH. The publisher stops serving the hostname locally (kills any container, drops Cloudflare/Traefik routing, purges /var/publish) and writes no Traefik config — TLS and web-server config on the remote host are the operator's. The target (host/user/path/port + private key + optional publicUrl) is set once per domain via POST /targets/ssh-setup, which stores an ssh_key (chmod 600, never logged), target.json, and an ssh-keyscan'd known_hosts. sshHost/sshUser/sshPath/sshPort are validated to safe shapes since they reach ssh-keyscan and rsync as shell arguments. RENDER_HOSTS["ssg:ssh"] wired up, /capabilities reports ssh:true and lists "ssg:ssh" in targets, teardownSite notes the remote files are left in place. Dockerfile gains rsync + openssh-client (+ curl + jq for calling the setup endpoint from `docker compose exec`). Closes webstudio-self-host#7 (publisher side). Supersedes #33. Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_017rF3HMB1Z7V3LLsYaUrELs * fix: write state.json for SSG so later publishes can detect the transition publishBuild never wrote a state.json, so the `prevState.mode === "ssg"` branches in publishBuildSsr / publishBuildCloudflare (purge the stale /var/publish copy when a site moves to a runtime) have been dead since they were added in #8 — switching a published site SSG→SSR left the old static files on disk, shadowed by the container in the proxy but still there. Persist `{ mode: "ssg", publishDomain, customDomains }` at the end of a successful SSG publish. restoreTargets() already ignores it, teardownSite falls through to the generic hostname cleanup, and unpublishHostname now gets a proper hostname list for multi-domain SSG sites instead of the "no state.json" fallback. Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_017rF3HMB1Z7V3LLsYaUrELs --------- Co-authored-by: Claude Sonnet 5 <noreply@anthropic.com>
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
What
Adds
buildMode: "ssh"to the publisher: build the site as SSG, thenrsynctheoutput to a remote server the user owns, instead of serving it locally.
Closes webstudio-community/webstudio-self-host#7 · sub-issue of webstudio-community/webstudio-self-host#22
How it works
The
publicUrlorigin is baked intoog:tags andsitemap.xml. It defaults tothe first custom domain, else the wstd hostname.
The publisher does not serve the hostname and writes no Traefik config —
TLS and routing on the remote host are the user's job. Unpublish / mode switch
leave the remote files in place (logged, not deleted).
Changes
server.mjs:buildSsgOutput()— the sync→generate→vite build half shared bypublishBuildand the newpublishBuildSshtransformOutputFiles()and addqualifyPublishDomain()to module scopepublishBuildSsh(),readSshTarget(),stopLocalServing()POST /targets/ssh-setupendpoint/publishacceptsbuildMode: "ssh";/capabilities→{ cloudflare, ssh }teardownSite()handlesmode: "ssh"Dockerfile:+ rsync openssh-client curl jqCLAUDE.md,README.md: document the modeTesting
node --check server.mjspassespublishBuildcalls the extractedbuildSsgOutput()with the samepublicOriginit built inline beforeCompanion PRs
webstudio-fork:buildModeenum + Publish-panel optionwebstudio-self-host: README publish-modes guide🤖 Generated with Claude Code
https://claude.ai/code/session_01VtPSP1i7NsfKY2DigXV53m