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 example production) 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, so contents: read is 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 like webfactory/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

  1. Generate a dedicated ed25519 key pair used only for deployment — never reuse a developer’s personal SSH key.
  2. Add the public key to the deploy user’s authorized_keys on the server, ideally restricted with a command= and from= prefix so the key can only run the deploy script from expected source addresses.
  3. Store the private key as a secret scoped to a GitHub environment (for example production), not a repository-wide secret.
  4. Capture the server’s host key with ssh-keyscan once, verify its fingerprint out of band, and store it as a secret or commit it if it is not sensitive.
  5. Configure the environment’s protection rules: required reviewers for production, and optionally a wait timer.
  6. Write the remote deploy script to unpack into a fresh directory and swap a symlink, so rollback is a pointer change, not a rebuild.
  7. Add a post-restart health check and automatic rollback on failure.
  8. 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 specific sudo commands the deploy script needs (for example, restarting one service) via a narrowly scoped sudoers entry.
  • Restrict the deploy key in authorized_keys with command= 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_keys should not affect other systems.
  • Pin third-party actions like appleboy/ssh-action or webfactory/ssh-agent to 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

  1. Generate an ed25519 key pair, add the public half to a test server’s authorized_keys restricted with a command= prefix, and store the private key as an environment-scoped secret.
  2. Add a production environment to a sample repository with at least one required reviewer, then verify a deploy job pauses for approval.
  3. Rewrite Example 1 to use the native OpenSSH client with a pinned known_hosts entry instead of a wrapper action’s default trust behavior.
  4. 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.