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" 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 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 server — npm 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.
This is a summary aggregated from Dev.to. Read the complete article on the original site:
Read full article at Dev.to