Skip to content

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.

  1. 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.

  2. 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.
  3. Add the workflow to the repository

    Create .github/workflows/menestrel.yml on the default branch. VPS version, complete:

    name: menestrel-publish
    on:
    repository_dispatch:
    types: [menestrel_publish]
    # A newer publish cancels the running build: the latest snapshot wins.
    concurrency:
    group: menestrel-publish
    cancel-in-progress: true
    jobs:
    deploy:
    runs-on: ubuntu-latest
    steps:
    - uses: actions/checkout@v5
    - uses: actions/setup-node@v5
    with:
    node-version: 24
    cache: npm
    - run: npm ci
    - name: Build the site
    run: npm run build
    env:
    MENESTREL_CONTENT_URL: ${{ vars.MENESTREL_CONTENT_URL }}
    MENESTREL_SNAPSHOT_ID: ${{ github.event.client_payload.snapshot_id }}
    - name: Set up the SSH key
    run: |
    install -d -m 700 ~/.ssh
    printf '%s\n' "${{ secrets.DEPLOY_SSH_KEY }}" > ~/.ssh/id_ed25519
    chmod 600 ~/.ssh/id_ed25519
    ssh-keyscan -H "${{ secrets.DEPLOY_HOST }}" >> ~/.ssh/known_hosts
    - name: Deploy over rsync
    run: rsync -az --delete dist/ "${{ secrets.DEPLOY_USER }}@${{ secrets.DEPLOY_HOST }}:/var/www/my-site/"

    Three lines carry all the wiring:

    • types: [menestrel_publish]: the event_type Menestrel sends. Do not rename it.
    • concurrency with cancel-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_ID pins the build to the snapshot that was just published, MENESTREL_CONTENT_URL says where to read it. Resolution order is detailed in Environment variables.
  4. 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_HOST and DEPLOY_USER.
    • Variable: MENESTREL_CONTENT_URL, for example https://content.menestrel.dev/p/prj_xxx/pk_xxx. It is not a secret, a variable is enough.

    The workflow’s ssh-keyscan accepts the server’s key on first contact. To pin it instead, replace that line with a secret holding your known_hosts entry.

  5. 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 FTP
    uses: SamKirkland/FTP-Deploy-Action@v4.3.6
    with:
    server: ${{ secrets.FTP_SERVER }}
    username: ${{ secrets.FTP_USERNAME }}
    password: ${{ secrets.FTP_PASSWORD }}
    protocol: ftps
    local-dir: dist/
    server-dir: www/

    Add the FTP_SERVER, FTP_USERNAME and FTP_PASSWORD secrets, and point server-dir at your host’s public directory. Keep protocol: ftps if the host supports it: the password travels encrypted. The action only re-uploads files that changed since the previous deployment.

  6. Test the target

    The “Test” button sends a real repository_dispatch against 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.

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.

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 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.