1. Why one VPS, not ten #
Most production-deployment advice assumes you're running one service and want it infinitely scalable. That's not most people. If you're a solo developer, a small team, or a shop shipping ten related side-services, the right answer is usually a single rented VM priced at coffee-and-a-donut a month.
One VPS running a web server, a few APIs, a worker or two, a dashboard, a static site, and a media server is a completely reasonable production setup. It fits a whole constellation of small services on one invoice, one IP, one cert-renewal schedule. The tradeoff is that you have to understand your box — but you'd have to understand Kubernetes either way, and a VPS is a lot smaller.
2. What fits on one box #
Rough rules of thumb for a 4-core / 8GB VPS:
| Service type | Comfortable count | Notes |
|---|---|---|
| Node/Python API servers (idle to light traffic) | 5–10 | Most spend their life asleep; memory is the binding constraint |
| Static sites (served by nginx) | Unlimited practically | Nginx serves static at line speed |
| Postgres databases | 1, sometimes 2 | Use schemas, not databases, to split workloads |
| Redis/KV stores | 1 shared, multiple DBs | Redis supports 16 logical DBs on one instance |
| Background workers | 3–5 | CPU-bound work is your real constraint here |
| Media servers (Jellyfin, etc.) | 1 | Transcoding is a CPU hog — profile first |
Signs you're pushing the limit
- Load average (from
uptime) regularly exceeds your core count. - Free memory is below 10% and you're hitting swap.
- Response times have a long tail during bursts — neighbor effects.
- Nginx error logs start showing
upstream timed out.
Any of those sustained means it's time to either right-size the services, split off one heavy service to its own box, or upgrade the VPS.
3. The stack #
The whole pattern is three pieces:
- nginx — terminates TLS, routes by hostname or subpath, serves static, proxies to backends.
- systemd — starts and supervises every long-running service.
- Let's Encrypt + certbot — obtains and renews TLS certs automatically.
That's it. No Docker required, no Kubernetes, no orchestrator, no service mesh. You can add Docker for service isolation, but a bare-metal systemd + nginx setup is simpler and still reaches production-grade.
Debian 12 or Ubuntu 22.04+ are the obvious OS picks — systemd is native, nginx and certbot are one apt away.
4. Filesystem layout #
Consistency here pays off every single day. Pick a layout and stick to it.
/etc/nginx/sites-available/ # vhost configs (one per site)
/etc/nginx/sites-enabled/ # symlinks to sites-available
/etc/systemd/system/ # your service unit files
/etc/letsencrypt/live/ # SSL certs (managed by certbot)
/var/www/<domain>/ # static sites by hostname
/opt/<service>/ # application code for each service
/opt/<service>/.env # per-service env file (600 perms)
/var/log/nginx/<domain>.access.log # per-site access log
/var/log/nginx/<domain>.error.log # per-site error log
The pattern: one site equals one domain equals one directory equals one nginx vhost. When you add a new service, you always know where its files go and what to name them.
5. Nginx vhost patterns #
Pattern A — Static site
Cheapest possible vhost. Serves files from a directory.
# /etc/nginx/sites-available/example.com
server {
listen 80;
listen [::]:80;
server_name example.com www.example.com;
root /var/www/example.com;
index index.html;
access_log /var/log/nginx/example.com.access.log;
error_log /var/log/nginx/example.com.error.log;
location / {
try_files $uri $uri/ =404;
}
location ~ /\.well-known/acme-challenge/ {
allow all;
}
location ~ /\. {
deny all;
}
}
The .well-known block lets certbot solve the HTTP-01 challenge. The /\. block denies access to any hidden file (.env, .git, etc.). Both should be in every vhost.
Pattern B — Reverse proxy to a backend
Node/Python service listening on a loopback port.
server {
listen 80;
server_name api.example.com;
access_log /var/log/nginx/api.example.com.access.log;
error_log /var/log/nginx/api.example.com.error.log;
location / {
proxy_pass http://127.0.0.1:8080;
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;
proxy_http_version 1.1;
proxy_set_header Upgrade $http_upgrade;
proxy_set_header Connection "upgrade";
proxy_read_timeout 60s;
}
}
The Upgrade/Connection headers enable WebSockets. Always bind your backend to 127.0.0.1 (not 0.0.0.0) so it's not reachable from the public internet directly — nginx is the only door in.
Pattern C — One domain, many subpaths
Useful when you want example.com to host a static landing page plus several apps under subpaths.
server {
listen 80;
server_name example.com;
root /var/www/example.com;
index index.html;
location / {
try_files $uri $uri/ =404;
}
location /app/ {
proxy_pass http://127.0.0.1:8001/;
proxy_set_header Host $host;
proxy_set_header X-Real-IP $remote_addr;
}
location /api/ {
proxy_pass http://127.0.0.1:8002/;
proxy_set_header Host $host;
proxy_set_header X-Real-IP $remote_addr;
}
}
Pattern D — Subdomain per service
One vhost file per subdomain, each with its own cert. Cleaner for services that will grow.
server {
listen 80;
server_name dashboard.example.com;
access_log /var/log/nginx/dashboard.example.com.access.log;
error_log /var/log/nginx/dashboard.example.com.error.log;
location / {
proxy_pass http://127.0.0.1:8003;
proxy_set_header Host $host;
proxy_set_header X-Real-IP $remote_addr;
}
}
Enabling a vhost
sudo ln -s /etc/nginx/sites-available/example.com \
/etc/nginx/sites-enabled/example.com
sudo nginx -t # always test before reload
sudo systemctl reload nginx
nginx -t before reload
A single syntax error will stop nginx from starting. A failed reload on a misconfigured vhost can take down every site on the box. Make nginx -t muscle memory.
6. Systemd units that survive #
Every long-running service gets a systemd unit. No nohup, no pm2, no homegrown init scripts.
A Node service unit
# /etc/systemd/system/my-api.service
[Unit]
Description=My API service
After=network.target
[Service]
Type=simple
User=deploy
Group=deploy
WorkingDirectory=/opt/my-api
EnvironmentFile=/opt/my-api/.env
ExecStart=/usr/bin/node server.js
Restart=on-failure
RestartSec=5s
# hardening
NoNewPrivileges=true
ProtectSystem=strict
ProtectHome=true
ReadWritePaths=/opt/my-api /var/log/my-api
PrivateTmp=true
[Install]
WantedBy=multi-user.target
A Python service unit
[Unit]
Description=My worker
After=network.target
[Service]
Type=simple
User=deploy
WorkingDirectory=/opt/my-worker
EnvironmentFile=/opt/my-worker/.env
ExecStart=/opt/my-worker/venv/bin/python -u main.py
Restart=on-failure
RestartSec=10s
[Install]
WantedBy=multi-user.target
The -u flag on Python keeps stdout unbuffered so your logs appear in journald in real time rather than in chunks.
Enabling and managing
sudo systemctl daemon-reload # after editing units
sudo systemctl enable --now my-api # start and auto-start on boot
sudo systemctl status my-api # is it up?
sudo systemctl restart my-api # restart cleanly
sudo journalctl -u my-api -n 100 # last 100 log lines
sudo journalctl -u my-api -f # tail live
Restart=on-failure is the whole reason people think services are fragile. Services aren't fragile if you let the init system restart them. Never run anything in production that systemd can't bring back.
7. SSL automation #
Let's Encrypt via certbot. Issue once, renew forever.
Initial issue (for each domain)
sudo certbot --nginx -d example.com -d www.example.com \
--non-interactive --agree-tos -m you@example.com --redirect
Certbot:
- Solves the HTTP-01 challenge through your existing nginx vhost.
- Rewrites the vhost to add
listen 443 ssl, point to the cert files, and (with--redirect) force all HTTP to HTTPS. - Drops an auto-renewal timer into systemd.
Verify renewal is actually happening
sudo systemctl list-timers | grep certbot
# expected: certbot.timer, firing twice a day
sudo certbot renew --dry-run
# simulates a renewal to catch problems before they matter
CAA records (if you control DNS)
A CAA record at your domain scopes who's allowed to issue certs for you. Even a permissive one ("letsencrypt.org can issue") is better than none — it blocks rogue issuance from other CAs.
example.com. CAA 0 issue "letsencrypt.org"
8. Deploy workflow #
Two patterns cover 95% of what a small shop needs.
Rsync + restart (the default)
# local
rsync -avz --delete \
--exclude venv/ --exclude node_modules/ \
--exclude .git/ --exclude __pycache__/ \
./ deploy@vps:/opt/my-api/
ssh deploy@vps "cd /opt/my-api && npm ci --omit=dev"
ssh deploy@vps "sudo systemctl restart my-api"
ssh deploy@vps "sudo systemctl is-active my-api"
Works for Node, Python, or anything else with a single restart boundary. The --delete flag removes remote files that no longer exist locally — use it intentionally (it's a footgun if your remote has state you forgot about).
Blue-green lite
For services where a 2-second restart is too much downtime, run two copies on different ports behind nginx and flip the upstream.
# /etc/nginx/sites-available/api.example.com
upstream api_backend {
server 127.0.0.1:8080; # blue
# server 127.0.0.1:8081; # green (swap at cutover)
}
server {
listen 443 ssl;
server_name api.example.com;
location / {
proxy_pass http://api_backend;
}
}
Deploy to the idle color, verify, flip the upstream block, nginx -s reload. Reload is zero-downtime; restart is not.
Rollback
A rollback is just another deploy from a known-good tag. If you deploy from git, tag each release:
# on deploy
git tag -a "deploy-$(date +%Y%m%d-%H%M)" -m "deploy"
git push --tags
# to roll back: check out the previous tag, rsync, restart
9. Per-service logging #
Nginx access & error logs
Configure per-site via the access_log and error_log directives inside the server block. Every vhost gets its own log file. This makes log inspection during an incident a question of "which site?" not "grep 30 lines out of a million".
Application logs via journald
If your service writes to stdout, systemd captures it into journald automatically. No log files to rotate, no disk-full surprises — journald handles retention.
# last hour of logs for one service
sudo journalctl -u my-api --since "1 hour ago"
# only error-level
sudo journalctl -u my-api -p err
# live tail with filtering
sudo journalctl -u my-api -f | grep -i "slow query"
Retention
# /etc/systemd/journald.conf
SystemMaxUse=2G # cap journal size
MaxRetentionSec=30day
Reload with sudo systemctl restart systemd-journald. You now have 30 days of searchable logs, capped at 2GB, across every service on the box.
10. Secrets & env #
One .env file per service, loaded by its systemd unit via EnvironmentFile=. Never check .env into git, never read it in app code.
# /opt/my-api/.env
DATABASE_URL=postgres://user:pass@127.0.0.1/my_api
STRIPE_KEY=sk_live_...
LOG_LEVEL=info
Lock down the permissions:
sudo chown deploy:deploy /opt/my-api/.env
sudo chmod 600 /opt/my-api/.env
Only the service user can read it. The file isn't reachable from nginx (the /\. deny block we set earlier) and isn't checked into git (add .env to .gitignore for every project).
.env
Add it to .gitignore in every project template. Use a .env.example with placeholder values for documentation. A leaked .env is the single most common way small shops lose production credentials.
11. When to outgrow #
Signs you've outgrown the one-VPS model:
- Sustained CPU/memory pressure that doesn't resolve by right-sizing services.
- Neighbor effects: one heavy service consistently degrades latency for others.
- Downtime tolerance shrinks. Your users (paying or otherwise) can't tolerate the ~15s total downtime of reboots, cert renewals, or nginx reloads.
- Team size grows. More than two people SSH'ing into the same box starts to hurt — change audit, permissions, and "who restarted what" all degrade.
- Compliance requirements. SOC 2, HIPAA, or similar start requiring isolation boundaries you can't demonstrate on a shared box.
First escape hatches (before going to k8s)
- Split the heaviest service to its own VPS. Same pattern, one-per-box.
- Move the database to a managed service. Free up RAM and offload backups.
- Front everything with a CDN for static. Offloads bandwidth.
- Use a load balancer (another VPS or a managed one) in front of two identical app VPSes.
Most shops never need more than that. Kubernetes is rarely the right answer — it's expensive, operationally complex, and solves problems most teams don't have.
12. Rules & pitfalls #
- One site equals one directory equals one vhost. Consistency compounds.
- Always
nginx -tbeforereload. A bad config takes down every site. - Bind backends to
127.0.0.1, never0.0.0.0. Nginx is the only public door. - Every service gets a systemd unit with
Restart=on-failure. Nonohup, ever. - Enable units with
--nowso you don't forget to start them. - Log to stdout and let journald handle it. Don't manage app log files by hand.
- Cap journald size in
journald.confor a runaway log will fill the disk. - Run
certbot renew --dry-runmonthly to catch renewal issues before a cert expires. - Store secrets in
.envwith 600 permissions. Never in code, never in git. - Deploy with rsync + restart. Resist the urge to add complexity until it hurts.
- Tag every deploy. Rollback is much easier when you know what was live.
- When one service eats the others, give it its own VPS before reaching for orchestration.