Most CI/CD tutorials assume you're deploying to Kubernetes, a PaaS, or at least a Docker host. But a huge number of real-world apps run the boring, reliable way: a frontend built to static files, a backend running as a systemd service, and Nginx in front of both — all on a single Ubuntu VPS.
If that's your setup, you don't need to containerize anything to get proper automated deployments. In this guide I'll show you how to build a complete pipeline where every git push automatically builds, deploys, and restarts your app on your VPS, with database migrations handled safely along the way.
By the end you'll have a setup where your whole team deploys just by pushing code — no SSH-ing into servers, no manual scp, no "it worked on my machine."
The architecture
Here's the whole thing at a glance:
The key insight: the build happens on GitHub's servers, not your VPS. Your server only receives finished artifacts and restarts a service. Nothing gets compiled in production, and your VPS stays clean.
Why no Docker? If your app already runs fine as a systemd service behind Nginx, adding Docker just introduces a container runtime, image registry, and a new layer of networking to debug — for zero benefit. This pipeline meets your server where it is.
What you'll need
- A GitHub repository (public or private).
- An Ubuntu VPS you can SSH into, with Nginx installed and a user that has
sudo. - Your app already running the "normal" way: frontend served by Nginx from a folder, and backend running as a systemd service that Nginx reverse-proxies to.
Throughout, I'll use these placeholders — swap in your own:
| PlaceholderMeaning | |
deploy | SSH user on the VPS (has sudo) |
your-vps.example.com | VPS IP or hostname |
myapp.service | systemd service name |
/var/www/myapp/api | backend app folder |
/var/www/myapp/web | frontend files folder (Nginx root) |
5000 | port your backend listens on |
Step 0: how the app runs on the VPS
So the automation makes sense, here's the manual setup it's replacing. If you already have this, skip ahead.
A systemd service for the backend — /etc/systemd/system/myapp.service:
Golden rule: keep configuration and secrets in an EnvironmentFile outside the deploy directory. That way deployments replace code only and never touch your production secrets.Nginx — serving the frontend and proxying /api to the backend:
Step 1: Create an SSH deploy key
GitHub Actions needs to log into your VPS. Never reuse your personal SSH key — generate a dedicated key pair just for deployments, on your local machine:
This creates two files: deploy_key (the private key, for GitHub Secrets) and deploy_key.pub (the public key, for the VPS). Print the public key:
Step 2: Authorize the key and lock down sudo
SSH into your VPS and run the following. It authorizes the deploy key, ensures rsync is installed, and grants the deploy user passwordless sudo for only the exact commands the pipeline needs — nothing more.
Why the narrow rule? Deploys need root to write into /var/www and restart the service. Instead of blanket root, we whitelist those two commands — so if the key ever leaks, the blast radius is tiny.
A note onrsync+ sudo: granting NOPASSWD onrsyncis effectively root-capable (rsync can write anywhere). That's an accepted trade-off for auto-deploy. For tighter security, make the deploy user own the target directories and dropsudofrom rsync entirely.
Step 3: Add secrets to GitHub
In your repo, go to Settings → Secrets and variables → Actions and add four repository secrets:
| SecretValue | |
VPS_HOST | your-vps.example.com |
VPS_USER | deploy |
VPS_SSH_PORT | 22 |
VPS_SSH_KEY | the entire contents of the private deploy_key file |
Prefer the command line? The GitHub CLI makes it a one-liner each:
Setting Actions secrets requires admin permission on the repository.
Once the key is safely in GitHub Secrets and authorized on the VPS, delete your local copies:
Step 4: Write the workflow
Create .github/workflows/deploy.yml. This is the heart of the pipeline — shown for a React/Vite frontend + a compiled backend (e.g. .NET):
Adapting it to your stack
- Node/Express backend? Replace the .NET steps with your build (or none) and upload your server folder; systemd runs
node server.js. - Python/Django/Flask? Skip "Setup .NET", upload your source, and run Gunicorn/uvicorn from systemd.
- Frontend-only? Delete the backend and restart steps — just build and rsync the static files.
Two details that save you from pain
1. --delete cleans up, but be careful. It removes files on the server that no longer exist in your build — great for stale assets. But if your app writes files inside its own folder at runtime (uploads, generated reports, cache), --delete will wipe them. Always --exclude those directories. Config lives in the external EnvironmentFile, so that's already safe.
2. ssh-keyscan prevents a hang. Without adding the host to known_hosts, SSH interactively asks "Are you sure you want to continue connecting?" — and hangs forever on a CI runner. ssh-keyscan pre-trusts the host.
Step 5: Handle database migrations
If your app has a database, pick based on your risk tolerance:
Option A — App migrates on startup. Simplest: your app applies pending migrations when it boots, so a plain systemctl restart does the job. Convenient, but risky for production where you want control.
Option B — Dedicated migration command (recommended). Run migrations as a separate step before the restart, using the service's own environment via systemd-run:
--wait makes the step fail if the migration fails, so a bad migration stops the deploy loudly. (Add /usr/bin/systemd-run to your sudoers rule if you use this.)
Option C — Manual migrations. Leave migrations out of the pipeline and run them by hand for releases that need schema changes. Safest for sensitive production data.
For anything non-trivial — especially multi-tenant apps — I recommend Option B on staging and Option C (or a very deliberate B) on production.
Step 6: Ship it and watch
Commit the workflow and push:
Open the Actions tab and watch the run live: build → build → upload → upload → restart. A green check means you're deployed. Verify from anywhere:
From now on, deploying is just git push. Anyone with push access can ship — no server credentials required, because the SSH key lives only in GitHub Secrets and is used only by GitHub's runners.
Staging first, then production
Don't point your first pipeline at production. Set it up against a staging environment (a second service + Nginx server block on the same VPS), prove it across a few real deploys, then clone the workflow for production. The production workflow is the same file with a handful of values changed: trigger branch, service name, folder paths, and the environment file used for migrations. Because the deploy-key and secrets pattern are identical, standing up production takes minutes.
Security checklist
- Secrets never live in the repo — only in GitHub Secrets, injected at runtime.
- Dedicated deploy key, not your personal one. Rotate it if it leaks.
- Narrow sudoers — the deploy user can only run the exact deploy commands.
- Config in an external
EnvironmentFile— deploys replace code, never secrets. - Exclude runtime data dirs so
--deletecan't destroy user data. concurrencyprevents overlapping deploys from corrupting a release.- Keep HTTPS on via Certbot; it renews itself and the pipeline never touches it.
Troubleshooting
| SymptomLikely cause / fix | |
| Job hangs on the SSH step | Missing ssh-keyscan, or wrong host/port. |
| Permission denied (publickey) | Public key not in the VPS authorized_keys, or wrong VPS_SSH_KEY. |
| sudo: a password is required | Your sudoers rule doesn't match the exact command being run. |
| Site 502 after deploy | Service didn't restart — check systemctl status and journalctl -u myapp. |
| Old assets still showing | Frontend rsync missing --delete, or a cache. |
| Runtime files disappeared | You forgot to --exclude a folder the app writes to. |
Wrapping up
You don't need Kubernetes or Docker to have a professional deployment pipeline. With GitHub Actions doing the heavy lifting in the cloud and a plain Ubuntu VPS running your app as a systemd service behind Nginx, you get push-to-deploy for the whole team, repeatable, logged deployments, safe database migrations, and no servers to babysit during a release.
Set it up once against staging, roll it to production, and never scp a build folder again. Happy shipping.