DevOps & CI/CD Pipelines

GitHub Actions CI/CD to Domain India VPS — Build, Test, Deploy Automation

By Domain India Team · DomainIndia EngineeringPublished 10 min read
Knowledge base article
Contents (16 sections)

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.

Key takeaways

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:

yaml
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:

bash
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:

bash
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/.ssh

Grant specific sudo for service restart (no password):

bash
# /etc/sudoers.d/deploy
deploy ALL=(root) NOPASSWD: /usr/bin/systemctl restart myapp, /usr/bin/systemctl status myapp

Step 2 — Add secrets to GitHub

Repo → Settings → Secrets and variables → Actions → New repository secret:

  • VPS_HOST = your vps IP or hostname
  • VPS_USER = deploy
  • VPS_SSH_KEY = contents of ~/.ssh/github_deploy (private key)
  • VPS_KNOWN_HOSTS = the output of ssh-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.pub on 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

yaml
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.

yaml
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.

yaml
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 too

Pattern 4 — Test matrix

Run tests on multiple versions / environments:

yaml
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.1

Pattern 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.

yaml
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:

  1. Repository secrets (${{ secrets.NAME }})
  2. Environment secrets (scoped per environment like production)
  3. 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:

yaml
jobs:
  deploy:
    environment: production   # requires approval before running
    runs-on: ubuntu-latest

GitHub pauses the deploy job until a reviewer clicks approve.

Rollback strategy

Tag every release:

yaml
- name: Create release
  uses: softprops/action-gh-release@v2
  if: startsWith(github.ref, 'refs/tags/')
  with:
    generate_release_notes: true

To 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

Tests only in the deploy workflow
Tests should run on every pull request. Add a test workflow with on: pull_request.
Values derived from secrets
GitHub masks registered secrets in logs, but not values built from them (a decoded key, a URL with a password). Mask those with echo "::add-mask::$VALUE".
Interactive migrations
Laravel and Django migrations can prompt and hang CI. Use --force or --no-input.
Building twice
Building separately on each server can give different results. Build the artifact once and deploy the same artifact everywhere.
No deploy gate
A push to main goes straight to production. Add environment approval for production.
rsync --delete on a live folder
Files disappear mid-request during the copy. Use atomic deploys (a new release folder plus a symlink switch) or blue-green.

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 with scp, 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.

Deploy to a server you control

A self-managed Domain India VPS gives you full root access for deploy users, systemd services and Docker.

See VPS plans

Was this article helpful?

Your answer helps us decide what to improve next.

Still need help? Open a support ticket and our team will reply.

Prefer an app? Add this site to your home screen.Get the app