DEV Community

Urvin Sanghavi
Urvin Sanghavi

Posted on

Automatically Deploy Your App to a VPS with GitHub Actions

If your deploy process is "SSH into the server, cd to the app, git pull, restart something, hope" — this post replaces it with git push.

What you'll have by the end: every push to your main branch updates the app on your server automatically. You can also trigger a deploy by hand from the GitHub UI.

This is the simple version — it has a few seconds of downtime during the restart and it builds on the server. Post 6 in this series upgrades it to a zero-downtime setup. Start here; the simple version is enough for a long time.

What is "deploying with GitHub Actions"?

GitHub Actions is a task runner built into every GitHub repo. You describe a job in a YAML file, and GitHub runs it on a fresh virtual machine when something happens — in our case, "when code lands on main."

The job we want is tiny: connect to our server over SSH and run the same commands we'd run by hand. The value isn't magic — it's that the steps are written down, always run in the same order, and don't depend on you being awake.

Prerequisites

  • An app in a GitHub repo.
  • A VPS (any provider) you can SSH into, with your app already cloned and running once — e.g. in /srv/snip, served by nginx, run by a process manager. If you don't have that yet, set it up manually first; this post automates the update, not the first install.
  • A dedicated Linux user for deploys (don't use root). We'll call it deploy.

Step 1: Make an SSH key just for deploys

On your local machine, generate a key pair that only GitHub Actions will use:

ssh-keygen -t ed25519 -f ~/.ssh/snip_deploy -N "" -C "github-actions-deploy"
Enter fullscreen mode Exit fullscreen mode

This creates ~/.ssh/snip_deploy (private) and ~/.ssh/snip_deploy.pub (public).

Add the public key to your server so the deploy user accepts it:

ssh-copy-id -i ~/.ssh/snip_deploy.pub deploy@YOUR_SERVER_IP
# or manually: append the .pub line to /home/deploy/.ssh/authorized_keys on the server
Enter fullscreen mode Exit fullscreen mode

Test it:

ssh -i ~/.ssh/snip_deploy deploy@YOUR_SERVER_IP "echo connected"
Enter fullscreen mode Exit fullscreen mode

Step 2: Give the deploy user permission to restart the app

The deploy needs to restart your app process without a password prompt. If you run the app as a systemd service (snip.service), allow exactly that one command:

# On the server, as root:
echo 'deploy ALL=(root) NOPASSWD: /usr/bin/systemctl restart snip, /usr/bin/systemctl reload nginx' \
  | sudo tee /etc/sudoers.d/snip-deploy
Enter fullscreen mode Exit fullscreen mode

Adjust for your stack: if you use PM2, supervisor, Docker Compose, etc., substitute the restart command you actually run.

Step 3: Store the secrets in GitHub

In your repo: Settings → Secrets and variables → Actions → New repository secret. Add three:

Name Value
VPS_HOST your server's IP or hostname
VPS_USER deploy
VPS_SSH_KEY the entire contents of ~/.ssh/snip_deploy (the private key, including the BEGIN/END lines)

Secrets are encrypted and never printed in logs.

Step 4: The workflow file

Create .github/workflows/deploy.yml:

name: Deploy

on:
  push:
    branches: [main]
  workflow_dispatch:        # adds a "Run workflow" button in the Actions tab

# Never run two deploys at once.
concurrency:
  group: deploy-main
  cancel-in-progress: false

jobs:
  deploy:
    name: Deploy to VPS
    runs-on: ubuntu-latest
    steps:
      - name: Deploy over SSH
        uses: appleboy/ssh-action@v1.2.2
        with:
          host: ${{ secrets.VPS_HOST }}
          username: ${{ secrets.VPS_USER }}
          key: ${{ secrets.VPS_SSH_KEY }}
          script: |
            set -euo pipefail
            cd /srv/snip

            # Get the exact code that's on origin/main.
            git fetch origin main
            git reset --hard origin/main

            # Install dependencies and build.
            # Adjust for your stack:
            npm ci
            npm run build

            # Apply database migrations.
            # Adjust for your stack:
            npm run migrate

            # Restart the app.
            sudo systemctl restart snip
Enter fullscreen mode Exit fullscreen mode

Commit and push it to main.

What each part does

  • on.push.branches: [main] — run only when commits land on main. Pull requests don't trigger it. (Post 2 adds a test job that does run on pull requests.)
  • concurrency — if you push twice quickly, the second deploy waits for the first to finish instead of racing it.
  • git reset --hard origin/main — not git pull. This guarantees the server's code is exactly origin/main, even if something on the server got modified. Nothing should ever be edited directly on the server.
  • set -euo pipefail — stop on the first error instead of charging ahead. If npm run build fails, the deploy stops before the restart, and your old version keeps running.

Step 5: Watch it run

Push any commit to main, then open the repo's Actions tab. You'll see the workflow run live. Click it to see the SSH output.

To deploy without a new commit (e.g. to retry): Actions → Deploy → Run workflow.

Common gotchas

  • Permission denied (publickey) — the private key in VPS_SSH_KEY doesn't match the public key in authorized_keys, or you pasted only part of it. Re-copy the whole file.
  • could not read Username for 'https://github.com' — your server's clone uses an HTTPS remote, which can't authenticate non-interactively. Switch it to SSH with a deploy key, or make the repo public, or add a step that sets a token.
  • npm: command not found — the non-interactive SSH session has a minimal PATH. Use full paths, or add source ~/.profile / source ~/.nvm/nvm.sh at the top of the script.
  • Build gets killed on a small servernpm run build can run out of memory on a 1 GB VPS. Add swap, or build in GitHub Actions and copy the result over — that's covered in post 2 and post 6.
  • The site is broken for a few seconds on every deploy — expected with this approach. Post 6 fixes it.

What's next

Right now a commit that breaks the app will still deploy — the workflow doesn't know the difference between working code and broken code. Next: run your test suite in CI and only deploy if it passes.

Top comments (0)