How to Reduce Server Costs for Indie Projects

by David Park
How to Reduce Server Costs for Indie Projects

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 -v returns a version)
  • Basic familiarity with systemctl and editing files with nano or vim
  • 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_dump backups 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 journalctl with 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_dump backups 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 logrotate rules 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.