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
ssh-keygen -t ed25519 -f deploy_key -N "" -C "gocdnext deploy"ssh-copy-id -i deploy_key.pub deploy@api.example.comCapture the host fingerprint
ssh-keyscan -t ed25519 -p 22 api.example.com > known_hostsThis 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
| Key | Value |
|---|---|
SSH_DEPLOY_KEY | full content of deploy_key (the private key, not the .pub) |
SSH_KNOWN_HOSTS | full content of known_hosts |
Both are AES-256-GCM-encrypted at rest and masked in run logs.
The pipeline
name: cdwhen: 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-pagerWhat’s happening:
binaryjob builds + uploadsdist/api-serveras an artefact.deployjob pulls that artefact down vianeeds_artifacts, then thegocdnext/sshplugin:- writes the key to
/tmp/.../idwith mode 600 - validates the host key against
known_hosts - rsyncs
dist/api-serverto/opt/api/on the remote (--mkpathcreates the dir if missing) - opens an SSH session and runs the script under
set -euo pipefail—systemctl restartfailing orsystemctl statusreturning non-zero fails the deploy.
- writes the key to
Manual one-off (no upload)
For migrations, debug commands, or anything you want to fire on a production host without a build:
name: prod-migratewhen: 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=falseevent: [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 apiThree 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(orhost_key). Thehost_key_check: "no"escape hatch logs aWARNINGin run output and disables MITM protection — never use it in a real deploy. - Prefer keys over
password:. Password auth viasshpassexists for legacy hosts but each pipeline run printsWARNreminders. - Rotate the deploy key +
known_hoststogether. The known-hosts fingerprint changes when the host’s host-keys are regenerated. - Pin the plugin version (
ghcr.io/klinux/gocdnext-plugin-ssh@v1, notgocdnext/ssh@latest) so a plugin breaking change can’t sneak into a deploy without showing up in a PR.