Last March, I stared at my AWS bill and felt a slow, creeping dread. It was $217. The month before, it had been $194. Six months earlier, it was $82. I was running a small web app, a task queue, a managed database, and a file storage bucket. None of it had heavy traffic. The app had maybe 900 active users, and the backend spent most of its time idle. Yet my bill was climbing like I was serving a million requests per minute. Something was fundamentally wrong, and I needed to fix it before my side project ate my savings alive.
This is the story of how I moved everything from a fragmented cloud setup to a single Virtual Private Server, cut my monthly bill by 60 percent, and learned more about infrastructure than I ever wanted to know. It is also a story about what I sacrificed, where I got lucky, and the one mistake that nearly cost me all my user data.
The Old Setup: Death by a Thousand Services
My app, a lightweight inventory manager for small repair shops, ran on a combination of AWS services that had grown organically and without discipline. When I first launched it, I followed the tutorials that promised scalability and best practices. I had an EC2 t3.medium instance running the Node.js backend, an RDS t3.small instance for the PostgreSQL database, an ElastiCache node for session storage that I barely used, an S3 bucket for user uploads, and a CloudFront distribution because someone on a forum said I needed a CDN. There was also a Lambda function triggered by S3 uploads to generate thumbnails, which cost almost nothing but added cognitive weight.
The setup worked. It was “correct” in the way cloud architecture diagrams are correct. But it was a financial disaster for a bootstrapped app with modest traffic. The RDS instance alone cost $70 a month. ElastiCache added another $28. The EC2 instance sat at single-digit CPU usage 98 percent of the time, but I was paying for it around the clock. The data transfer costs between services, tiny as they were individually, added up. Every month I told myself I would optimize, and every month I paid the bill and moved on because I was afraid of breaking something.
Then one evening I did the math. Over a year, I had paid AWS over $2,100. The app had earned $1,400 in that same period. I was losing money on a project that was supposed to supplement my income. That math was impossible to ignore. I decided to see if I could run the entire application on a single server, the way I had done with personal projects years ago, before the cloud convinced me I needed a distributed system for a few hundred users.
Picking the VPS and the New Architecture
I chose a single VPS from a provider I had used for small experiments: a $24 per month plan with 4 vCPUs, 8GB of RAM, and 160GB of NVMe storage. I added automatic backups for an extra $6, bringing the total to $30. That was less than a third of my AWS bill. The plan was to run everything on this one machine: the Node.js app, PostgreSQL, Redis (to replace ElastiCache), and file storage on the local disk with a simple cron job to sync uploads to a cheap S3-compatible object storage bucket for offsite backup. No managed services, no separate database instances, no CDN. Just one box, properly configured.
I knew this came with risks. A single server is a single point of failure. If the machine went down, the entire app went down. I accepted that tradeoff because my users were small repair shops that used the app during business hours. A few hours of downtime on a Sunday night was survivable. Losing money every month was not.
The Migration: Moving Without Breaking Everything
I gave myself a weekend to complete the migration. That was optimistic. It took five days of evening work, a lot of coffee, and one panicked rollback that taught me never to skip a database dump step.
Day one was setup. I provisioned the VPS with Ubuntu, secured it with a firewall, fail2ban, and SSH key-only access, then installed Docker. I used Docker Compose to define three services: the Node.js app, a PostgreSQL container, and a Redis container. I wanted isolation without the complexity of multiple machines. The app container connected to the database and Redis containers over a Docker network, the same way they had connected on AWS but without the network latency and transfer costs.
Day two was the database migration. I dumped my RDS database using pg_dump, scp’d the file to the VPS, and restored it into the new PostgreSQL container. The dump was 2.3GB, and the restore took nearly an hour. I sat there watching the terminal, convinced the connection would drop and corrupt everything. It didn’t. I ran a few test queries, and the data looked intact. I felt a surge of relief that lasted about ten minutes, because then I discovered the application couldn’t connect to the database at all. I had misconfigured the Docker Compose network alias, and the app was trying to reach “db” when the service was named “postgres.” A simple fix, but it cost me two hours of debugging.
Day three was file storage. On AWS, user uploads went to an S3 bucket, and the app referenced them via CloudFront URLs. On the VPS, I stored uploads in a local directory mounted into the Docker container. I had to update every file reference in the application code from the S3 URL pattern to a local path. I missed a few, which resulted in broken images that users reported within hours of the switch. After a grep through the codebase and a quick patch, the images returned. I also set up a nightly cron job that rsync’d the uploads directory to a cheap object storage bucket, providing an offsite backup in case the VPS disk failed.
Day four was the DNS cutover. I pointed the app’s domain from the AWS load balancer to the new VPS IP address. DNS propagation is a lesson in patience. I watched traffic gradually shift from the old setup to the new one over several hours. Some users hit the old server, some hit the new one, and I had to leave the old server running for a full day to avoid losing anyone. During that overlap, I kept the database sync’d by running a final pg_dump and restore, so data written to the old server during propagation wouldn’t be lost. It was messy, but it worked.
Day five was the cleanup. I confirmed that all traffic was hitting the VPS, that uploads were working, and that the nightly backup had completed successfully. Then I terminated the AWS resources one by one, like pulling plugs from life support. The RDS instance was the last to go. Clicking “delete” on a database that had run for two years felt like cutting a safety rope, but it also felt liberating.
The Numbers: A 60 Percent Drop, and a Few Surprises
After the first full month on the VPS, my bill was $30. The object storage for offsite backups added $4. My new total was $34. My old AWS bill had been $217. That is a reduction of about 84 percent, but I rounded down to 60 percent in the title because I had to factor in the time I spent on the migration. If I charged myself my freelance rate for those five evenings, the migration cost around $800 in opportunity cost. Over a year, the savings still came out to roughly $2,000, which is why I called it a 60 percent cut in practical terms. But the raw monthly reduction was even steeper.
Performance was the bigger surprise. The app felt faster. Page loads that took 400ms on AWS now took 90ms. The database queries, which previously traveled over the network from EC2 to RDS, now ran on the same machine over a Unix socket. Redis responses were nearly instantaneous. The CPU usage on the VPS never exceeded 15 percent, even during peak hours. I had overprovisioned the VPS, but for $24 a month, I didn’t care. I had headroom for growth.
There was one unexpected cost I hadn’t planned for. The S3-compatible object storage I used for offsite backups had a minimum monthly charge and a per-request fee that added up faster than I expected. My first month’s backup bill was $12, not $4. I switched to a different provider with a simpler pricing model and got it down to $5. Infrastructure pricing is full of these hidden traps, and you never see them until you’re already committed.
The Near Miss: How I Almost Lost the Database
Two months into the new setup, I made a mistake that still makes my stomach tighten when I think about it. I was updating the PostgreSQL Docker image to a new minor version, following the standard Docker Compose pull and recreate steps. I had done this before without issue. But this time, I accidentally omitted the volume mount definition in my docker-compose.yml file while trying out a new configuration. Docker Compose created a fresh, empty database container with no data. My application started, connected to the empty database, and began serving blank pages.
I noticed within minutes because a user emailed me saying their inventory was gone. I rushed to the server, saw the mistake, and fixed the volume mount. But the new container had already overwritten the empty data directory. My heart stopped. Then I remembered the nightly backup. I had a dump from 14 hours earlier. I restored it, and the app came back to life with a day of lost data. I emailed my users, apologized, and explained what happened. Most were understanding. One customer was angry, and I gave them a free month. It was a cheap lesson in humility and the absolute necessity of automated backups that you have actually tested.
After that incident, I implemented a local backup script that ran every six hours, not just nightly, and I added a health check endpoint that verified the database contained recent records. If the check failed, I got a text message. That small safety net cost me nothing but a few lines of bash, and it has saved my sleep many times since.
What I Sacrificed by Leaving the Cloud
Moving to a single VPS was not a pure win. I traded managed services for operational responsibility. When the VPS kernel needed a security patch, I had to apply it and reboot. When the disk filled up with old Docker images, I had to prune them. When I wanted to scale horizontally, I couldn’t just add another instance behind a load balancer; I would have to redesign the architecture. For my current traffic level, none of this matters. But I am aware that if the app grows significantly, I will face a new migration someday.
I also lost the automatic failover that AWS provided. My VPS has gone down exactly once in six months, for a host machine maintenance event that lasted 12 minutes. On AWS, that would have been handled invisibly. On a single VPS, the app was simply unavailable. Twelve minutes is nothing, but it reminded me that I am one hardware failure away from a longer outage. I have accepted that risk for now, but I monitor the server closely and keep a manual runbook for spinning up a replacement on another provider if needed.
What I’d Do Differently
If I could rewind and do this migration again, I would change three things.
I would test the backup restoration process before the migration. The near miss with the Docker volume taught me that a backup you have not restored is a wish, not a backup. I should have restored my pg_dump into a fresh container and verified the data before I ever touched the production database. That single test would have prevented the stomach-dropping panic when the volume mount disappeared.
I would set up a staging environment on the VPS first. I migrated directly from AWS to the production VPS without a staging step. I was lucky that the only broken thing was the database connection string. A staging environment on the same VPS, using different ports and a test subdomain, would have caught that and other issues before real users saw them. It would have added a day to the migration, and that day would have been worth far more than the stress I endured.
I would document every manual step as I performed it. During the DNS cutover, I ran a series of one-off commands that I did not write down. Later, when I wanted to understand exactly what I had done, I had to piece it together from shell history. A simple text file with timestamps and commands would have been a permanent reference and an insurance policy for future me. I now keep a plain-text operations log for every server change, no matter how small. It has saved me hours of detective work.
Is a Single VPS Right for You?
For my small, revenue-generating side project, the single VPS approach has been a financial and operational win. The app runs faster, costs a fraction of what it used to, and I have a deeper understanding of every component because I set it up myself. But I would not recommend it to everyone. If your application requires high availability, if you cannot tolerate any downtime, or if you do not have the time or interest to manage your own server, managed cloud services are still the right choice. They are more expensive because they solve real problems that a single VPS leaves unsolved.
What I have learned is that the cloud’s default architectures are designed for scale, and most small projects do not need scale. They need simplicity. A single server, carefully configured and backed up, can serve thousands of users with room to spare. The cloud marketing machine will tell you that you need a constellation of services to be production-ready. My experience says otherwise. Sometimes the best architecture is the one that fits on a single machine and costs less than a dinner out.
