VPS vs AWS for Small Apps: When One Server Wins

VPS vs AWS for small apps: when a single VPS with Nginx, systemd, and Postgres beats AWS complexity and cost for developers.

A solo developer working at a cluttered wooden desk in a small home office, laptop open beside a coffee mug and a notebook, late afternoon light through the window.

Intro

The question lands in my inbox most weeks, usually from someone who has spent a weekend reading about containers and load balancers and now wants permission to stop. VPS vs AWS for small apps is not a religious argument, and I get annoyed when it gets framed as one. The person asking is rarely chasing a cheaper bill. They want to finish the feature.

That distinction matters more than any price comparison. A marketing site with a contact form, a SaaS product with a few hundred accounts, an internal tool that replaces somebody’s spreadsheet: none of these need a control plane or a message queue that only exists because a tutorial had one. They need a process that stays up and a database that does not lose writes. They need a TLS certificate that renews itself while nobody is watching.

I run a small VPS hosting business, so I see both sides of this. Some customers arrive after a billing surprise, which is usually a NAT gateway or a stack of small charges nobody remembered creating. Others read one article about scaling and provision for traffic that never appears. Neither group is careless. They had no reason to know where the line sits.

David Timothy made this point well in his piece on choosing boring VPS hosting over AWS (opens in new tab). He describes an ordinary application that somehow acquires 25 managed services before it has done anything useful. I have watched that happen to a client with about three hundred registered users. The work of writing IAM policies and deployment manifests is real work. It just does not ship features.

What I want from infrastructure at this stage is for it to leave me alone. One machine I can SSH into, one invoice at the end of the month, one log location to check at 2am when the app stops responding. That is not nostalgia and it is not a refusal to learn new tools. It is a decision about where my attention goes.

The honest answer to which one you should pick depends entirely on what the workload is doing, and that is why I start there and treat the hosting choice as the last decision rather than the first.

Background

The workload comes first. Before I look at any provider, I want to know how many requests arrive at peak, which queries are expensive, how much memory the process actually holds under load, how fast the database grows, and how long a rebuild from backup takes. Registered user count tells me almost nothing. I have hosted apps with 40,000 accounts that saw fewer than ten concurrent requests, and apps with 200 accounts where one report query pinned a CPU core for nine seconds. Those two workloads do not want the same infrastructure, and only one of them is anywhere close to needing a managed queue.

The billing side of this gets underestimated. A VPS invoice is one line. The AWS equivalent of the same small app tends to arrive as a dozen: compute hours, EBS storage and snapshot charges, data transfer out, NAT gateway hours plus per-gigabyte processing, load balancer hours, a managed database priced on instance class and provisioned IOPS, plus whatever the logging stack ingests. None of those are hidden. They are just scattered across separate consoles, and the NAT gateway one in particular catches people who assumed it was free like a route table.

I want to be fair here: at small sizes AWS is not always more expensive in raw dollars. A t4g.small instance with a reserved price can undercut a lot of VPS plans. What usually costs more is the fixed overhead around it, and the predictability. On the VPS I know the number on the first of the month before I deploy anything. That matters when you are a two-person operation deciding whether a feature is worth building at all.

Then there is the time cost, which never shows up on an invoice. Reading IAM policy syntax, versioning a deployment manifest, wiring a queue permission, tracing a request across three services to find where the 502 came from. I can do that work and I have done plenty of it. It only pays off if the workload genuinely needs those boundaries. What I keep noticing is that the hours go into the platform instead of into the product, and nobody notices until the release cadence quietly drops.

What’s happening now

The pattern I keep running into is overengineering before validation. A booking form, an admin page and a Stripe webhook end up with an architecture diagram that reads like a mid-size company’s. There is a VPC, a load balancer, a managed database with a read replica nobody queries, a queue for a job that runs once a night, and a CDN in front of pages that were already static. David Timothy described this well in his post about sticking with boring VPS hosting (opens in new tab), where an ordinary application somehow acquires 25 managed services and the conversation moves to IAM policies and queue permissions before the product has done anything useful.

None of those services are bad. They answer questions nobody has asked yet. Was the connection pool exhausted? Was there a traffic spike you could not absorb? Did you need two versions running at once during a deploy? If the honest answer is no, you have paid for the answer anyway.

That is a large part of why VPS hosting for developers has not gone anywhere. One box running Nginx, your application process and Postgres is boring, and boring is exactly what you want while the product is still finding out whether anyone cares about it.

So when does a single server make sense? I use a short set of tests, and an app has to pass most of them, not all.

  • Peak concurrency fits on the machine with room to spare. If your busiest hour leaves the box under half its memory used, you have space.
  • A restart measured in minutes is survivable. Scheduled at 3am, most small apps are.
  • The database is small enough to dump and restore inside a window you can tolerate, and you have actually restored a dump rather than only taken one.
  • One or two people do the deploying. Nobody is on call in shifts.
  • No compliance requirement forces the data tier onto separate hardware.
  • Recovery measured in hours is acceptable to the business, not seconds.

A shop owner renting a unit on the high street knows the rent and the electricity bill. The same business in a mall pays rent plus common area maintenance plus a share of the parking, and the monthly number arrives in pieces. Nothing wrong with the mall if you need the footfall. Most small web apps are not paying for footfall, they are paying for the option to have it later.

For the mundane version of this, I check uptime and free -m on the box after a busy day, and run SELECT pg_database_size('app') once a month. Two commands and one query, read in about a minute, and they tell me more about whether I need a second machine than any dashboard has.

What it means in practice

Once a project passes those tests, the stack I build is small enough to sketch on a napkin. Nginx terminates TLS and proxies to the application on loopback. systemd supervises the app process. Postgres runs on the same host, listening on 127.0.0.1 only. A cron job at 02:15 runs pg_dump.

The Nginx piece that does the actual work is four lines:

location / {
    proxy_pass http://127.0.0.1:3000;
    proxy_set_header Host $host;
    proxy_set_header X-Forwarded-Proto $scheme;
}

The rest of the server block is TLS certificates, an HTTP redirect, a client_max_body_size value that matches what the app actually accepts, and nothing else. I have seen production configs with fourteen location blocks where three would have done, and every one of them was a place a future me would have to read at 1am.

For the process, a unit file with three lines beats any orchestrator for a single box:

Restart=on-failure
RestartSec=5
StartLimitBurst=5

That gives the app five tries with a five second gap. journalctl -u myapp.service -f shows the crash loop if it never settles, which is the signal something needs a human rather than another restart. I run the app as a dedicated user with no shell and no sudo, so a compromised process is not a compromised machine.

Postgres on one VPS is fine until it is not. I set listen_addresses = 'localhost' and let ufw allow only 22, 80, and 443. Backups are the part people skip: a dump sitting in /var/backups on the same disk is not a backup. My script dumps to a file, then ships it off the box with restic to object storage the same night. I rehearse a restore twice a year, on a throwaway droplet, because an untested restore is a hope, not a plan. Honest recovery time on a single VPS is somewhere between thirty and sixty minutes: provision, install, restore, flip DNS. That is fine for a brochure site and not fine for a payments API.

The scale-out trigger I watch for is swap in use during peak, or a query that holds a connection for several seconds. When either shows up twice in a week, I move Postgres to its own machine before I touch anything else.

What to expect next

Growth on a small app does not announce itself. It shows up as a number you have to go looking for. I watch four: peak concurrent connections in Nginx, resident memory of the app process, the slowest queries in pg_stat_statements, and how fast the Postgres data directory grows week over week. Any one of those moving alone is a data point. Two of them moving together, twice in the same fortnight, is when I start planning a split instead of waiting for a Friday night page.

Storage is the one people forget. A 40GB volume that fills up does not degrade gracefully. Postgres refuses writes, the app throws 500s, and the CPU graph stays flat the whole time, which sends you looking in the wrong place for twenty minutes.

The discipline that keeps your options open is not infrastructure, it is code shape. If the background worker is a module with its own function calls and its own database connection pool, moving it to a second machine is an afternoon. If it reaches into the web process’s global state, it is a rewrite. Write the boundaries. Do not wire them up as network calls until you have a reason. The first thing I move off a loaded box is almost never the web process. It is the worker, because a runaway cron job and a request handler competing for the same CPU is the most common way a healthy small app falls over.

Where I expect this to go: I do not think the single-box pattern disappears. Cheap NVMe and more cores per dollar keep arriving, and the managed layer keeps getting more capable while also getting more expensive and more opinionated. My expectation, and it is only an expectation, is that the crossover point moves a little each year rather than vanishing. Plenty of teams will move to an AWS alternative for small applications not because they outgrew one server, but because a compliance requirement or a second engineer arrived.

Failure planning is the part that survives either choice. I keep a one page runbook per box: what the app user is, where the unit file lives, where the latest dump is, and the exact commands to restore it. Same disk dumps do not count, which is why mine land in object storage the same night. DNS TTLs sit at 300 seconds so a cutover is five minutes, not a day. Hosts reboot for kernel updates, disks fail without warning, and a provider’s control plane has an afternoon it would rather forget. Thirty to sixty minutes of recovery is a number you decide in advance, and the only way to know your number is real is to have actually done the restore once.

Go Break It on Purpose

The only way to know whether any of this holds up is to run it once yourself, on a box you do not care about. Rent the smallest VPS your budget allows, a single core and a gigabyte of RAM is plenty for this exercise, and build the least impressive version of your app that still answers a request. Debian or Ubuntu, nginx from the distro repo, Postgres from the same, and one systemd unit pointing at a process listening on 127.0.0.1:3000. Point a subdomain you will not miss at it.

Write the unit file carefully and read it back before you start anything.

[Unit]
Description=myapp

[Service]
User=app
WorkingDirectory=/srv/myapp
ExecStart=/srv/myapp/venv/bin/gunicorn -b 127.0.0.1:3000 app:app
Restart=on-failure
RestartSec=5

[Install]
WantedBy=multi-user.target

Then do the thing most people skip when they set up a restart policy. They enable it and assume. Kill the process outright instead:

sudo systemctl kill -s SIGKILL myapp.service
sleep 6
systemctl status myapp.service

If the status says Active: active (running) with a Process line whose start time is six seconds old, the policy works and you have proof. If it says failed, you have a typo in ExecStart or a missing working directory, and finding that out now costs you two minutes instead of a night. I have shipped both outcomes and the second one only hurt because I never tested it.

Add systemctl enable myapp.service so it survives a reboot, then reboot the box and confirm the whole chain comes back: nginx up, Postgres up, app listening on loopback, HTTPS page loading in a browser. That single reboot tests more of your assumptions than a week of reading about availability.

Now pick one number and watch only that. Time to first byte from a free external monitor, checked every five minutes, is enough to start. Not CPU graphs, not a dashboard with twelve panels. You want to know what normal looks like before you have an incident, because the first time the number doubles you will otherwise have no idea whether that is a bad day or a bad query.

The point of the exercise is not that one VPS is always right. It is that you can measure it. So the step is this: tonight, rent the smallest box you can find, deploy a throwaway app on it, and kill the process twice. Once to watch systemd bring it back, and once after changing the unit file to Restart=no so you can see the difference. You will know within an hour whether one server is enough for what you are actually running.