Skip to content
A AhsanLab.Tech
Deployment & CI/CD 13 min read · July 11, 2026

GitHub Actions to a VPS: A Pipeline That Doesn't Need a Cloud Platform

You do not need a cloud platform for push-to-deploy. You need a runner that can build, a server that can receive, and a key that can do exactly one thing — and most of the work is making sure that key cannot do anything else.

A Ahsan Habib Save

You do not need a cloud platform to get push-to-deploy. You need a runner that can build, a server that can receive, and a key that can do exactly one thing. The whole pipeline is about sixty lines of YAML, and most of the work is in making sure that key cannot do anything else.

This is the pipeline I run on every VPS project — build, test, package, ship, swap, verify — and the security decisions inside it, which are the part worth arguing about.

FIVE STAGES, ONE GATE BETWEEN EACH BUILD composer · npm cached TEST red here means nothing ships PACKAGE release.tar.gz no .env inside SHIP scp over one key that runs one command SWAP + VERIFY symlink, reload, health check HEALTH CHECK FAILS → AUTOMATIC ROLLBACK symlink back, reload, exit non-zero, job goes red the runner holds ONE secret: a deploy key a leaked workflow log must not be a leaked database the .env stays on the server, always · the artifact is identical for staging and production
FIGURE 1 — THE PIPELINE · Every arrow is a gate. The interesting design work is in the box at the bottom left.

The server side

A deploy user that can deploy and nothing else

The default advice is "add your SSH key and run a script". That gives your CI runner a shell on your production server, which means a compromised Action, a malicious dependency in your build, or a leaked secret is full server access. It is worth twenty minutes to make that key much less interesting to steal.

  1. A dedicated user that owns only the app

    deploy, no password, home directory outside the web root. It owns /var/www/app and nothing else on the box.

  2. A key restricted to one command

    The command= option in authorized_keys means the key can only ever run the deploy script, whatever the client asks for. This is the single highest-value line in the whole setup.

  3. Narrow sudo, spelled out

    The deploy needs to reload PHP-FPM and restart the worker. That is two entries in a sudoers file with NOPASSWD — not membership of sudo.

  4. The deploy script validates its own input

    It receives an uploaded tarball at a known path. It does not take arguments from the caller, because the caller is the thing you are defending against.

/home/deploy/.ssh/authorized_keys
# This key can do exactly one thing. `ssh deploy@host anything-at-all` runs
# deploy.sh regardless — the requested command is ignored entirely.
command="/usr/local/bin/deploy.sh",no-agent-forwarding,no-port-forwarding,no-pty,no-X11-forwarding ssh-ed25519 AAAAC3Nza... github-actions@app
/etc/sudoers.d/deploy — two lines, not group membership
deploy ALL=(root) NOPASSWD: /bin/systemctl reload php8.2-fpm
deploy ALL=(root) NOPASSWD: /bin/systemctl restart app-worker
Why no-pty matters Without it, a forced command still allows an interactive terminal allocation, and several escapes rely on that. With no-pty, no-port-forwarding and a forced command, a stolen deploy key buys an attacker one thing: the ability to trigger a deploy of code that is already in your repository. That is a genuinely boring prize, which is the goal.

The workflow

The pipeline, end to end

.github/workflows/deploy.yml
name: Deploy
on:
  push:
    branches: [main]

concurrency:
  group: production
  cancel-in-progress: false      # never abandon a half-finished deploy

jobs:
  deploy:
    runs-on: ubuntu-latest
    environment: production      # gives you required reviewers if you want them
    steps:
      - uses: actions/checkout@v4

      - uses: shivammathur/setup-php@v2
        with: { php-version: '8.2', extensions: mbstring, intl, pdo_mysql }
      - uses: actions/setup-node@v4
        with: { node-version: 20, cache: npm }

      - name: Cache composer
        uses: actions/cache@v4
        with:
          path: ~/.cache/composer
          key: composer-${{ hashFiles('composer.lock') }}

      - run: composer install --no-dev --optimize-autoloader --prefer-dist
      - run: npm ci && npm run build
      - run: php artisan test

      - name: Package
        run: |
          tar -czf release.tar.gz \
            --exclude='.git' --exclude='node_modules' --exclude='tests' \
            --exclude='.env' --exclude='storage/app' \
            .

      - name: Load the deploy key
        run: |
          install -m 700 -d ~/.ssh
          echo "${{ secrets.DEPLOY_KEY }}" > ~/.ssh/id_ed25519
          chmod 600 ~/.ssh/id_ed25519
          # Pin the host key. Without this you are trusting DNS.
          echo "${{ secrets.KNOWN_HOSTS }}" > ~/.ssh/known_hosts

      - name: Ship
        run: scp -i ~/.ssh/id_ed25519 release.tar.gz deploy@${{ secrets.HOST }}:/tmp/release.tar.gz

      - name: Deploy and verify
        run: ssh -i ~/.ssh/id_ed25519 deploy@${{ secrets.HOST }}
        # No command needed — the key is forced to deploy.sh, which swaps the
        # symlink, reloads FPM, health-checks and rolls back on failure.

Details

Four things that are easy to get wrong

Common

StrictHostKeyChecking=no

Every tutorial does this. It means your pipeline will happily hand its key to whatever answers on that IP, which is the entire attack DNS hijacking exists for.

Correct

Pin the host key in a secret.

ssh-keyscan your.host once, paste the output into KNOWN_HOSTS. It takes a minute and closes the hole permanently.

  • cancel-in-progress: false. The default cancels a running job when a new commit lands — mid-upload, mid-symlink-swap. Deploys are the one workflow you never want interrupted.
  • Cache ~/.cache/composer, not vendor/. Caching the output means a stale vendor can survive a lock change; caching the download cache keeps composer install authoritative and still fast.
  • Exclude .env from the tarball explicitly, even though it should not be in the repo. Defence in depth costs one line.
  • Use an environment:. It scopes the secrets to production and gives you optional required reviewers, which turns a push into a deploy request when you want that.

Honesty

What this is not

  • Not a build cache across runs beyond dependencies. Every deploy recompiles assets. On a large frontend that is minutes; a Turborepo or Vite cache keyed on source hash is the next step.
  • Not multi-server. One host in a secret. Several means a matrix and a decision about whether a partial failure is a rollback everywhere.
  • Not a canary. Everyone moves at once, so the health check is doing all the work. Make it hit a real endpoint that touches the database, not a static ok.
  • Not zero-downtime by itself. That comes from the symlink layout on the server; the pipeline just delivers the tarball.
  • Not free of GitHub. Fewer moving parts than a cloud platform, but if Actions is down you deploy by hand — worth having the manual path written down.

The checklist

  • A dedicated deploy user owning only the application directory.
  • A forced-command SSH key with no-pty and no forwarding.
  • Two explicit sudoers lines, never group membership.
  • Pinned host key — never StrictHostKeyChecking=no.
  • Tests before packaging, in the same job.
  • .env and uploads excluded from the artifact.
  • Concurrency group with cancel-in-progress: false.
  • Cache the dependency cache, not the installed output.
  • The server script health-checks and rolls back itself, so a red job means a live site.
  • A written manual deploy path for when the pipeline is unavailable.

Where to start on Monday

Add command="/usr/local/bin/deploy.sh" to your existing deploy key. If you already have a pipeline, that one edit turns a shell credential into a single-purpose one, and it takes about a minute.

  • forced command
  • known_hosts pinning
  • concurrency group
  • artifact packaging
  • narrow sudo
  • composer cache
  • health check
  • auto rollback

The server-side script this triggers is the symlink swap, the box should be hardened before any of this, and if you are on shared hosting instead, the FTP and cron version is the equivalent.

#CI/CD #GitHub Actions #Docker #VPS #Laravel

Comments (0)

No comments yet

Be the first to share a thought on this article.

Join the conversation

Comments are moderated before they appear.

Keep reading

Related articles