AWS Builder Center

Three Client Sites, One VPS: What I Would Move to AWS

An honest component-by-component look at what I run on one VPS, what gets harder on AWS, and what I would move first.

I have been scared of production for a year. I run three real client websites on a single cheap VPS, and every night I check that they are still up, and every night I do the maths on what would happen if I lost the box.
They have been fine. That is the honest headline. Nobody asked me to write this because I moved to AWS. I did not. This is a beginner with $30 of AWS student credits looking at the thing he actually runs, and asking which parts deserve to move.

What one VPS is actually holding right now

Three client sites, one automation engine, one database, one reverse proxy. No inbound ports. Not one.
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
INTERNET
|
(Cloudflare edge)
|
Cloudflare Tunnel ---> outbound-only, no port open
|
+--------------v---------------+
| VPS |
| Caddy ----------------+ |
| +--> site-a :8000 | | each: mem_limit set
| +--> site-b :8001 | | restart: unless-stopped
| +--> site-c :8002 | | named volume
| +--> n8n :5678 | |
| Postgres :5432 internal | |
| db_a + user_a |
| db_b + user_b |
| db_c + user_c |
| UFW deny inbound |
| fail2ban |
| Uptime Kuma --> monitors |
+------------------------------+
Three clients share one Postgres instance. One database, one user, per client. That is the part that keeps me up.

VPS now vs. the AWS equivalent, component by component

ComponentToday on the VPSAWS equivalentCost directionWhat gets harder
Next.js appDocker container, Caddy proxyECS Fargate / App RunnerUpYou lose the single host. Now three clients are three tasks and a load balancer.
PostgresContainer + named volumeRDSUpPer-tenant isolation gets real. Backups become point-in-time. Cost gets a floor.
n8n automationContainer, memory-cappedECS or a small EC2UpIt is stateful. A task restart loses queue state. It wants a stickier home than my app.
File uploadsLocal volumeS3DownNothing hard. This is the easiest move on the list.
Backupspg_dump to another diskpg_dump to S3 + RDS PITRDownNothing hard. Just better.
SecretsCaddy/compose .env, 0600Secrets ManagerUpEvery developer now needs a role. More moving parts than one 0600 file.
MonitoringUptime KumaCloudWatch + a real APMUpCloudWatch has no "is the page readable" check. I still want Uptime Kuma.
TLSCaddy, automaticACM / ALB listener~SameThe hard part is not TLS. It is that Caddy terminates my whole tunnel topology.
Read that last column slowly. The money going up is not the problem. The count of things I must now understand at 2am is the problem.

The deploy pipeline and the immutable tag rule

GitHub Actions builds the image, pushes it to GHCR, SSHes in, and swaps it. The one rule that keeps this honest: an explicit tag. Never latest.
1
2
3
4
5
6
7
8
9
10
11
12
13
git push origin feature-branch
|
GitHub Actions (build)
|
docker build -t ghcr.io/me/site-a:$GITHUB_SHA .
|
docker push ghcr.io/me/site-a:$GITHUB_SHA
|
SSH to VPS
|
# $GITHUB_SHA is immutable: I can name the exact image
# that is live, and roll back to it by name.
docker compose pull && docker compose up -d
latest means "whatever was built last". When a deploy breaks, I cannot name the good thing I want to go back to. So: explicit tag, every time.
My other golden rule, the one I break most: never push directly to main. Main = live site. Always work on a branch first. When things break, the recovery drill is git revert HEAD to undo cleanly, or git reset --hard abc1234 to yank the branch back.

Why Cloudflare Tunnel changed the security model

Before: Caddy listening on 443, UFW opening 443, fail2ban watching the door. Now: Caddy listens only on localhost, and a tunnel makes the outbound connection to Cloudflare. Zero inbound ports.
This is the single biggest security win in my stack and it costs nothing. There is no port to scan, no SSH exposed to the internet (I moved SSH to a Tailscale-only bind), nothing to brute-force. My attack surface went from "the whole box" to "the box, if it is already inside."
1
2
3
4
5
6
7
8
9
10
11
12
13
services:
site-a:
image: ghcr.io/me/site-a:TAG # explicit tag, never latest
restart: unless-stopped # survives reboot AND daemon restart
mem_limit: 512m # cap it; one OOM at 3am taught me this
environment:
DATABASE_URL: DB_URL
volumes:
- site-a-uploads:/app/uploads # named volume, survives redeploy
# NOTE: no ports block. Tunnel only. Nothing inbound.

volumes:
site-a-uploads:
That is the whole app service. No ports block at all.

The shared-Postgres multi-tenancy fear

Here is my actual fear, written plainly: one bug in the tenant check and client A sees client B's orders. One line of wrong WHERE clause and I have leaked another company's data. I cannot un-send an email.
So I test the check every single time. Not "it should work" -> an actual test that logs in as user A, requests a resource belonging to B, and expects a 404. Every deploy. It costs me twenty minutes and it is the only reason I can sleep.
If I moved to RDS, I would get real per-tenant isolation and point-in-time recovery. That is genuinely better. But it also means an RDS instance bills storage while sitting idle, and the security posture of a shared Postgres is something I chose, not something I inherited.

The SSH-rotation silent failure

Here is the bug that changed how I work. I rotated an SSH key. Every deploy stopped working immediately. The sites stayed up. Uptime Kuma stayed green. Nothing was red anywhere.
Nobody noticed for days.
My lesson, in my own words: a pipeline you cannot re-run is a pipeline you do not have. And green monitoring says nothing at all about whether you can deploy. Uptime Kuma watches the site, not the path to the site.

What I would actually move to AWS first, and why

  1. S3 for uploads. Easiest possible win. Objects are already files. Real durability, near-zero operational cost.
  2. S3 for backups. This is my urgent one. A pg_dump cron to a bucket I can actually restore from.
  3. CloudWatch alarms. Specifically a memory alarm. My one lazy Map cleanup leaked memory for three weeks and only Uptime Kuma's memory graph caught it.
After that, I stop. The apps stay in Docker. I am not learning ECS to serve three websites that already work.
1
2
3
4
5
6
7
8
9
# Nightly backup: real pg_dump piped to S3. Not a tar of /var/lib/postgresql.
# WHY per-tenant: a dump of database "a" cannot leak customer b's orders,
# and I can restore ONE client without touching the other two.
set -euo pipefail
export PGPASSWORD="$PGPASS"
for tenant in a b c; do
pg_dump -h 127.0.0.1 -U "app_TENANT" -Fc "app_TENANT" \
| aws s3 cp - "s3://my-backups/pg/TENANT/DATE.dump"
done
1
2
# crontab -e, 03:17 daily. Not 03:00: every backup job on earth fires at 03:00.
17 3 * * * /usr/local/bin/pg-backup.sh >> /var/log/pg-backup.log 2>&1
And the rule that goes with it: a backup you never restored is not a backup. I restore to a scratch database every month. If the restore fails, the backup is fiction.

The money part, briefly

I have $30 of AWS student credits. That is not a budget, it is a weekend.
The trap that almost got me: a NAT gateway bills hourly even when your EC2 instance is stopped. I had a tutorial open with a NAT gateway configured, and it would have quietly eaten the credits overnight while nothing was running. Nothing about a stopped EC2 protects you there.
So my rule is blunt: if an EC2 instance, RDS instance or NAT gateway is not needed today, I stop it or I delete it. Stopped is not free. RDS keeps storage while stopped, and snapshots bill separately from the instance. Free tier is for things you actually keep on. Anything I am experimenting with gets a calendar reminder.
AWS pricing models your infrastructure as something that runs forever. My infrastructure is three client websites on a box I can turn off. Cost is a design constraint, not an afterthought.

One mistake, and the rule

The mistake: I did not set mem_limit on my containers. One worker grew, one node process grew, and at 3am the OOM killer chose the wrong victim. The site went down at the worst possible hour.
The Prevention rule: every container gets mem_limit and restart: unless-stopped before it ever ships. Not after the first outage. Before.
It is a small rule. It catches a small class of disaster. That is most of engineering.

Closing

I am not against AWS. I am a student with $30 and three live sites, and my job is to keep those sites up while I learn. Right now the boring box does that and costs $8.
My plan: ship the boring thing first. S3 for files and backups. Watch the credits. Stop everything I am not using. Move the apps when I have a reason, not because a tutorial told me to.
Speed beats perfection. I have shipped working software for paying clients for a year now, and every single one of them is still online.
Any opinions in this article are those of the individual author and may not reflect the opinions of AWS.
Enjoyed reading this content? Let the author know!

Your likes, comments, shares, and saves help creators reach more builders.

Loading recommendations

Loading article