GitHub Actions can test every change and deploy it to your own server without a separate CI service. This guide shows working workflows for a VPS, and notes what changes if you deploy to shared hosting or the App Platform instead.
Turn a push to main into a tested production deploy. GitHub Actions runs your tests, builds the artifact, and ships it to your VPS over SSH with a dedicated deploy key, all from .github/workflows/*.yml. This guide covers SSH, Docker and PHP/Laravel deploys, test matrices, PR previews, secrets, approval gates and rollbacks.
Why GitHub Actions
- A free monthly allowance of minutes for private repositories on GitHub Free, and free standard runners for public repositories
- No separate CI service to run + secure
- Native integration with PRs, issues, releases
- Massive marketplace of pre-built actions
Anatomy of a workflow
.github/workflows/deploy.yml:
name: Deploy
on:
push:
branches: [main]
workflow_dispatch: # allow manual trigger from GitHub UI
jobs:
test:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v5
- uses: actions/setup-node@v5
with: { node-version: 22 }
- run: npm ci
- run: npm test
deploy:
needs: test
runs-on: ubuntu-latest
if: github.ref == 'refs/heads/main'
steps:
- uses: actions/checkout@v5
- name: Deploy
env:
VPS_SSH_KEY: ${{ secrets.VPS_SSH_KEY }}
VPS_HOST: ${{ secrets.VPS_HOST }}
run: |
...Pattern 1 — SSH deploy (simplest)
Build in CI, rsync the result to the VPS, restart the service.
Step 1 — Generate deploy SSH key
On your laptop:
ssh-keygen -t ed25519 -C "github-deploy" -f ~/.ssh/github_deploy -N ""
# Creates: ~/.ssh/github_deploy (private) + ~/.ssh/github_deploy.pub (public)Add public key to your VPS:
ssh root@your-vps
# Create restricted deploy user:
useradd -m -s /bin/bash deploy
mkdir -p /home/deploy/.ssh
# Paste github_deploy.pub into /home/deploy/.ssh/authorized_keys
chmod 700 /home/deploy/.ssh
chmod 600 /home/deploy/.ssh/authorized_keys
chown -R deploy:deploy /home/deploy/.sshGrant specific sudo for service restart (no password):
# /etc/sudoers.d/deploy
deploy ALL=(root) NOPASSWD: /usr/bin/systemctl restart myapp, /usr/bin/systemctl status myappStep 2 — Add secrets to GitHub
Repo → Settings → Secrets and variables → Actions → New repository secret:
VPS_HOST= your vps IP or hostnameVPS_USER=deployVPS_SSH_KEY= contents of~/.ssh/github_deploy(private key)VPS_KNOWN_HOSTS= the output ofssh-keyscan -H your-vps-ip, run once from your own computer and checked against the server's fingerprint (ssh-keygen -lf /etc/ssh/ssh_host_ed25519_key.pubon the VPS)
Storing the host key as a secret means CI refuses to connect if the server's identity ever changes, instead of trusting whatever answers.
Step 3 — Workflow
name: Deploy to VPS
on:
push:
branches: [main]
jobs:
deploy:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v5
- name: Setup SSH
env:
SSH_KEY: ${{ secrets.VPS_SSH_KEY }}
KNOWN_HOSTS: ${{ secrets.VPS_KNOWN_HOSTS }}
run: |
mkdir -p ~/.ssh
printf '%s\n' "$SSH_KEY" > ~/.ssh/id_ed25519
chmod 600 ~/.ssh/id_ed25519
printf '%s\n' "$KNOWN_HOSTS" > ~/.ssh/known_hosts
- uses: actions/setup-node@v5
with: { node-version: 22 }
- name: Build
run: |
npm ci
npm run build
- name: Deploy
run: |
rsync -az --delete \
--exclude='.git' \
--exclude='node_modules' \
-e "ssh -i ~/.ssh/id_ed25519" \
./ ${{ secrets.VPS_USER }}@${{ secrets.VPS_HOST }}:/home/deploy/myapp/
- name: Install production deps on server + restart
run: |
ssh -i ~/.ssh/id_ed25519 ${{ secrets.VPS_USER }}@${{ secrets.VPS_HOST }} '
cd /home/deploy/myapp &&
npm ci --omit=dev &&
sudo systemctl restart myapp
'Pattern 2 — Docker deploy
Build image in CI, push to registry, pull on VPS.
name: Build & Deploy Docker
on:
push:
branches: [main]
jobs:
build:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v5
- name: Login to registry
uses: docker/login-action@v3
with:
registry: ghcr.io
username: ${{ github.repository_owner }}
password: ${{ secrets.GITHUB_TOKEN }}
- name: Build & push
uses: docker/build-push-action@v6
with:
context: .
push: true
tags: |
ghcr.io/yourorg/myapp:latest
ghcr.io/yourorg/myapp:${{ github.sha }}
cache-from: type=gha
cache-to: type=gha,mode=max
deploy:
needs: build
runs-on: ubuntu-latest
steps:
- name: Deploy via SSH
env:
SSH_KEY: ${{ secrets.VPS_SSH_KEY }}
KNOWN_HOSTS: ${{ secrets.VPS_KNOWN_HOSTS }}
run: |
mkdir -p ~/.ssh
printf '%s\n' "$SSH_KEY" > ~/.ssh/id_ed25519 && chmod 600 ~/.ssh/id_ed25519
printf '%s\n' "$KNOWN_HOSTS" > ~/.ssh/known_hosts
ssh -i ~/.ssh/id_ed25519 deploy@${{ secrets.VPS_HOST }} '
docker pull ghcr.io/yourorg/myapp:${{ github.sha }} &&
(docker stop myapp || true) &&
(docker rm myapp || true) &&
docker run -d --name myapp --restart=always -p 127.0.0.1:3000:3000 \
--env-file /home/deploy/.env \
ghcr.io/yourorg/myapp:${{ github.sha }}
'Notes: the deploy user must be in the docker group (which is effectively root access, so protect that key), a private image needs a one-time docker login ghcr.io on the VPS with a read-only token, and publishing on 127.0.0.1 keeps the container behind your reverse proxy instead of opening port 3000 to the internet. Deploying the commit-SHA tag rather than latest makes a rollback a matter of re-running with an older SHA.
Pattern 3 — PHP + Composer deploy
Run Composer in CI, never on the server you deploy to, and ship the finished vendor/ folder.
name: Deploy PHP
on:
push: { branches: [main] }
jobs:
deploy:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v5
- uses: shivammathur/setup-php@v2
with: { php-version: '8.3' }
- run: composer install --no-dev --optimize-autoloader
- name: Rsync to VPS
uses: burnett01/[email protected]
with:
switches: -avzr --delete --exclude=.env --exclude=storage/logs
path: ./
remote_path: /home/deploy/myapp/
remote_host: ${{ secrets.VPS_HOST }}
remote_user: deploy
remote_key: ${{ secrets.VPS_SSH_KEY }}
- name: Laravel post-deploy
uses: appleboy/ssh-action@v1
with:
host: ${{ secrets.VPS_HOST }}
username: deploy
key: ${{ secrets.VPS_SSH_KEY }}
script: |
cd /home/deploy/myapp
php artisan migrate --force
php artisan config:cache
php artisan route:cache
php artisan view:cache
sudo systemctl reload php-fpm # clears OPcache; allow this in sudoers tooPattern 4 — Test matrix
Run tests on multiple versions / environments:
jobs:
test:
strategy:
matrix:
php: ['8.3', '8.4', '8.5']
db: [mysql, pgsql]
runs-on: ubuntu-latest
services:
postgres:
image: postgres:17
env:
POSTGRES_PASSWORD: postgres
ports: ['5432:5432']
options: --health-cmd pg_isready --health-interval 5s --health-retries 10
mysql:
image: mysql:8.4
env:
MYSQL_ROOT_PASSWORD: root
MYSQL_DATABASE: test
ports: ['3306:3306']
options: --health-cmd "mysqladmin ping -proot" --health-interval 5s --health-retries 10
steps:
- uses: actions/checkout@v5
- uses: shivammathur/setup-php@v2
with: { php-version: '${{ matrix.php }}' }
- run: composer install --no-progress
- run: vendor/bin/phpunit
env:
DB_CONNECTION: ${{ matrix.db }}
DB_HOST: 127.0.0.1Pattern 5 — PR preview deployments
For static sites or lightweight apps — deploy each PR to pr-123.yourcompany.com. This needs a wildcard DNS record and a wildcard certificate (for example from Let's Encrypt with a DNS challenge) on the VPS. Only run previews for PRs from your own repository: workflows triggered by pull requests from forks don't get your secrets, and must never be given them.
on:
pull_request:
types: [opened, synchronize]
jobs:
preview:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v5
- run: npm ci && npm run build
- name: Deploy to subdomain
run: |
# upload to VPS under /var/www/previews/pr-${{ github.event.pull_request.number }}
# nginx wildcard: server_name ~^pr-(?<pr>\d+)\.yourcompany\.com$;
# root /var/www/previews/pr-$pr;
# (SSH key and known_hosts set up as in Pattern 1)
ssh deploy@vps "mkdir -p /var/www/previews/pr-${{ github.event.pull_request.number }}"
scp -r dist/* deploy@vps:/var/www/previews/pr-${{ github.event.pull_request.number }}/
- name: Comment on PR
uses: marocchino/sticky-pull-request-comment@v2
with:
message: "Preview: https://pr-${{ github.event.pull_request.number }}.yourcompany.com"Secrets management
Never commit secrets. Use:
- Repository secrets (
${{ secrets.NAME }}) - Environment secrets (scoped per environment like
production) - Organization secrets (shared across repos)
For rotation: change secret in GitHub → next workflow run picks it up. No config file to update.
Required reviews before deploy
Repo → Settings → Environments → Create "production":
- Required reviewers: 1+
- Wait timer: 0 (or add delay)
- Allowed branches: main
Workflow:
jobs:
deploy:
environment: production # requires approval before running
runs-on: ubuntu-latestGitHub pauses the deploy job until a reviewer clicks approve.
Rollback strategy
Tag every release:
- name: Create release
uses: softprops/action-gh-release@v2
if: startsWith(github.ref, 'refs/tags/')
with:
generate_release_notes: trueTo roll back, re-deploy the previous tag (for example v1.4.2): check it out, push it to a release branch and let the workflow deploy it, or keep a manual rollback.yml workflow (workflow_dispatch with a tag input) that re-deploys a specific tag.
Common pitfalls
on: pull_request.echo "::add-mask::$VALUE".--force or --no-input.Running this on Domain India
- VPS: everything in this guide applies. A Domain India VPS is self-managed with full root access, so you create the deploy user, the sudo rule and the firewall yourself. VPS plans include no backups or snapshots, so a rollback plan (tagged releases, database dumps) matters.
- Shared hosting (cPanel, DirectAdmin): SSH is a jailed shell that is off by default; ask support to enable it, and log in with a key. rsync is not available in the jailed shell, and Composer isn't pre-installed. Build everything (including
vendor/) in CI, upload one archive withscp, and unpack it over SSH. See GitHub Actions deploys to cPanel. - App Platform: there is no automatic deploy on every push. Trigger a deploy from your workflow with a deploy token; the exact command is in your app's Deploy Tokens tab in the client area. See the App Platform guide.
FAQ
GitHub Actions vs GitLab CI vs Jenkins?
Actions is the easiest choice for repositories on GitHub, GitLab CI for repositories on GitLab, and Jenkins suits teams that want a self-hosted, highly customisable server and can maintain it. If your code is on GitHub, Actions is usually the simplest option.
How much does it cost?
Standard GitHub-hosted runners are free for public repositories. Private repositories get a monthly allowance of free minutes that depends on your GitHub plan, then per-minute billing. Check GitHub's billing page for current allowances and rates; most small teams stay within the free allowance.
Self-hosted runner?
Useful if you need to deploy into a private network or use specific hardware. Install the runner on a VPS and register it to your repository. Never use a self-hosted runner for a public repository, because anyone who opens a pull request could run code on it.
Deployment of multiple services?
Use path filters: paths: ['services/frontend/**'] triggers only when frontend changes. Keep monorepo workflows efficient.
Deploy to k3s/Kubernetes?
Run kubectl apply -f k8s/ after building the image. Install kubectl with the azure/setup-kubectl action and store the kubeconfig as a secret, scoped to a service account that can only deploy.
Ready to automate your deploys? Compare VPS plans (from ₹553 a month, excluding 18% GST), or open a support ticket to ask for jailed SSH on a shared hosting account.
A self-managed Domain India VPS gives you full root access for deploy users, systemd services and Docker.
See VPS plans