Skip to content

Deploy to a VPS via SSH

For VPS, bare-metal, edge boxes, or anything that doesn’t run on Kubernetes, the gocdnext/ssh plugin covers the “rsync the binary, restart the service” pattern in one job. This recipe builds a Go binary then deploys it.

Prereqs (one-time per environment)

Generate a deploy key

Terminal window
ssh-keygen -t ed25519 -f deploy_key -N "" -C "gocdnext deploy"
ssh-copy-id -i deploy_key.pub deploy@api.example.com

Capture the host fingerprint

Terminal window
ssh-keyscan -t ed25519 -p 22 api.example.com > known_hosts

This protects against MITM. The plugin defaults to StrictHostKeyChecking=yes and refuses to connect without the known-hosts blob.

Stash both as gocdnext secrets

In the dashboard: Project → Secrets

KeyValue
SSH_DEPLOY_KEYfull content of deploy_key (the private key, not the .pub)
SSH_KNOWN_HOSTSfull content of known_hosts

Both are AES-256-GCM-encrypted at rest and masked in run logs.

The pipeline

.gocdnext/cd.yaml
name: cd
when:
event: [push]
branch: [main] # only deploy from main
stages: [build, ship]
jobs:
binary:
stage: build
uses: ghcr.io/klinux/gocdnext-plugin-go@v1
with:
command: build -o dist/api-server ./cmd/api
artifacts:
paths: [dist/api-server]
deploy:
stage: ship
uses: ghcr.io/klinux/gocdnext-plugin-ssh@v1
needs: [binary]
needs_artifacts:
- from_job: binary
paths: [dist/api-server]
secrets: [SSH_DEPLOY_KEY, SSH_KNOWN_HOSTS]
with:
host: api.example.com
user: deploy
key: ${{ SSH_DEPLOY_KEY }}
known_hosts: ${{ SSH_KNOWN_HOSTS }}
upload: dist/api-server
target: /opt/api/
script: |
sudo systemctl restart api
sudo systemctl status api --no-pager

What’s happening:

  1. binary job builds + uploads dist/api-server as an artefact.
  2. deploy job pulls that artefact down via needs_artifacts, then the gocdnext/ssh plugin:
    • writes the key to /tmp/.../id with mode 600
    • validates the host key against known_hosts
    • rsyncs dist/api-server to /opt/api/ on the remote (--mkpath creates the dir if missing)
    • opens an SSH session and runs the script under set -euo pipefailsystemctl restart failing or systemctl status returning non-zero fails the deploy.

Manual one-off (no upload)

For migrations, debug commands, or anything you want to fire on a production host without a build:

name: prod-migrate
when:
event: [manual] # only via "Run" button or CLI
stages: [run]
jobs:
run:
stage: run
uses: ghcr.io/klinux/gocdnext-plugin-ssh@v1
secrets: [SSH_OPS_KEY, DB_KNOWN_HOSTS]
with:
host: db.example.com
user: ops
key: ${{ SSH_OPS_KEY }}
known_hosts: ${{ DB_KNOWN_HOSTS }}
script: |
/opt/migrate/run.sh --env=prod --dry-run=false

event: [manual] removes the auto-trigger on push — the only way this fires is from Run latest in the dashboard or gocdnext run prod-migrate. Pair with approval gates when the operation is destructive.

Multiple hosts (fanout)

For a small fleet, expand the host list via parallel.matrix: — each matrix entry maps a variable name to a list of values, and the cartesian product becomes one job per cell. Matrix vars are referenced as ${{ NAME }} (identifier only).

deploy:
stage: ship
uses: ghcr.io/klinux/gocdnext-plugin-ssh@v1
parallel:
matrix:
- HOST:
- api-1.example.com
- api-2.example.com
- api-3.example.com
secrets: [SSH_DEPLOY_KEY, SSH_KNOWN_HOSTS_FLEET]
with:
host: ${{ HOST }}
user: deploy
key: ${{ SSH_DEPLOY_KEY }}
known_hosts: ${{ SSH_KNOWN_HOSTS_FLEET }}
upload: dist/api-server
target: /opt/api/
script: |
sudo systemctl restart api

Three parallel jobs, three deploys. A single host failure doesn’t hold the others; the run aggregates success only when every matrix cell does.

Security checklist

  • Always set known_hosts (or host_key). The host_key_check: "no" escape hatch logs a WARNING in run output and disables MITM protection — never use it in a real deploy.
  • Prefer keys over password:. Password auth via sshpass exists for legacy hosts but each pipeline run prints WARN reminders.
  • Rotate the deploy key + known_hosts together. The known-hosts fingerprint changes when the host’s host-keys are regenerated.
  • Pin the plugin version (ghcr.io/klinux/gocdnext-plugin-ssh@v1, not gocdnext/ssh@latest) so a plugin breaking change can’t sneak into a deploy without showing up in a PR.