srmdn.

Back

Deploying to a VPS Without Docker or CI/CDBlur image

When I moved this site from a managed static host to a bare VPS, the first thing people asked was: “Why not Docker?” or “Why not just use Coolify?” Fair questions. Let me answer them before getting into the actual workflow.

Why Not X?#

Why not Docker?#

Docker shines when you have complex multi-service apps, a team that needs consistent environments across many machines, or you’re shipping to Kubernetes. For a personal site running a Go API and a Node.js Astro frontend, it’s overkill.

Docker adds:

  • Build time overhead (image layers, registry pushes)
  • Runtime overhead (container daemon, networking abstraction)
  • Operational complexity (container orchestration, volume management)
  • A new failure domain to debug when something goes wrong

When your app is two processes and a static site, just run the processes. systemd is your process manager. It starts services on boot, restarts on crash, and gives you journalctl for logs. That’s everything you need.

Why not a managed platform (Railway, Render, Fly.io)?#

These platforms are genuinely good, and I’d recommend them for most projects. But I wanted to understand what’s happening underneath — how nginx sits in front of your app, how systemd manages processes, how firewall rules protect internal ports from the internet. Managed platforms abstract all of that away. Also, at the hobby tier, they get expensive once you add a database and background workers.

Why not Coolify or Dokku?#

Coolify and Dokku are excellent self-hosted tools that give you a Heroku-like experience on your own VPS. But they run Docker under the hood, so you’re still adding that complexity. And they introduce an abstraction layer you have to learn and trust. For a site I’ll maintain solo indefinitely, I’d rather know exactly what’s running than have a tool I don’t fully understand managing it.

Why not CI/CD?#

I use GitLab for source control, but I don’t use GitLab CI/CD for deployment. A few reasons:

Free compute minutes are limited. GitLab’s free tier gives you 400 CI/CD compute minutes per month. For a personal project where you might deploy dozens of times during active development, that evaporates fast. Paying for compute just to run git pull && go build on a server you already pay for doesn’t make sense.

A self-hosted GitLab Runner is the right long-term solution — the runner runs on your own VPS, uses your own compute, and has no minute limits. I plan to set that up eventually. But the manual workflow I’m using now works fine, and I actually prefer having an explicit promotion gate.

Manual deploys are fine for solo projects. CI/CD adds real value when multiple people are merging code and you need automated testing before every deploy. For a personal site with one contributor, a deliberate “I’m pushing this to production now” moment has its own value. You know exactly what you’re deploying and when.


The Mental Model#

[Local machine]  →  git push  →  [GitLab]  →  git pull  →  [VPS]
   write code          transport              runs code
plaintext

Your local machine is where you write. GitLab is the transport layer. The VPS is where things run. You never SCP files directly — git is always in the middle.

The VPS runs two environments side by side:

/var/www/
├── myproject-staging/      ← test changes here first
│   ├── backend/            ← Go API (localhost-only port)
│   └── frontend/           ← Astro SSR (localhost-only port)
└── myproject-production/   ← promote when staging looks good
    ├── backend/
    └── frontend/
plaintext

nginx routes by domain name, so both environments run simultaneously without interfering with each other.


Prerequisites#

On the VPS#

  • A non-root deploy user that runs your services
  • nginx
  • Go (installed system-wide — often not in PATH, use the full binary path)
  • Node.js + npm

GitLab deploy key#

The VPS needs to git pull from your private repo. Create a key specifically for this:

# On the VPS as root
ssh-keygen -t ed25519 -f /root/.ssh/id_ed25519_gitlab -N "" -C "vps-deploy"
cat /root/.ssh/id_ed25519_gitlab.pub  # copy this
bash

Add the public key to GitLab: Project → Settings → Repository → Deploy Keys → Add key (read-only is fine).

Then add to /root/.ssh/config on the VPS:

Host gitlab.com
    IdentityFile /root/.ssh/id_ed25519_gitlab
    IdentitiesOnly yes
plaintext

Test it:

ssh -T [email protected]
# Welcome to GitLab, @yourusername!
bash

First-Time Server Setup#

Clone and set ownership#

mkdir -p /var/www/myproject-staging
git clone [email protected]:yourusername/myproject.git /var/www/myproject-staging/myproject
chown -R deploy:deploy /var/www/myproject-staging/
bash

If you later get fatal: detected dubious ownership when running git as root:

git config --global --add safe.directory /var/www/myproject-staging/myproject
bash

Environment files#

.env files are never committed to git. Create them manually on the server:

# Backend — owned by root (systemd reads it before dropping to deploy user)
nano /var/www/myproject-staging/myproject/backend/.env.staging
chmod 600 /var/www/myproject-staging/myproject/backend/.env.staging
chown root:root /var/www/myproject-staging/myproject/backend/.env.staging

# Frontend — owned by deploy (npm build runs as deploy)
nano /var/www/myproject-staging/myproject/frontend/.env.staging
chmod 600 /var/www/myproject-staging/myproject/frontend/.env.staging
chown deploy:deploy /var/www/myproject-staging/myproject/frontend/.env.staging
bash

Content directory ownership#

If your app writes files to disk (for example, blog posts as markdown files), that directory must be owned by deploy — the user the service runs as:

chown -R deploy:deploy /var/www/myproject-staging/myproject/frontend/src/content/
bash

Missing this step causes confusing 500 errors when the API tries to create files. The error in the logs is permission denied on mkdir, not something that obviously points to ownership.

This same issue appears whenever you touch files as root. If you SSH in as root and edit a file, copy a file, or use any tool that runs as root (an AI coding assistant, a script, anything) — the resulting file is root:root. The deploy service silently fails to write it. The fix is always chown deploy:deploy <file>.


Building the App#

Go backend#

Build directly on the VPS. This avoids cross-compilation issues if your VPS is Linux x86_64 and your dev machine is Apple Silicon:

cd /var/www/myproject-staging/myproject/backend

# Stop the service first — you can't overwrite a running binary ("text file busy")
systemctl stop myproject-backend-staging

/usr/local/go/bin/go build -o myproject-backend ./cmd/server/main.go
chown deploy:deploy myproject-backend

systemctl start myproject-backend-staging
bash

Node.js / Astro frontend#

Always run npm as the deploy user, not root. Running as root creates files owned by root inside node_modules/ and dist/, which the service (running as deploy) can’t later write or overwrite:

cd /var/www/myproject-staging/myproject/frontend
sudo -u deploy npm install
sudo -u deploy npm run build
bash

systemd Services#

systemd is your process manager. Two service files, one per process.

Backend service#

/etc/systemd/system/myproject-backend-staging.service:

Frontend service (SSR only)#

Only needed if your frontend uses server-side rendering. For a static site, nginx serves the dist/ folder directly — no service needed at all.

/etc/systemd/system/myproject-astro-staging.service:

Enable and start:

systemctl daemon-reload
systemctl enable myproject-backend-staging myproject-astro-staging
systemctl start  myproject-backend-staging myproject-astro-staging

# Verify
systemctl status myproject-backend-staging
journalctl -u myproject-backend-staging -f
bash

nginx Reverse Proxy#

nginx listens on port 80/443 and proxies traffic to your internal app ports. The apps bind to 127.0.0.1 — they’re never directly reachable from the internet.

Repeat for your API subdomain, pointing to the backend’s port.

nginx -t && systemctl reload nginx
bash

Firewall: Lock Down App Ports#

Your app ports should only be reachable from localhost (via nginx). Block everything else:

# IPv4 — allow localhost, drop everything else
iptables -I INPUT -p tcp --dport 3000 ! -s 127.0.0.1 -j DROP
iptables -I INPUT -p tcp --dport 8080 ! -s 127.0.0.1 -j DROP

# IPv6 — blanket drop
ip6tables -I INPUT -p tcp --dport 3000 -j DROP
ip6tables -I INPUT -p tcp --dport 8080 -j DROP

# Persist across reboots
netfilter-persistent save
bash

One subtlety: use ! -s 127.0.0.1 -j DROP rather than a plain -j DROP. The localhost exemption means SSH tunnels (ssh -L 8888:127.0.0.1:8888) still work for local debugging. A blanket DROP silently breaks them.


The Daily Deploy Loop#

If only the frontend changed, you don’t touch the backend. If only the backend changed, you don’t rebuild the frontend. Do both if both changed.


Staging → Production: The Manual Promotion Gate#

Once staging looks good, promote to production:

# Pull the same code into the production directory
cd /var/www/myproject-production/myproject && git pull

# Rebuild frontend
cd frontend && sudo -u deploy npm run build
systemctl restart myproject-astro-production

# Rebuild backend if it changed
cd ../backend
systemctl stop myproject-backend-production
/usr/local/go/bin/go build -o myproject-backend ./cmd/server/main.go
chown deploy:deploy myproject-backend
systemctl start myproject-backend-production
bash

This is the manual promotion gate — you explicitly decide when production gets updated. For a solo project, this is a feature, not a limitation. You’re never wondering why production is broken because something got auto-deployed while you were away.


Common Gotchas#

ProblemCauseFix
permission denied when app writes filesService runs as deploy, directory owned by rootchown -R deploy:deploy <dir>
API returns 500 after you manually created/edited a file as rootRoot-owned files are unwritable by the deploy servicechown deploy:deploy <file>
text file busy on Go rebuildCan’t overwrite a running binarysystemctl stop first, then build
npm run build fails with EACCESRan npm as rootAlways sudo -u deploy npm ...
detected dubious ownershipgit clone ran as rootgit config --global --add safe.directory <path>
nginx 502App not listening on expected portCheck journalctl -u <service>, verify PORT env var
Changes don’t appear after deployForgot to rebuild or restartRebuild frontend, restart service
SSH tunnel broken after adding DROP ruleBlanket -j DROP blocks localhostUse ! -s 127.0.0.1 -j DROP

Is This Right for You?#

This workflow makes sense if:

  • You’re running a personal project or small site on a VPS you already pay for
  • You want to understand deployment fundamentals rather than abstract them away
  • You have one or two contributors — the manual step doesn’t scale to a team
  • You don’t want to burn CI/CD compute minutes on simple deploys

It probably doesn’t make sense if:

  • Multiple people are merging code and you need automated testing on every commit
  • You need zero-downtime blue/green deploys (use a proper pipeline)
  • You’re managing many services and want a unified dashboard (Coolify is genuinely great for that)
  • You need horizontal scaling across multiple VPS nodes

For this site, the boring approach is working fine. Two systemd services, one nginx config, and a git pull to deploy. No containers, no orchestration, no surprise bills.

Enjoyed this post?

Get Linux tips, sysadmin war stories, and new posts delivered to your inbox.

No spam. Unsubscribe anytime.

Deploying to a VPS Without Docker or CI/CD
https://srmdn.com/blog/deploying-to-vps-without-docker-and-cicd
Author srmdn
Published at February 22, 2026