Docker & DevOps

Deploy from GitHub Actions to your VPS over SSH

Create a dedicated deploy user, store its SSH key as a GitHub secret and run a workflow that ships your code to the VPS on every push.

IntermediateLinux server6 min read

A GitHub Actions deploy to a VPS over SSH means GitHub builds or packages your code, then connects to your server with a key and copies the result into place. This guide sets up a dedicated deploy user, stores the private key as a repository secret and runs a workflow that syncs files and restarts your service on every push to main.

The result is a repeatable pipeline you control: no shared root password, no manual uploads, and a clear audit trail in the Actions tab.

Before you start

You need:

  • A Linux VPS with root or sudo access, and the IP address (we use 203.0.113.10).
  • A GitHub repository with a main branch.
  • SSH access from your own machine — see Connect to your Linux VPS with SSH if this is new.
  • rsync installed on the VPS: sudo apt install rsync on Ubuntu or Debian, sudo dnf install rsync on AlmaLinux or Rocky.

Note: Run the workflow from a branch you trust. Anyone who can push to that branch can trigger a deployment, so protect main with branch rules if the repository is shared.

Create a deploy user and key on the VPS

Do not deploy as root. Create a service account with only the rights it needs.

  1. On the VPS, create the user and its .ssh directory:
Bash
sudo adduser --disabled-password --gecos "" deploy
sudo mkdir -p /home/deploy/.ssh
sudo chmod 700 /home/deploy/.ssh
sudo chown deploy:deploy /home/deploy/.ssh
  1. On your local machine, generate a key pair used only by CI:
Bash
ssh-keygen -t ed25519 -C "github-actions-deploy" -f ~/.ssh/gh_deploy -N ""
  1. Install the public key on the server:
Bash
ssh-copy-id -i ~/.ssh/gh_deploy.pub deploy@203.0.113.10
  1. Confirm it works before going further:
Bash
ssh -i ~/.ssh/gh_deploy deploy@203.0.113.10 'whoami; hostname'

You should see deploy and the hostname. If you get Permission denied (publickey), check the key landed in /home/deploy/.ssh/authorized_keys and that the file is mode 600.

  1. Give the account permission to restart only your service. Create a sudoers drop-in:
Bash
echo 'deploy ALL=(ALL) NOPASSWD: /bin/systemctl restart myapp' | sudo tee /etc/sudoers.d/deploy-restart
sudo chmod 440 /etc/sudoers.d/deploy-restart
sudo visudo -c

Replace myapp with your unit name. visudo -c reports syntax errors before they lock you out.

Warning: A wildcard sudo rule such as deploy ALL=(ALL) NOPASSWD: ALL turns a leaked CI key into full root access. Keep the rule to the exact commands the pipeline runs.

Add the secrets in GitHub

In the repository, go to Settings → Secrets and variables → Actions → New repository secret and add:

  • VPS_HOST — 203.0.113.10
  • VPS_USER — deploy
  • VPS_SSH_KEY — the full contents of ~/.ssh/gh_deploy, including the -----BEGIN and -----END lines.
  • VPS_KNOWN_HOSTS — the server's host key line, generated with ssh-keyscan -H 203.0.113.10.

Pinning the host key in a secret is stronger than scanning at run time. Scanning on every run accepts whatever key answers, which is exactly the weakness described in SSH host key verification.

Write the workflow

Create .github/workflows/deploy.yml in your repository:

YAML
name: Deploy to VPS

on:
  push:
    branches: [main]

concurrency:
  group: deploy-production
  cancel-in-progress: false

jobs:
  deploy:
    runs-on: ubuntu-latest
    steps:
      - name: Check out the repository
        uses: actions/checkout@v4

      - name: Prepare SSH
        run: |
          mkdir -p ~/.ssh
          printf '%s\n' "${{ secrets.VPS_SSH_KEY }}" > ~/.ssh/id_ed25519
          chmod 600 ~/.ssh/id_ed25519
          printf '%s\n' "${{ secrets.VPS_KNOWN_HOSTS }}" > ~/.ssh/known_hosts
          chmod 644 ~/.ssh/known_hosts

      - name: Sync files
        run: |
          rsync -az --delete \
            -e "ssh -i ~/.ssh/id_ed25519 -o IdentitiesOnly=yes" \
            ./app/ "${{ secrets.VPS_USER }}@${{ secrets.VPS_HOST }}:/var/www/myapp/"

      - name: Restart the service
        run: |
          ssh -i ~/.ssh/id_ed25519 -o IdentitiesOnly=yes \
            "${{ secrets.VPS_USER }}@${{ secrets.VPS_HOST }}" \
            'sudo /bin/systemctl restart myapp'

What each part does:

  • concurrency stops two pushes from deploying at the same time.
  • IdentitiesOnly=yes prevents the runner from offering unrelated keys, which avoids the "too many authentication failures" error.
  • rsync -az --delete mirrors the app/ directory to the server. The trailing slashes matter: ./app/ to /var/www/myapp/ copies the contents, not the folder itself.

Warning: --delete removes files on the server that no longer exist in the repository. If /var/www/myapp/ also holds uploads, logs or a .env file, exclude them with --exclude '.env' --exclude 'uploads/' or point the sync at a release directory instead.

If your build produces artifacts, add a build step before the sync and copy the output directory, for example ./dist/. For container-based apps, the same pattern works with a docker compose up -d --build command in the final step — see Deploy a self-hosted app with Docker Compose.

Check it works

  1. Commit and push the workflow file:
Bash
git add .github/workflows/deploy.yml
git commit -m "Add deploy workflow"
git push origin main
  1. Open the Actions tab in GitHub and watch the run. Each step should show a green tick within a minute or two.
  1. Confirm the files arrived and the service is running:
Bash
ssh -i ~/.ssh/gh_deploy deploy@203.0.113.10 'ls -l /var/www/myapp; systemctl is-active myapp'
  1. Make a small change, push again and check that the timestamp on the server updates. That proves the pipeline, not just the first manual run.

Troubleshooting

  • Permission denied (publickey) — the secret holds a truncated key, or the wrong user. Check that VPS_SSH_KEY includes the header and footer lines, and read /var/log/auth.log on the VPS for the exact reason.
  • Host key verification failed — the server's host key changed or the VPS_KNOWN_HOSTS secret is stale. Regenerate it with ssh-keyscan -H 203.0.113.10 and update the secret.
  • rsync: command not found — rsync is missing on the VPS. Install it with sudo apt install rsync or sudo dnf install rsync.
  • sudo: a terminal is required — the sudoers rule is missing or the command path differs. Confirm with sudo -l -U deploy and match the exact binary path.
  • Workflow succeeds but the site is unchanged — the rsync source or destination path is wrong, or the service reads from a different directory. Print both paths in the workflow with pwd and ls to compare.

Next steps

Was this guide helpful?

Still stuck?

Ask MdmHost AI for a quick answer, or open a ticket and our team will take a look. We are here 24/7.