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 itdeploy.
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"
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
Test it:
ssh -i ~/.ssh/snip_deploy deploy@YOUR_SERVER_IP "echo connected"
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
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
Commit and push it to main.
What each part does
-
on.push.branches: [main]— run only when commits land onmain. 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— notgit pull. This guarantees the server's code is exactlyorigin/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. Ifnpm run buildfails, 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 inVPS_SSH_KEYdoesn't match the public key inauthorized_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 minimalPATH. Use full paths, or addsource ~/.profile/source ~/.nvm/nvm.shat the top of the script. -
Build gets killed on a small server —
npm run buildcan 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)