Guider og veiledninger

CI/CD: deploy til VPS-en din med GitHub Actions

Publisert 15. oktober 2024 · 13 min lesing

Denne artikkelen er tilgjengelig på fransk og engelsk.

The strategy

Two ways to deploy from GitHub Actions to your VPS: a GitHub-hosted runner pushing over SSH (covered here, the most universal), or a self-hosted runner installed on the VPS (discussed at the end). Either way, the goal is zero-downtime deployment through symlinked releases.

Prerequisites

Your application already runs once on the VPS (see our guide "Deploy a Node.js application in production"), served by a non-root deploy user. The repository is on GitHub.

Step 1 — A dedicated deployment SSH key

On your workstation, generate a key pair used only by CI:

ssh-keygen -t ed25519 -f ~/.ssh/github-deploy -C "github-actions"

Add the public key on the VPS:

cat ~/.ssh/github-deploy.pub | ssh deploy@VPS-IP "cat >> ~/.ssh/authorized_keys"

In the GitHub repository: Settings → Secrets and variables → Actions → New repository secret. Create VPS_HOST (the IP), VPS_USER (deploy), VPS_PORT (22 or your custom port) and VPS_SSH_KEY (the full private key, including the BEGIN/END lines).

Step 2 — The zero-downtime layout on the VPS

sudo mkdir -p /var/www/myapp/releases /var/www/myapp/shared
sudo chown -R deploy:deploy /var/www/myapp

Each deployment creates a timestamped folder inside releases/. The shared/ folder holds what survives across versions: the .env, uploaded files. A current symlink points to the live release — Nginx or PM2 never see an interruption. Move your .env into shared/ right now.

Step 3 — The deployment script

Create /home/deploy/deploy.sh on the VPS:

#!/usr/bin/env bash
set -euo pipefail

APP_DIR=/var/www/myapp
RELEASE=$(date +%Y%m%d%H%M%S)

mkdir -p "$APP_DIR/releases/$RELEASE"
tar -xzf /tmp/release.tar.gz -C "$APP_DIR/releases/$RELEASE"

ln -sfn "$APP_DIR/shared/.env" "$APP_DIR/releases/$RELEASE/.env"

cd "$APP_DIR/releases/$RELEASE"
npm ci --omit=dev
npm run build

ln -sfn "$APP_DIR/releases/$RELEASE" "$APP_DIR/current"
pm2 reload myapp

cd "$APP_DIR/releases"
ls -1dt */ | tail -n +6 | xargs -r rm -rf

The switch is atomic: ln -sfn replaces the symlink in a single operation. The last five releases are kept, older ones removed. Make the script executable: chmod +x /home/deploy/deploy.sh.

Step 4 — The GitHub Actions workflow

Create .github/workflows/deploy.yml in your repository:

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

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

      - name: Build
        run: |
          npm ci
          npm run build
          tar -czf release.tar.gz dist package.json package-lock.json

      - name: Upload to VPS
        uses: appleboy/[email protected]
        with:
          host: ${{ secrets.VPS_HOST }}
          username: ${{ secrets.VPS_USER }}
          key: ${{ secrets.VPS_SSH_KEY }}
          port: ${{ secrets.VPS_PORT }}
          source: release.tar.gz
          target: /tmp/

      - name: Deploy
        uses: appleboy/[email protected]
        with:
          host: ${{ secrets.VPS_HOST }}
          username: ${{ secrets.VPS_USER }}
          key: ${{ secrets.VPS_SSH_KEY }}
          port: ${{ secrets.VPS_PORT }}
          script: /home/deploy/deploy.sh

Every push to main builds the archive, transfers it and runs the script. On the first deployment, watch the Actions tab — every step is logged.

Step 5 — Rollback in ten seconds

The symlink makes rollback trivial — list the releases and point back:

ls /var/www/myapp/releases
ln -sfn /var/www/myapp/releases/20241015120000 /var/www/myapp/current
pm2 reload myapp

Alternative — self-hosted runner

Install the runner on the VPS (Settings → Actions → Runners → New self-hosted runner) and the workflow becomes a plain git pull && npm ci && pm2 reload executed locally: faster, no network transfer. Keep this option for private repositories where you are the only contributor — a self-hosted runner on a public repo can execute arbitrary third-party code on your server.

Security

  • The deployment key is used nowhere else; revoking it means deleting one line from authorized_keys.
  • The .env lives in shared/, never in the archive or the repository.
  • Watch the workflow logs: GitHub secrets are masked, but a misplaced set -x can leak paths.

Troubleshooting

  • Permission denied (publickey): the VPS_SSH_KEY secret must hold the complete private key. Test the key manually: ssh -i ~/.ssh/github-deploy deploy@VPS-IP.
  • `pm2: command not found` inside the script: the PATH of a non-interactive SSH session is minimal — use the full path $(which pm2) or export PATH at the top of the script.
  • The site still serves the old version: check ls -l /var/www/myapp/current and reload the application, not just Nginx.

Going further

The same release pattern works for any stack: swap npm ci for composer install or pip install -r requirements.txt, and pm2 reload for systemctl restart myapp. Add a workflow_dispatch entry under on: in the workflow to enable manual deployments from the Actions tab without pushing a commit — handy for redeploying after rotating a secret or patching the server.

Steg-for-steg-veiledninger skrevet av ingeniørene våre, testet på infrastrukturen vår.

GLOBALCLOUDHOSTING →