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 ofgha_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
productionenvironment. - 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.