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, asuser@hostorhost.ssh-key- Required. Private key with access to the server.ssh-port- Port, when the server does not listen on 22.known-hosts- Contents forknown_hosts. Without it the host key is fetched on first connect.target- Which copy of the app to deploy. Defaults toproduction, or topreview:<branch>on a pull request.environmentis the deprecated name, still honoured whentargetis 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 avelastack.apphostname.action-deploy,destroy, orauto.autodeploys on every event except a closed pull request, where it removes the preview — and only ever a preview: aclosedevent whose target is anything else fails the job. Defaults toauto.confirm-name- The app’s name, required whenaction: destroytargets production or a named environment such asstaging. Never needed for previews.comment- On pull requests, keep one comment up to date with the preview URL. Defaults totrue.github-token- Token the comment is posted with. Defaults to the workflow token, which needspull-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. Setfalseto build against a throwaway one.working-directory- Directory holding the app. Defaults to..node-version- Node.js version to build with. Defaults to.nvmrcor.node-version, then 24.install- Runnpm cifirst. Defaults totrue.vela-version- Version of the CLI to run. Defaults to the one the project pins.
Outputs
release- Identifier of the release that was activatedurl- URL the app is served onhostnames- Every hostname the target is served on, comma separatedtarget- The target that was deployed or removed, such asproductionorpreview: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.jsonis committed — it carries the app id the server keys everything on- The repository uses npm (a
package-lock.jsonis present)