How to Deploy with GitHub Actions to a VPS

Use GitHub Actions and SSH to ship code to a VPS automatically after pushes, while keeping the workflow understandable enough to debug.

GitHub ActionsCI/CDVPS deployment
What you learn

How to connect a GitHub repository to a VPS using an SSH deploy key, GitHub secrets, and a simple workflow file.

Best for

Small apps, static sites, and Docker Compose projects that need repeatable deployments without a large platform layer.

Risk to watch

CI can make a bad deployment happen faster. Keep rollback steps simple and explicit.

Before you begin

  • A GitHub repository with code ready to deploy.
  • A VPS with SSH access and a deploy path such as /opt/myapp or /var/www/myapp.
  • A non-root deploy user on the VPS.
  • A build or restart command you already trust when run manually.

GitHub Actions is useful when you want pushes to main to trigger a consistent deployment path. The goal is not maximum platform complexity. The goal is to move from “someone SSHes in and remembers the steps” to “the steps are written down, reviewed, and repeatable.”

Expected outcome: By the end, a push to your main branch will connect to the VPS over SSH, update code, and restart the app or Compose stack in a predictable way.

Step 1: Choose a simple deployment pattern

For beginner-friendly VPS deployments, a good default pattern is:

  1. GitHub Actions checks out the repo.
  2. The workflow connects to the VPS over SSH.
  3. The VPS pulls the latest code or receives built files.
  4. The app restarts with a small command such as docker compose up -d --build or systemctl restart myapp.

This guide uses an SSH-based deploy because it stays close to standard Linux tools. You can add containers, artifacts, or release directories later, but first get one clean path working end to end.

Step 2: Prepare the VPS for CI deployments

Generate a dedicated SSH key pair on your local machine or a safe admin workstation:

ssh-keygen -t ed25519 -f ~/.ssh/github-actions-myapp -C "github-actions-myapp"

Add the public key to the deploy user on the VPS:

ssh deploy@your-vps 'mkdir -p ~/.ssh && chmod 700 ~/.ssh'
cat ~/.ssh/github-actions-myapp.pub | ssh deploy@your-vps 'cat >> ~/.ssh/authorized_keys && chmod 600 ~/.ssh/authorized_keys'

Create or confirm the app directory on the VPS:

ssh deploy@your-vps 'mkdir -p /opt/myapp && cd /opt/myapp && git init'

If you plan to deploy a Docker Compose app, test the manual command now:

ssh deploy@your-vps 'cd /opt/myapp && docker compose config'

Do not move to CI until the deployment steps already make sense by hand.

Step 3: Add GitHub Actions secrets

In your GitHub repository, go to SettingsSecrets and variablesActions and add:

  • VPS_HOST with your server IP or hostname
  • VPS_USER with the deploy username
  • VPS_SSH_KEY with the full private key contents from ~/.ssh/github-actions-myapp
  • VPS_PORT if you use a custom SSH port

If the app also needs environment values during deploy, keep them on the VPS in a protected .env file whenever possible. Avoid using GitHub Actions to rewrite secrets on every run unless that is part of your actual design.

Step 4: Create the workflow file

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

name: Deploy to VPS

on:
  push:
    branches:
      - main

jobs:
  deploy:
    runs-on: ubuntu-latest

    steps:
      - name: Check out repository
        uses: actions/checkout@v4

      - name: Start ssh-agent and add key
        uses: webfactory/ssh-agent@v0.9.0
        with:
          ssh-private-key: ${{ secrets.VPS_SSH_KEY }}

      - name: Add VPS host to known_hosts
        run: |
          mkdir -p ~/.ssh
          ssh-keyscan -p "${{ secrets.VPS_PORT || '22' }}" -H "${{ secrets.VPS_HOST }}" >> ~/.ssh/known_hosts

      - name: Deploy on VPS
        run: |
          ssh -p "${{ secrets.VPS_PORT || '22' }}" "${{ secrets.VPS_USER }}@${{ secrets.VPS_HOST }}" <<'EOF'
            set -euo pipefail
            cd /opt/myapp

            if [ ! -d .git ]; then
              git clone https://github.com/your-user/your-repo.git .
            fi

            git fetch --all
            git checkout main
            git reset --hard origin/main

            docker compose pull
            docker compose up -d --build
            docker image prune -f
          EOF

This workflow assumes the VPS itself can access the repository without extra authentication if it needs to clone. If your repo is private, you can instead use rsync or upload artifacts from the workflow rather than having the VPS pull directly.

Commit and push the workflow:

git add .github/workflows/deploy.yml
git commit -m "Add VPS deployment workflow"
git push origin main

Then open the Actions tab in GitHub and watch the first run carefully.

Expected outcomes and verification

When the workflow succeeds, verify both the CI side and the server side:

ssh deploy@your-vps 'cd /opt/myapp && git rev-parse --short HEAD'
ssh deploy@your-vps 'cd /opt/myapp && docker compose ps'
curl -I https://example.com

You should see the current commit deployed, the containers or services healthy, and the public site responding the way you expect.

Rollback and recovery notes

If a deployment breaks production, the fastest rollback is usually on the VPS itself:

ssh deploy@your-vps
cd /opt/myapp
git log --oneline -n 5
git reset --hard <previous-good-commit>
docker compose up -d --build

If you use image tags or release directories, your rollback may be even cleaner. The key idea is that rollback should not depend on remembering which files changed. Keep it anchored to a known good commit or release.

If GitHub Actions itself is broken, you can temporarily return to the manual SSH deployment path while you fix the workflow. Automation should support operations, not trap them.

Troubleshooting common GitHub Actions deploy issues

SSH authentication fails.
Check that the private key in GitHub secrets matches the public key in authorized_keys, and confirm the right username and port.

The workflow connects, but git pull fails on the VPS.
For private repos, the VPS may not have repository access. Switch to rsync, artifacts, or configure a deploy key for the repo itself.

Containers restart, but the app still shows the old version.
Check caching, mounted volumes, reverse proxies, or whether the real live directory differs from the one you updated.

The first deployment modified too much.
Run the same commands manually on the VPS to understand their effect before editing the workflow again.

Warning: Avoid putting long-lived app secrets directly into workflow files or regular repository variables. Keep them in GitHub secrets or on the server with locked-down permissions.

What to do next

Once CI deployments work, the next safety improvement is controlling image drift so a rebuild does not unexpectedly change your stack. Continue with How to Pin Docker Images and Avoid Bad Updates.