What You'll End Up With
After following this guide you'll have a leaner server stack that costs less per month without dropping reliability. Specifically: a right-sized VPS, Nginx serving static assets directly, aggressive caching in front of your app, swap configured so the box doesn't OOM-kill your process at 3 a.m., and a checklist of recurring charges to cut. I run three SaaS products on $40/month of Hetzner using exactly these techniques.
Prerequisites
- Ubuntu 24.04 LTS on a VPS (Hetzner, DigitalOcean, Vultr, or equivalent)
- SSH access as a non-root sudo user
- Nginx installed (
nginx -vreturns a version) - Basic familiarity with
systemctland editing files withnanoorvim - 30–60 minutes
Step 1 — Right-Size Your VPS Before Anything Else
Over-provisioned compute is the single biggest waste in indie budgets. Before tuning software, measure actual usage.
1.1 Check average CPU and RAM over the last 24 hours:
sar -u 1 5
Expected output (example):
Linux 6.8.0-31-generic 05/28/2025 _x86_64_
12:00:01 AM CPU %user %system %idle
12:00:02 AM all 1.23 0.45 98.32
...
Average: all 1.10 0.40 98.50
If idle stays above 85% consistently, you're on a server that's too large. Drop one tier — on Hetzner that's often $4–$6/month saved immediately.
1.2 Check RAM pressure:
free -h
Expected output:
total used free shared buff/cache available
Mem: 3.8Gi 1.1Gi 1.4Gi 45Mi 1.3Gi 2.5Gi
Swap: 2.0Gi 0B 2.0Gi
If available is consistently above 1.5 GB on a 4 GB box, downgrade to 2 GB RAM. That single move cuts your Hetzner CX22 bill from ~$6 to ~$4/month — small alone, but multiply it across three projects.
Step 2 — Add Swap So a Smaller Box Stays Stable
Downgrading RAM only works if you have swap as a safety net. A 2 GB swap file costs nothing in money and prevents OOM kills on memory spikes.
2.1 Create a 2 GB swap file:
sudo fallocate -l 2G /swapfile
2.2 Lock down permissions and enable it:
sudo chmod 600 /swapfile && sudo mkswap /swapfile && sudo swapon /swapfile
2.3 Make swap persistent across reboots:
echo '/swapfile none swap sw 0 0' | sudo tee -a /etc/fstab
2.4 Reduce swappiness so the kernel prefers RAM:
echo 'vm.swappiness=10' | sudo tee -a /etc/sysctl.conf && sudo sysctl -p
Expected output:
vm.swappiness = 10
Step 3 — Serve Static Assets Directly from Nginx
Every request that hits your Node, Python, or Ruby app process burns CPU and memory. Static files — JS, CSS, images, fonts — should never reach your app. Nginx serves them at near-zero cost.
3.1 Open your site's Nginx server block:
sudo nano /etc/nginx/sites-available/myproject
3.2 Add a location block for static assets inside your server {} block:
server {
listen 80;
server_name example.com;
root /var/www/myproject/public;
# Serve static files directly; skip the app entirely
location ~* \.(js|css|png|jpg|jpeg|gif|ico|svg|woff|woff2|ttf|eot|webp)$ {
expires 1y;
add_header Cache-Control "public, immutable";
access_log off;
try_files $uri =404;
}
location / {
proxy_pass http://127.0.0.1:3000;
proxy_set_header Host $host;
proxy_set_header X-Real-IP $remote_addr;
}
}
3.3 Test and reload Nginx:
sudo nginx -t && sudo systemctl reload nginx
Expected output:
nginx: the configuration file /etc/nginx/nginx.conf syntax is ok
nginx: configuration file /etc/nginx/nginx.conf test is successful
This alone can cut app process CPU by 20–40% on content-heavy pages, which means you stay comfortable on a smaller instance.
Step 4 — Enable Nginx Gzip Compression
Smaller payloads mean less bandwidth. On metered plans, bandwidth charges add up. On unmetered plans, smaller responses still reduce latency and CPU time in your app.
4.1 Edit the global Nginx config:
sudo nano /etc/nginx/nginx.conf
4.2 Inside the http {} block, add or verify these lines:
gzip on;
gzip_vary on;
gzip_proxied any;
gzip_comp_level 6;
gzip_types text/plain text/css text/xml application/json application/javascript
application/rss+xml application/atom+xml image/svg+xml;
gzip_min_length 256;
4.3 Reload Nginx:
sudo systemctl reload nginx
Typical result: HTML and JSON responses shrink 60–80%. On a $6/TB bandwidth plan that's real money once you have traffic.
Step 5 — Cache Database Query Results with Redis
Database queries are expensive. A $4/month Redis instance (or a local Redis process on the same box) can absorb the majority of read traffic for most indie apps.
5.1 Install Redis on the same VPS to avoid network egress fees:
sudo apt update && sudo apt install -y redis-server
5.2 Bind Redis to localhost only (never expose it publicly):
sudo nano /etc/redis/redis.conf
Find and set:
bind 127.0.0.1 ::1
maxmemory 256mb
maxmemory-policy allkeys-lru
5.3 Enable and start Redis:
sudo systemctl enable redis-server && sudo systemctl start redis-server
5.4 Verify Redis is responding:
redis-cli ping
Expected output:
PONG
With maxmemory 256mb and allkeys-lru, Redis self-manages its footprint. On a 2 GB box that's a reasonable trade for dramatically fewer DB round-trips.
Step 6 — Audit and Cut Recurring Cloud Charges
Software tuning gets you so far. The other half of cost reduction is eliminating services you're paying for but not using. Run this audit monthly.
6.1 List all running services and their memory footprint:
systemctl list-units --type=service --state=running
Then check each one:
ps aux --sort=-%mem | head -20
Disable anything you don't recognize or no longer need:
sudo systemctl disable --now servicename
6.2 Check open ports — every exposed service is a potential cost and attack surface:
sudo ss -tlnp
Expected output (example):
State Recv-Q Send-Q Local Address:Port Peer Address:Port Process
LISTEN 0 511 0.0.0.0:80 0.0.0.0:* users:(("nginx",pid=1234))
LISTEN 0 511 0.0.0.0:443 0.0.0.0:* users:(("nginx",pid=1234))
LISTEN 0 128 127.0.0.1:5432 0.0.0.0:* users:(("postgres",pid=5678))
If you see ports 5432 (Postgres) or 6379 (Redis) on 0.0.0.0, close them immediately — and you were probably paying for a firewall product to compensate.
6.3 Common charges to cut for indie projects:
- Managed database ($15–$50/month): Run Postgres locally on the same VPS with daily
pg_dumpbackups to S3-compatible storage (Backblaze B2 is $0.006/GB). Saves $15–$45/month. - CDN for low-traffic sites (varies): If you're under 50 GB/month of traffic, Cloudflare's free tier handles caching and DDoS protection. No paid CDN needed.
- Multiple small VPS instances: Consolidate staging and production onto one box using separate Nginx server blocks and systemd services on different ports. Cuts one full instance fee.
- Monitoring SaaS ($20–$30/month): Replace with a self-hosted Uptime Kuma instance on the same box (
docker run -d --restart=always -p 3001:3001 louislam/uptime-kuma:1). Free after that. - Log aggregation SaaS ($30+/month): Use
journalctlwith log rotation via/etc/logrotate.d/for most indie workloads, or explore how to set up log aggregation with Loki for a more structured approach.
Verify It Works
After completing all steps, run these checks:
Check swap is active:
swapon --show
Expected output:
NAME TYPE SIZE USED PRIO
/swapfile file 2G 0B -2
Check Nginx is serving static files (not proxying them):
curl -I https://example.com/assets/app.js
Look for Cache-Control: public, immutable in the response headers. Its absence means the location block isn't matching.
Check gzip is active:
curl -H "Accept-Encoding: gzip" -I https://example.com/ | grep -i content-encoding
Expected output:
content-encoding: gzip
Check Redis is caching:
redis-cli info stats | grep keyspace_hits
After a few minutes of traffic, keyspace_hits should be non-zero and rising.
Troubleshooting
Nginx reload fails after editing nginx.conf
Run sudo nginx -t to see the exact line number of the syntax error. The most common cause is a missing semicolon or an unclosed } brace.
Static files still hitting the app process
Confirm your root directive points to the directory that actually contains the files. Run ls /var/www/myproject/public/assets/ to verify the path exists.
Redis consuming more than 256 MB
Check redis-cli info memory | grep used_memory_human. If it's over the limit, maxmemory-policy allkeys-lru should be evicting keys — verify it's set with redis-cli config get maxmemory-policy.
Swap is active but OOM kills still happen
Your process is growing faster than swap can absorb it. Add LimitAS and MemoryMax to the systemd unit file for your app to cap its memory ceiling and get a clean failure instead of a kernel OOM kill.
VPS downgrade caused performance regression
Restore to the previous tier, then profile with htop and iotop to find the real bottleneck before downsizing again. CPU-bound workloads don't benefit from more RAM; IO-bound workloads don't benefit from more CPU.
Next Steps
With these changes in place you have the foundation to reduce server costs for indie projects without sacrificing stability. The logical next moves:
- Set up automated
pg_dumpbackups to Backblaze B2 using a systemd timer (replaces managed DB backups). - Configure Cloudflare's free proxy in front of Nginx to cache full HTML pages at the edge.
- Add
logrotaterules for your app logs under/etc/logrotate.d/to prevent disk fill on a smaller instance. - Review your VPS bill again in 30 days after traffic data confirms the smaller tier is holding.
Every dollar saved on infrastructure is a dollar that stays in the project budget. Small optimizations compound — $15 here, $6 there, and you're running a real product for $40/month.