Deploy with GitHub Actions (VPS, shared hosting)
A VPS or a shared host has no deploy hook. The default path: on every publish, Menestrel triggers a GitHub Actions workflow through repository_dispatch. The workflow builds the site and pushes dist/ to your server, over rsync for a VPS, over FTP for shared hosting. GitHub provides the build machine: the server needs neither Node nor Git, it only receives static files.
Prerequisites: the site’s code in a GitHub repository, and a site wired to the Menestrel loader.
-
Create the GitHub access token
In GitHub, open Settings > Developer settings > Personal access tokens > Fine-grained tokens. Create a token restricted to the site’s repository only, with two permissions:
- “Contents: Read and write”. GitHub requires it to call
repository_dispatch, the endpoint Menestrel uses to trigger the workflow. - “Actions: Read”. Menestrel uses it to read the run’s state and follow the deployment all the way to “Live”.
Give it an expiration date and write that date down: an expired token is the first cause of a broken target.
- “Contents: Read and write”. GitHub requires it to call
-
Connect the host in Menestrel
In the admin, open “Site settings”, the “Deployment” tab, then “Connect a host”. Pick the GitHub Actions provider, give it a name (for example
Production), then fill in the three GitHub-specific fields, all required:- “Owner”: the GitHub account or organization, for example
my-agency. - “Repository”: the repository name, for example
client-site. - “Personal access token”: the token from step 1.
- “Owner”: the GitHub account or organization, for example
-
Add the workflow to the repository
Create
.github/workflows/menestrel.ymlon the default branch. VPS version, complete:name: menestrel-publishon:repository_dispatch:types: [menestrel_publish]# A newer publish cancels the running build: the latest snapshot wins.concurrency:group: menestrel-publishcancel-in-progress: truejobs:deploy:runs-on: ubuntu-lateststeps:- uses: actions/checkout@v5- uses: actions/setup-node@v5with:node-version: 24cache: npm- run: npm ci- name: Build the siterun: npm run buildenv:MENESTREL_CONTENT_URL: ${{ vars.MENESTREL_CONTENT_URL }}MENESTREL_SNAPSHOT_ID: ${{ github.event.client_payload.snapshot_id }}- name: Set up the SSH keyrun: |install -d -m 700 ~/.sshprintf '%s\n' "${{ secrets.DEPLOY_SSH_KEY }}" > ~/.ssh/id_ed25519chmod 600 ~/.ssh/id_ed25519ssh-keyscan -H "${{ secrets.DEPLOY_HOST }}" >> ~/.ssh/known_hosts- name: Deploy over rsyncrun: rsync -az --delete dist/ "${{ secrets.DEPLOY_USER }}@${{ secrets.DEPLOY_HOST }}:/var/www/my-site/"Three lines carry all the wiring:
types: [menestrel_publish]: theevent_typeMenestrel sends. Do not rename it.concurrencywithcancel-in-progress: two publishes in quick succession do not produce two overlapping releases; the cancelled run shows as “Cancelled” in Menestrel, which is the expected behavior.MENESTREL_SNAPSHOT_IDpins the build to the snapshot that was just published,MENESTREL_CONTENT_URLsays where to read it. Resolution order is detailed in Environment variables.
-
Set the secrets and the variable on GitHub
In the repository, Settings > Secrets and variables > Actions:
- Secrets:
DEPLOY_SSH_KEY(a private key dedicated to deployment, for a user restricted to the site’s directory),DEPLOY_HOSTandDEPLOY_USER. - Variable:
MENESTREL_CONTENT_URL, for examplehttps://content.menestrel.dev/p/prj_xxx/pk_xxx. It is not a secret, a variable is enough.
The workflow’s
ssh-keyscanaccepts the server’s key on first contact. To pin it instead, replace that line with a secret holding yourknown_hostsentry. - Secrets:
-
Shared hosting: replace rsync with FTP
On shared hosting without SSH, replace the last two workflow steps (SSH key and rsync) with a single one:
- name: Deploy over FTPuses: SamKirkland/FTP-Deploy-Action@v4.3.6with:server: ${{ secrets.FTP_SERVER }}username: ${{ secrets.FTP_USERNAME }}password: ${{ secrets.FTP_PASSWORD }}protocol: ftpslocal-dir: dist/server-dir: www/Add the
FTP_SERVER,FTP_USERNAMEandFTP_PASSWORDsecrets, and pointserver-dirat your host’s public directory. Keepprotocol: ftpsif the host supports it: the password travels encrypted. The action only re-uploads files that changed since the previous deployment. -
Test the target
The “Test” button sends a real
repository_dispatchagainst the latest published snapshot, without publishing anything new: the workflow runs and deploys for real. The result shows immediately: “Host reached, deployment triggered.” if GitHub accepted the dispatch, “Test failed” with the detail otherwise. The test does not appear under “Recent deployments”: follow the run in the repository’s Actions tab. Full tracking starts with the first publish.
What the workflow receives
Section titled “What the workflow receives”Menestrel sends a POST /repos/{owner}/{repo}/dispatches with this content:
| Key | Content |
|---|---|
event_type |
Always menestrel_publish. This is the value expected in types:. |
client_payload.snapshot_id |
The id of the published snapshot, to pass into MENESTREL_SNAPSHOT_ID. null if you test the target before any publish: the variable comes through empty and the loader falls back to the latest published snapshot. |
client_payload.project |
The project slug. Useful when one repository serves several sites. |
client_payload.cache_bust |
Reserved for a future rebuild-from-scratch option. Always false today; the workflow can ignore it. |
Status tracking
Section titled “Status tracking”Tracking is automatic: with the same token, Menestrel lists the repository’s runs triggered by repository_dispatch since the trigger time and follows the run to the end. Unlike Vercel or Netlify, there is no optional field to fill in. Once the run is spotted, the deployment name under “Recent deployments” becomes a link to the GitHub run.
| State | Meaning |
|---|---|
| Queued | Menestrel queued the trigger. |
| Triggered | GitHub accepted the dispatch. The run has not been spotted yet. |
| Building | The run is queued or executing on GitHub’s side. |
| Live | The run completed successfully. |
| Failed | The run failed, or the trigger did not go through. |
| Cancelled | The run was cancelled, most often by cancel-in-progress when a newer publish arrives. |
Menestrel spots the run by its event and creation date. If other workflows in the repository also listen to repository_dispatch, tracking may latch onto the wrong run: a single listening workflow is safer.
After repeated failures
Section titled “After repeated failures”After 3 consecutive failures, Menestrel puts the target “Paused” and stops triggering it: hammering a broken workflow fixes nothing and drowns the history. Fix the cause (expired token, workflow missing from the default branch, unreachable server), then click “Resume”. The failure counter resets and publishing rebuilds the site again.