Deploying to a Linux Server over SSH
Not every application ships as a container. Many teams run a single Linux virtual machine or bare-metal host and deploy by connecting over SSH, pulling or copying a new build, and restarting a service. This lesson covers how to do that safely from a GitHub Actions workflow: authenticating without passwords, verifying the server’s identity, gating the deploy behind tests and approvals, and rolling back automatically when a deploy goes bad.
Overview / How it works
An SSH deploy is push-based: the GitHub-hosted (or self-hosted) runner initiates an outbound connection to your server and runs commands there. This is different from pull-based deployment models where an agent on the server periodically checks for new releases. Push-based deploys are simple to reason about, but they mean the runner temporarily holds credentials capable of reaching production, so the server’s firewall must allow inbound SSH from wherever the runner executes, and the private key used must be scoped to do only what deployment requires.
Where this fits in CI/CD: the build and test jobs are continuous integration — they run on every push and tell you whether the code is safe to ship. The SSH step is the deployment stage. If it runs automatically after tests pass on the main branch, that is continuous deployment. If it waits for a human to approve a GitHub environment before running, that is continuous delivery — the pipeline proves the release is ready, but a person decides when it ships.
Syntax or workflow structure
A production-shaped SSH deploy job typically needs:
needs:pointing at the test/build job, so a failing test blocks deployment.- An
environment:key (for exampleproduction) so you can attach required reviewers, a wait timer, and environment-scoped secrets in repository settings. - An explicit
permissions:block. SSH deployment does not need write access to the repository or packages, socontents: readis normally sufficient — do not inherit broader default permissions. - Secrets for the connection: a host or IP, a deploy username, and a private key (commonly named
DEPLOY_HOST,DEPLOY_USER,DEPLOY_SSH_KEY). Store these as environment secrets, not repository-wide secrets, so only jobs targeting that environment can read them. - Either a maintained community action such as
appleboy/ssh-action(pin to a specific tag or commit SHA) or the native OpenSSH client plus an agent action likewebfactory/ssh-agent.
Examples
Example 1: minimal deploy with a wrapper action
This workflow runs tests, then deploys only if they pass, gated by a production environment.
name: Deploy to Linux Server
on:
push:
branches: [main]
permissions:
contents: read
jobs:
test:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- run: npm ci
- run: npm test
deploy:
needs: test
runs-on: ubuntu-latest
environment: production
permissions:
contents: read
steps:
- uses: actions/checkout@v4
- name: Deploy over SSH
uses: appleboy/ssh-action@v1.0.3
with:
host: ${{ secrets.DEPLOY_HOST }}
username: ${{ secrets.DEPLOY_USER }}
key: ${{ secrets.DEPLOY_SSH_KEY }}
script: |
cd /var/www/myapp
git pull origin main
npm ci --omit=dev
sudo systemctl restart myapp
Expected behavior: on every push to main, test runs first. If it fails, deploy never starts. If it succeeds, the production environment’s protection rules (if any reviewers are configured) pause the job for approval, then the runner connects over SSH, pulls the latest commit, installs dependencies, and restarts the service.
Example 2: native SSH client with host key verification
Wrapper actions are convenient, but they hide the host key check by default. This version pins the server’s known_hosts entry explicitly instead of trusting on first use.
deploy:
needs: test
runs-on: ubuntu-latest
environment: production
permissions:
contents: read
steps:
- uses: actions/checkout@v4
- name: Install SSH key
uses: webfactory/ssh-agent@v0.9.0
with:
ssh-private-key: ${{ secrets.DEPLOY_SSH_KEY }}
- name: Trust known server fingerprint
run: |
mkdir -p ~/.ssh
echo "${{ secrets.DEPLOY_KNOWN_HOSTS }}" >> ~/.ssh/known_hosts
- name: Build release archive
run: |
npm ci
npm run build
tar -czf release.tar.gz dist/
- name: Copy and deploy release
run: |
scp -o StrictHostKeyChecking=yes release.tar.gz \
${{ secrets.DEPLOY_USER }}@${{ secrets.DEPLOY_HOST }}:/tmp/release.tar.gz
ssh -o StrictHostKeyChecking=yes \
${{ secrets.DEPLOY_USER }}@${{ secrets.DEPLOY_HOST }} \
'bash -s' < scripts/remote-deploy.sh
Expected behavior: the workflow refuses to connect if the server’s host key does not match the value stored in DEPLOY_KNOWN_HOSTS (captured once, offline, with ssh-keyscan and verified against the server’s real fingerprint). The build artifact is copied to the server, then a deploy script runs remotely.
Example 3: immutable releases with automatic rollback
scripts/remote-deploy.sh unpacks each release into its own timestamped directory, switches a symlink atomically, and rolls back if a health check fails after restart.
RELEASE_DIR=/var/www/myapp/releases/$(date +%Y%m%d%H%M%S)
mkdir -p "$RELEASE_DIR"
tar -xzf /tmp/release.tar.gz -C "$RELEASE_DIR"
ln -sfn "$RELEASE_DIR" /var/www/myapp/current
sudo systemctl restart myapp
sleep 5
if curl -fsS http://localhost:3000/health; then
echo "Deployment healthy"
ls -dt /var/www/myapp/releases/*/ | tail -n +6 | xargs -r rm -rf
else
echo "Health check failed, rolling back"
PREVIOUS=$(ls -dt /var/www/myapp/releases/*/ | sed -n 2p)
ln -sfn "$PREVIOUS" /var/www/myapp/current
sudo systemctl restart myapp
exit 1
fi
Expected behavior: a new release directory is created without touching the previous one. If the health check after restart fails, the symlink flips back to the prior release and the service restarts on it, so a bad deploy self-heals within seconds instead of leaving the site down. The old releases are pruned only after a healthy deploy, keeping the last five for manual rollback.
Step by step
- Generate a dedicated ed25519 key pair used only for deployment — never reuse a developer’s personal SSH key.
- Add the public key to the deploy user’s
authorized_keyson the server, ideally restricted with acommand=andfrom=prefix so the key can only run the deploy script from expected source addresses. - Store the private key as a secret scoped to a GitHub environment (for example
production), not a repository-wide secret. - Capture the server’s host key with
ssh-keyscanonce, verify its fingerprint out of band, and store it as a secret or commit it if it is not sensitive. - Configure the environment’s protection rules: required reviewers for production, and optionally a wait timer.
- Write the remote deploy script to unpack into a fresh directory and swap a symlink, so rollback is a pointer change, not a rebuild.
- Add a post-restart health check and automatic rollback on failure.
- Trigger the deploy job only after the test job succeeds, using
needs:.
Common Mistakes
Mistake 1: disabling host key checking. Many examples online use StrictHostKeyChecking=no or an SSH action’s default of accepting any host key. This removes protection against a machine-in-the-middle silently intercepting your deploy credentials and commands.
Fix: pin the server’s known host key ahead of time and use StrictHostKeyChecking=yes, as in Example 2, so the connection fails loudly if the fingerprint ever changes unexpectedly.
Mistake 2: deploying on any pull request event. Triggering a deploy job on pull_request (especially with pull_request_target, which runs with the base repository’s secrets) means anyone who opens a pull request, including from a fork, can potentially reach production credentials by getting workflow code to execute.
Fix: trigger deployment only on push to a protected branch or on workflow_dispatch, and require the target environment to have approved reviewers. Never combine pull_request_target with checking out and running a fork’s code in a job that has deploy secrets.
Mistake 3: deploying directly over the previous release in place. Overwriting the running application’s files with git pull or rsync --delete into the live directory means a broken deploy is already live with no fast way back, and a partially-applied deploy can leave the app in a mixed, broken state mid-copy.
Fix: deploy into a new, isolated directory and switch an atomic symlink only once the new release is fully in place and healthy, as shown in Example 3.
Best Practices
- Use a dedicated, low-privilege deploy user on the server rather than
root, and grant only the specificsudocommands the deploy script needs (for example, restarting one service) via a narrowly scoped sudoers entry. - Restrict the deploy key in
authorized_keyswithcommand=so a stolen key cannot be used for an arbitrary interactive shell. - Gate production deploys behind a GitHub environment with required reviewers; keep staging or development environments unrestricted so the team can iterate quickly there.
- Prefer immutable release directories with an atomic symlink swap over in-place file updates — it makes rollback a one-line operation.
- Always run an automated health check after restart, and treat a failed check as a signal to roll back automatically, not just an alert to page someone.
- Rotate the deploy key periodically and immediately if a runner or workflow is ever compromised; revoking one entry in
authorized_keysshould not affect other systems. - Pin third-party actions like
appleboy/ssh-actionorwebfactory/ssh-agentto a specific version tag or commit SHA — a mutable tag can be repointed to malicious code that would run with access to your deploy secrets. - Never echo the private key, password, or full connection string to logs; if a step must reference it, pass it only through the action’s or command’s designated secret input.
Practice Exercises
- Generate an ed25519 key pair, add the public half to a test server’s
authorized_keysrestricted with acommand=prefix, and store the private key as an environment-scoped secret. - Add a
productionenvironment to a sample repository with at least one required reviewer, then verify a deploy job pauses for approval. - Rewrite Example 1 to use the native OpenSSH client with a pinned
known_hostsentry instead of a wrapper action’s default trust behavior. - Implement the releases-directory-plus-symlink pattern from Example 3 against a throwaway VM, then intentionally break the health check endpoint and confirm the workflow rolls back automatically.
Summary
SSH deployment is a push-based delivery method: the runner connects out to a Linux server and runs commands there, so the credentials it carries must be scoped as tightly as possible. Verify the server’s host key instead of disabling the check, keep deploy secrets in a protected environment rather than the whole repository, and never let a workflow triggered by untrusted pull request content touch those secrets. Structure the remote side around immutable release directories and an atomic symlink so that a failed health check can trigger an automatic rollback within seconds rather than leaving a broken deploy live.
