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:

Your PC ──git push──► GitHub ──► GitHub Actions (cloud runner)
│ 1. Build frontend (npm run build)
│ 2. Build backend (compile/publish)
│ 3. rsync both to the VPS over SSH
│ 4. Run DB migrations
│ 5. Restart the backend service
Ubuntu VPS: Nginx ──► static files (frontend)
└──► reverse proxy ──► backend service

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

  1. A GitHub repository (public or private).
  2. An Ubuntu VPS you can SSH into, with Nginx installed and a user that has sudo.
  3. 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
deploySSH user on the VPS (has sudo)
your-vps.example.comVPS IP or hostname
myapp.servicesystemd service name
/var/www/myapp/apibackend app folder
/var/www/myapp/webfrontend files folder (Nginx root)
5000port 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:

[Unit]
Description=My App API
After=network.target

[Service]
WorkingDirectory=/var/www/myapp/api
ExecStart=/usr/bin/dotnet /var/www/myapp/api/MyApp.dll # or: /usr/bin/node server.js
Restart=always
RestartSec=10
User=appuser
EnvironmentFile=/etc/myapp/app.env # secrets live OUTSIDE the deploy folder

[Install]
WantedBy=multi-user.target
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:

server {
listen 443 ssl;
server_name app.example.com;
# SSL managed by Certbot...

root /var/www/myapp/web;
index index.html;

location / {
try_files $uri $uri/ /index.html; # SPA fallback
}

location /api/ {
proxy_pass http://127.0.0.1:5000;
proxy_set_header Host $host;
proxy_set_header X-Real-IP $remote_addr;
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
proxy_set_header X-Forwarded-Proto $scheme;
}
}

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:

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

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:

cat ./deploy_key.pub

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.

# 1) Authorize the deploy key
mkdir -p ~/.ssh && chmod 700 ~/.ssh
touch ~/.ssh/authorized_keys && chmod 600 ~/.ssh/authorized_keys
echo "PASTE_YOUR_PUBLIC_KEY_HERE" >> ~/.ssh/authorized_keys

# 2) Make sure rsync exists
command -v rsync >/dev/null || { sudo apt-get update && sudo apt-get install -y rsync; }

# 3) Narrow sudoers rule — sync files & restart the one service, no password
sudo tee /etc/sudoers.d/app-deploy >/dev/null <<'EOF'
deploy ALL=(root) NOPASSWD: /usr/bin/rsync, /usr/bin/systemctl restart myapp.service
EOF
sudo chmod 440 /etc/sudoers.d/app-deploy

# 4) Validate (must print "parsed OK")
sudo visudo -cf /etc/sudoers.d/app-deploy

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 on rsync + sudo: granting NOPASSWD on rsync is 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 drop sudo from 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_HOSTyour-vps.example.com
VPS_USERdeploy
VPS_SSH_PORT22
VPS_SSH_KEYthe entire contents of the private deploy_key file

Prefer the command line? The GitHub CLI makes it a one-liner each:

gh secret set VPS_HOST --repo OWNER/REPO --body "your-vps.example.com"
gh secret set VPS_USER --repo OWNER/REPO --body "deploy"
gh secret set VPS_SSH_PORT --repo OWNER/REPO --body "22"
gh secret set VPS_SSH_KEY --repo OWNER/REPO < ./deploy_key
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:

rm deploy_key deploy_key.pub

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):

name: Deploy

on:
push:
branches: [ main ] # deploy on every push to main
workflow_dispatch: # ...and allow manual runs

concurrency:
group: deploy-main
cancel-in-progress: false # never let two deploys overlap

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

# ---------- Build frontend ----------
- name: Setup Node
uses: actions/setup-node@v4
with:
node-version: '20'
cache: 'npm'
cache-dependency-path: web/package-lock.json
- name: Build frontend
working-directory: web
run: |
npm ci
npm run build # outputs to web/dist

# ---------- Build backend ----------
- name: Setup .NET
uses: actions/setup-dotnet@v4
with:
dotnet-version: '8.0.x'
- name: Publish backend
run: dotnet publish src/MyApp/MyApp.csproj -c Release -o publish_output

# ---------- Deploy over SSH ----------
- name: Prepare SSH key
run: |
mkdir -p ~/.ssh
echo "${{ secrets.VPS_SSH_KEY }}" > ~/.ssh/deploy_key
chmod 600 ~/.ssh/deploy_key
ssh-keyscan -p "${{ secrets.VPS_SSH_PORT }}" "${{ secrets.VPS_HOST }}" >> ~/.ssh/known_hosts 2>/dev/null

- name: Upload frontend
run: |
rsync -az --delete
-e "ssh -p ${{ secrets.VPS_SSH_PORT }} -i ~/.ssh/deploy_key"
--rsync-path="sudo rsync"
web/dist/ ${{ secrets.VPS_USER }}@${{ secrets.VPS_HOST }}:/var/www/myapp/web/

- name: Upload backend (preserve runtime data)
run: |
rsync -az --delete --exclude 'App_Data'
-e "ssh -p ${{ secrets.VPS_SSH_PORT }} -i ~/.ssh/deploy_key"
--rsync-path="sudo rsync"
publish_output/ ${{ secrets.VPS_USER }}@${{ secrets.VPS_HOST }}:/var/www/myapp/api/

- name: Restart service
run: |
ssh -p ${{ secrets.VPS_SSH_PORT }} -i ~/.ssh/deploy_key
${{ secrets.VPS_USER }}@${{ secrets.VPS_HOST }}
"sudo systemctl restart myapp.service"

Adapting it to your stack

  1. Node/Express backend? Replace the .NET steps with your build (or none) and upload your server folder; systemd runs node server.js.
  2. Python/Django/Flask? Skip "Setup .NET", upload your source, and run Gunicorn/uvicorn from systemd.
  3. 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:

- name: Apply database migrations
run: |
ssh -p ${{ secrets.VPS_SSH_PORT }} -i ~/.ssh/deploy_key
${{ secrets.VPS_USER }}@${{ secrets.VPS_HOST }}
"sudo systemd-run --wait --collect --pipe
--uid=appuser --gid=appuser
--property=EnvironmentFile=/etc/myapp/app.env
--property=WorkingDirectory=/var/www/myapp/api
/usr/bin/dotnet /var/www/myapp/api/MyApp.dll --migrate"

--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:

git add .github/workflows/deploy.yml
git commit -m "Add CI/CD pipeline"
git push origin main

Open the Actions tab and watch the run live: build → build → upload → upload → restart. A green check means you're deployed. Verify from anywhere:

# Frontend should return 200
curl -o /dev/null -w "%{http_code}
" https://app.example.com/

# Backend alive if it's anything but 502/503/504
curl -o /dev/null -w "%{http_code}
" https://app.example.com/api/health

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

  1. Secrets never live in the repo — only in GitHub Secrets, injected at runtime.
  2. Dedicated deploy key, not your personal one. Rotate it if it leaks.
  3. Narrow sudoers — the deploy user can only run the exact deploy commands.
  4. Config in an external EnvironmentFile — deploys replace code, never secrets.
  5. Exclude runtime data dirs so --delete can't destroy user data.
  6. concurrency prevents overlapping deploys from corrupting a release.
  7. Keep HTTPS on via Certbot; it renews itself and the pipeline never touches it.

Troubleshooting

SymptomLikely cause / fix
Job hangs on the SSH stepMissing 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 requiredYour sudoers rule doesn't match the exact command being run.
Site 502 after deployService didn't restart — check systemctl status and journalctl -u myapp.
Old assets still showingFrontend rsync missing --delete, or a cache.
Runtime files disappearedYou 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.