velastack/action

velastack/action deploys a Vela app to your own server from GitHub Actions. It builds on the runner and hands the release to vela deploy over SSH, so a deploy from CI and a deploy from your laptop do the same thing.

Before you start

Provision the server and deploy once from your own machine — that first deploy is what mints the app id:

$ vela provision root@your-server
$ vela deploy --server root@your-server --domain example.com
$ git add .vela/project.json && git commit -m "Add the vela project id"

The action refuses to run until .vela/project.json is committed. A runner that finds no id mints a new one, and since its checkout is thrown away at the end of the job, every deploy would land as a brand new app and orphan the last one on the server.

Then give the action a way in:

$ ssh-keygen -t ed25519 -f vela-deploy -C "github actions"
$ ssh-copy-id -i vela-deploy.pub root@your-server

Add the private key to the repository as a secret named SSH_PRIVATE_KEY.

Usage

name: Deploy

on:
  push:
    branches: [main]

concurrency:
  group: deploy-prod
  cancel-in-progress: false

jobs:
  deploy:
    runs-on: ubuntu-latest
    steps:
      - uses: actions/checkout@v6
      - uses: velastack/action@v1
        with:
          server: root@your-server
          ssh-key: ${{ secrets.SSH_PRIVATE_KEY }}
          domain: example.com

The action installs dependencies, builds, uploads a release, runs migrations, restarts the services and health-checks the result. A deploy that fails its health check puts the previous release back, migrations included, and fails the job. Use a concurrency group so two pushes queue rather than race; the server serializes deploys of one target as well, and drops one that turns out to be older than what went live meanwhile — see Two deploys at once.

Pull request previews

Add pull request events and an API key, and every pull request gets its own preview: a full copy of the app on the server, at <project>--<branch>.velastack.app.

name: Deploy

on:
  push:
    branches: [main]
  pull_request:
    types: [opened, synchronize, reopened, closed]

permissions:
  contents: read
  pull-requests: write

concurrency:
  group: deploy-${{ github.head_ref || github.ref_name }}
  cancel-in-progress: false

jobs:
  deploy:
    runs-on: ubuntu-latest
    steps:
      - uses: actions/checkout@v6
      - uses: velastack/action@v1
        with:
          server: root@your-server
          ssh-key: ${{ secrets.SSH_PRIVATE_KEY }}
          api-key: ${{ secrets.VELA_API_KEY }}
          domain: example.com

The target comes from the event when target is left unset: a push deploys production, a pull request deploys preview:<branch>. Closing the pull request, merged or not, removes the preview from the server — database and uploads included, with a snapshot kept in the server’s trash for two weeks — and retires its hostname. One comment on the pull request is kept up to date with the preview URL on every push, which is what pull-requests: write is for; a fork’s token cannot comment, and that never fails the deploy.

The removal rides on the closed event, so keep it in the trigger. If the workflow runs tests before deploying, let that event skip them: a branch is often closed precisely because it is red, and gating the cleanup on green leaves the preview behind for good.

jobs:
  check:
    if: github.event.action != 'closed'
    # ...
  deploy:
    needs: check
    if: ${{ !cancelled() && (github.event.action == 'closed' || needs.check.result == 'success') }}
    # ...

Previews never inherit domain — that is production’s. To serve them on your own domain as well, add a preview base on the project’s Domains page.

VELA_API_KEY is what records deploys on velastack.dev and gives previews their hostname. Create one at velastack.dev/api-keys/new and add it as a repository secret. The project must also be linked, which vela link writes into the same .vela/project.json you already commit. Without the key the deploy still runs; it just is not recorded, and a preview has nothing to be served on.

If your production target has a name other than production, pass it on every event except a pull request, and leave it empty there so the preview target can take over. The empty value has to come last: '' is falsy in GitHub expressions, so cond && '' || 'prod' reads like a ternary but hands back prod on every event — and on a closed pull request that would try to remove production. The action refuses to remove anything but a preview in auto mode, but the deploys would still have landed on production.

          target: ${{ !startsWith(github.event_name, 'pull_request') && 'prod' || '' }}

Dependabot and forks

Pull requests opened by Dependabot, and any pull request from a fork, run without the repository’s secrets, so the action has no SSH key and cannot reach the server. Skip the job for them rather than let it fail; with no preview deployed there is nothing to remove on close either, so the same condition covers the closed event:

  deploy:
    if: github.event_name != 'pull_request' || (github.event.pull_request.user.login != 'dependabot[bot]' && !github.event.pull_request.head.repo.fork)

Inputs

  • server - Required. SSH target, as user@host or host.
  • ssh-key - Required. Private key with access to the server.
  • ssh-port - Port, when the server does not listen on 22.
  • known-hosts - Contents for known_hosts. Without it the host key is fetched on first connect.
  • target - Which copy of the app to deploy. Defaults to production, or to preview:<branch> on a pull request. environment is the deprecated name, still honoured when target is unset.
  • domain - Hostname(s) to serve on, comma separated. Only needed the first time, or when it changes. Never applied to previews.
  • api-key - velastack.dev API key, so the deploy is recorded on the linked project and previews get a velastack.app hostname.
  • action - deploy, destroy, or auto. auto deploys on every event except a closed pull request, where it removes the preview — and only ever a preview: a closed event whose target is anything else fails the job. Defaults to auto.
  • confirm-name - The app’s name, required when action: destroy targets production or a named environment such as staging. Never needed for previews.
  • comment - On pull requests, keep one comment up to date with the preview URL. Defaults to true.
  • github-token - Token the comment is posted with. Defaults to the workflow token, which needs pull-requests: write.
  • project - Override the project name.
  • health-path - Path the health check requests.
  • remote-db - Build against the server’s database. Left unset, the CLI decides: on once the target has been deployed to. Set false to build against a throwaway one.
  • working-directory - Directory holding the app. Defaults to ..
  • node-version - Node.js version to build with. Defaults to .nvmrc or .node-version, then 24.
  • install - Run npm ci first. Defaults to true.
  • vela-version - Version of the CLI to run. Defaults to the one the project pins.

Outputs

  • release - Identifier of the release that was activated
  • url - URL the app is served on
  • hostnames - Every hostname the target is served on, comma separated
  • target - The target that was deployed or removed, such as production or preview:my-branch

On a destroy, release, url and hostnames are empty.

Pages that prerender from data

A prerendered page is rendered once, at build time, against whatever database the build can see. On a runner that would be an empty throwaway database, so those pages would come out full of defaults.

The deploy avoids that on its own: once the target has been deployed to, the build is rendered against that instance’s database, tunnelled over the same SSH connection. The superuser credentials come off the server, so no new secrets go into CI, and the build only ever reads. Nothing has to be set for this.

Set remote-db: false to opt out and build against a throwaway database instead:

      - uses: velastack/action@v1
        with:
          server: root@your-server
          ssh-key: ${{ secrets.SSH_PRIVATE_KEY }}
          remote-db: false

Pass domain on the first deploy from CI, so prerendered pages see the domain as url.origin rather than SvelteKit’s placeholder host. Their canonical links, like the app’s name, come from src/lib/site.ts, not from the database.

Secrets

Production environment variables live on your server, not in the action and not in the release. Set them once with vela env. Anything the build needs — as opposed to the running app — belongs in the workflow, because it has to exist on the runner:

      - uses: velastack/action@v1
        env:
          POCKETBASE_SUPERUSER_EMAIL: ${{ secrets.POCKETBASE_SUPERUSER_EMAIL }}
          POCKETBASE_SUPERUSER_PASSWORD: ${{ secrets.POCKETBASE_SUPERUSER_PASSWORD }}
        with:
          server: root@your-server
          ssh-key: ${{ secrets.SSH_PRIVATE_KEY }}

Pinning the host key

By default the action trusts the server’s host key the first time it connects. To pin it instead, capture it once and pass the output as the known-hosts input:

$ ssh-keyscan -H your-server

Requirements

  • The app builds with @sveltejs/adapter-node
  • .vela/project.json is committed — it carries the app id the server keys everything on
  • The repository uses npm (a package-lock.json is present)