Pushing to main and having your site update automatically is one of the best quality-of-life upgrades for a project. With GitHub Actions, a workflow can test your code, build it, and deploy it to your VPS over SSH — no extra CI server needed.

This guide sets up a secure deployment pipeline with a dedicated deploy user, then shows three deployment styles: git pull on the server, rsync of build output, and Docker images.

1. Create a deploy user on the VPS

Never deploy as root:

sudo adduser --disabled-password --gecos "" deploy
sudo mkdir -p /var/www/app && sudo chown deploy:www-data /var/www/app

If the deploy needs to restart a service, allow exactly that via sudo:

echo 'deploy ALL=(root) NOPASSWD: /usr/bin/systemctl restart myapp, /usr/bin/systemctl reload nginx' | sudo tee /etc/sudoers.d/deploy
sudo chmod 440 /etc/sudoers.d/deploy
sudo visudo -c

See sudo and sudoers.

2. Create a deploy key

On your computer (not the server), create a key used only for deployments:

ssh-keygen -t ed25519 -f gha_deploy -N "" -C "github-actions-deploy"

Add the public key to the server:

sudo -u deploy mkdir -p /home/deploy/.ssh
cat gha_deploy.pub | sudo -u deploy tee -a /home/deploy/.ssh/authorized_keys
sudo chmod 700 /home/deploy/.ssh && sudo chmod 600 /home/deploy/.ssh/authorized_keys

Optionally restrict the key to certain source IPs with from="…" in authorized_keys (GitHub’s runner IP ranges are broad and change, so this is more practical with self-hosted runners).

3. Store secrets in GitHub

Repository → Settings → Secrets and variables → Actions:

  • SSH_PRIVATE_KEY — contents of gha_deploy (the private key).
  • SSH_HOST — server IP or hostname.
  • SSH_USER — deploy.
  • SSH_KNOWN_HOSTS — the server’s host key, so the workflow can verify it:
ssh-keyscan -t ed25519 203.0.113.10

Verify that fingerprint against the server (ssh-keygen -lf /etc/ssh/ssh_host_ed25519_key.pub) before saving it. Skipping host verification (StrictHostKeyChecking no) makes man-in-the-middle attacks possible.

Delete the private key file from your computer once it’s stored.

4a. Deploy by building and rsyncing (static sites, Node builds)

.github/workflows/deploy.yml:

name: Deploy
on:
  push:
    branches: [main]

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

jobs:
  deploy:
    runs-on: ubuntu-latest
    environment: production
    steps:
      - uses: actions/checkout@v4

      - uses: actions/setup-node@v4
        with:
          node-version: 22
          cache: npm

      - run: npm ci
      - run: npm test
      - run: npm run build

      - name: Configure SSH
        run: |
          mkdir -p ~/.ssh
          echo "${{ secrets.SSH_PRIVATE_KEY }}" > ~/.ssh/id_ed25519
          chmod 600 ~/.ssh/id_ed25519
          echo "${{ secrets.SSH_KNOWN_HOSTS }}" > ~/.ssh/known_hosts

      - name: Upload
        run: rsync -az --delete dist/ ${{ secrets.SSH_USER }}@${{ secrets.SSH_HOST }}:/var/www/app/

concurrency prevents two deployments running at once. The environment: production line lets you add required reviewers or restrict secrets to the main branch in the repository settings.

4b. Deploy with git pull on the server (PHP, Python apps)

Replace the upload step:

      - name: Deploy
        run: |
          ssh ${{ secrets.SSH_USER }}@${{ secrets.SSH_HOST }} 'bash -se' <<'EOF'
            set -euo pipefail
            cd /var/www/app
            git fetch --all
            git reset --hard origin/main
            composer install --no-dev --optimize-autoloader
            php artisan migrate --force
            sudo systemctl reload nginx
          EOF

The server needs read access to the repository — add a separate read-only deploy key (Repository → Settings → Deploy keys) for the server.

4c. Deploy Docker images

Build in Actions, push to GitHub Container Registry, then pull on the server:

    permissions:
      contents: read
      packages: write
    steps:
      - uses: actions/checkout@v4
      - uses: docker/login-action@v3
        with:
          registry: ghcr.io
          username: ${{ github.actor }}
          password: ${{ secrets.GITHUB_TOKEN }}
      - uses: docker/build-push-action@v6
        with:
          push: true
          tags: ghcr.io/${{ github.repository }}:${{ github.sha }}
      # …configure SSH as above, then:
      - run: |
          ssh ${{ secrets.SSH_USER }}@${{ secrets.SSH_HOST }} \
            "cd /srv/app && IMAGE_TAG=${{ github.sha }} docker compose pull && IMAGE_TAG=${{ github.sha }} docker compose up -d"

Your compose.yaml on the server references image: ghcr.io/you/app:${IMAGE_TAG}. Tagging by commit SHA makes rollbacks trivial: redeploy an older SHA. The deploy user needs access to Docker (which is root-equivalent — weigh that) and docker login ghcr.io with a read token if the image is private.

Zero-downtime and rollback

For non-Docker deployments, upload each release to its own directory and switch a symlink:

RELEASE=/var/www/releases/$(date +%Y%m%d%H%M%S)
rsync -az dist/ deploy@host:$RELEASE/
ssh deploy@host "ln -sfn $RELEASE /var/www/current && ls -1dt /var/www/releases/* | tail -n +6 | xargs rm -rf"

Rolling back means pointing current at the previous release. See deployment strategies.

Security checklist

  • Dedicated deploy user, no root, minimal sudo.
  • Host key verified via known_hosts.
  • Secrets scoped to a protected production environment.
  • Pin third-party actions to versions (or commit SHAs).
  • Rotate the deploy key if anyone with access leaves.

Related: GitHub Actions Docker builds, self-hosted GitHub runners and CI/CD pipelines explained.

  • GitHub Actions
  • CI/CD
  • Deployment