Skip to content

feat: add buildMode "ssh" — deploy an SSG site to a remote server via rsync - #33

Closed
vianmora wants to merge 1 commit into
mainfrom
feat/publish-mode-ssh
Closed

vianmora wants to merge 1 commit into
mainfrom
feat/publish-mode-ssh

Conversation

@vianmora

@vianmora vianmora commented Sep 7, 2026

Copy link
Copy Markdown
Contributor

What

Adds buildMode: "ssh" to the publisher: build the site as SSG, then rsync the
output 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

POST /targets/ssh-setup { domain, sshHost, sshUser, sshPath, sshPort?, sshPrivateKey, publicUrl? }
  → /var/work/<domain>/ssh_key      private key, chmod 600, never logged
  → /var/work/<domain>/target.json  { mode:"ssh", sshHost, sshUser, sshPath, sshPort, publicUrl }
  → ssh-keyscan → /var/work/<domain>/known_hosts  (best-effort)

POST /publish { buildId, builderOrigin, buildMode: "ssh" }
  → read target.json   (throws with curl instructions if the site has no target)
  → stop any local runtime/static copy for the site (mode switch → ssh)
  → buildSsgOutput()   (sync → build ssg → vite build → rewrite URLs to publicUrl)
  → rsync -az --delete -e "ssh -p … -i ssh_key -o UserKnownHostsFile=known_hosts
      -o StrictHostKeyChecking=accept-new"  dist/client/  user@host:path/
  → state.json { mode:"ssh", publishDomain, customDomains, sshHost, sshPath, publicUrl }

The publicUrl origin is baked into og: tags and sitemap.xml. It defaults to
the 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:
    • extract buildSsgOutput() — the sync→generate→vite build half shared by
      publishBuild and the new publishBuildSsh
    • hoist transformOutputFiles() and add qualifyPublishDomain() to module scope
    • publishBuildSsh(), readSshTarget(), stopLocalServing()
    • POST /targets/ssh-setup endpoint
    • /publish accepts buildMode: "ssh"; /capabilities → { cloudflare, ssh }
    • teardownSite() handles mode: "ssh"
  • Dockerfile: + rsync openssh-client curl jq
  • CLAUDE.md, README.md: document the mode

Testing

  • node --check server.mjs passes
  • SSG pipeline behaviour is unchanged — publishBuild calls the extracted
    buildSsgOutput() with the same publicOrigin it built inline before
  • Manual e2e (rsync to a remote box) not yet run on a live stack — flag for review

Companion PRs

  • webstudio-fork: buildMode enum + Publish-panel option
  • webstudio-self-host: README publish-modes guide

🤖 Generated with Claude Code

https://claude.ai/code/session_01VtPSP1i7NsfKY2DigXV53m

… 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
@vianmora

vianmora commented Sep 8, 2026

Copy link
Copy Markdown
Contributor Author

Superseded by #36, which re-implements the SSH pipeline on top of the renderMode × host model from #35 (host: "ssh" instead of buildMode: "ssh") and bundles the dead-legacy-SSR cleanup + buildSsgOutput() extraction.

@vianmora vianmora closed this Sep 8, 2026
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>
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

feat: mode ssh — publier un site SSG sur un serveur distant via rsync

1 participant