← Back to writing

Running production services on a free Oracle VM

Most of my side projects start the same way. Spin up a Next.js app, deploy it to Vercel, point a domain at it, ship. That stack handles the majority of quick apps that I like to build, and I love it for that.

Sometimes, it doesn't. Sometimes I need a little more control, or just a place to run a Python script without worrying about cold starts.

NearMint is one of those. It's an eBay price monitoring and Pokemon card collection platform I've been working on, and somewhere along the way it grew a Puppeteer scraper, a Python ML service for card detection, a Postgres database I wanted full control over, and a background worker that runs on a 2-minute cron cycle. None of that fits cleanly into the Vercel model. Some of it could, technically, but the cost math gets ugly fast and the cold starts kill the experience.

So I went looking for somewhere to host the heavy stuff. What I found was Oracle Cloud's Always Free tier, and it's been quietly doing the work ever since.

The setup

The VM is an ARM instance with 4 cores and 24GB of RAM. Oracle gives this away permanently as part of their free tier, and I'm still a little suspicious of how generous it is. I've been running real workloads on it for a few months and I've paid Oracle exactly zero dollars.

On that one machine, I'm running two completely separate applications. NearMint alone has five distinct services:

  • PostgreSQL as the primary database
  • A Puppeteer-based scraper for eBay listing data
  • A worker process for background jobs, alerts, and notifications
  • Chroma, a Python service that handles CLIP embeddings, OCR, and YOLO-based card detection
  • Caddy as the reverse proxy that fronts everything

ReddWords, my second app, runs alongside it doing its own thing.

Everything lives in Docker. The whole stack comes up through a docker-compose file, and deploying a change is one SSH and one shell script away.

Why everything is in Docker

I could have installed Postgres, Node, Python, and the rest of it directly on the VM. People do that. It works.

But the moment you want to deploy a change, you're suddenly thinking about systemd units, version pinning, Python virtualenvs, Node version managers, and which user owns which file. Multiply that by five services and the cognitive overhead becomes the actual work.

With Docker, every service has its own image, its own dependencies, its own networking, and its own restart policy. If a container dies, it comes back. If I want to rebuild one service without touching the others, I rebuild that container. If something gets corrupted, I throw the container away and spin up a fresh one from the image. The VM itself stays clean.

Deployment looks like this:

ssh -i ~/.ssh/oracle.key ubuntu@my-vm
cd /home/ubuntu/nearmint
bash deploy/deploy.sh

The script pulls the latest code, rebuilds whichever images changed, and restarts the containers that need restarting. From git push to running on the server takes about 90 seconds.

Locking it down with Caddy

The naive setup is to expose each service on its own port. Scraper on 3001, Chroma on 8100, and so on. Open the right ports in Oracle's firewall, point your web app at them, done.

That works until you remember a few things:

  1. You now have multiple HTTP endpoints to manage TLS for.
  2. Every public port is a separate attack surface.
  3. You're publishing your internal architecture to the internet.

So I put Caddy in front of everything.

Caddy is a reverse proxy that handles TLS automatically. You give it a domain, it talks to Let's Encrypt, and your services come up with HTTPS without you ever generating a certificate by hand. The configuration is also dramatically simpler than nginx. The whole Caddyfile for my stack is about 20 lines.

The architecture ended up looking like this. The Vercel-hosted web app sends requests to Caddy. Caddy looks at the path and routes accordingly: /scraper/* goes to the Puppeteer container, /chroma/* goes to the Python service. The internal services live on a Docker network that Caddy can reach but the public internet cannot.

That gives me one public entry point instead of three. One TLS configuration to maintain, one place to add rate limiting, request logging, or auth headers if I ever need to.

API keys and rate limits

Caddy gets you network-level protection. The services themselves still need application-level auth, because anyone who knows the URL could otherwise just hit them.

Both the scraper and the Chroma service check for an X-API-Key header on every request, except for health checks. The keys live in environment variables on the VM, and Vercel has matching keys in its env config so the web app can authenticate. If the keys aren't set, the services fall back to open mode, which is fine for local dev and a footgun in production.

Rate limiting lives at the application layer too. The scraper allows 100 requests per minute generally, with the expensive scrape endpoints capped at 20. The Chroma service has similar limits, with the embedding and OCR endpoints throttled harder than the cheap stuff. Nothing fancy. Just sliding-window counters in memory. But it means a runaway client can't turn my free VM into a 100% CPU bonfire.

Oracle's firewall

Oracle Cloud has its own concept of a firewall called a Security List, and it sits in front of the VM regardless of what iptables says on the host. Even if I forgot to lock down a port at the OS level, the cloud firewall has the final word.

My Security List has three rules and that's it:

| Port | Source | Purpose | |------|--------|---------| | 22 | My home IP | SSH | | 8080 | 0.0.0.0/0 | Caddy reverse proxy | | 5432 | Vercel egress IPs | PostgreSQL |

Everything else is closed. The internal service ports for the scraper, Chroma, and the worker aren't reachable from the internet at all. They exist only inside the Docker network.

Postgres was the one I went back and forth on. I considered putting it behind Caddy too, but Postgres has its own protocol with proper TLS support, and the connection only ever comes from a known set of Vercel IPs. Restricting at the firewall level was simpler than building a proxy, and the attack surface is tiny.

The boring parts that matter

A few things took me longer than they should have:

Logs. Docker captures stdout and stderr per container, and docker logs -f is your friend. I tried setting up a fancy log aggregator at one point and gave up. For a single VM, tailing logs works fine.

Backups. Postgres gets dumped nightly to object storage. The script is six lines. It runs in cron. I tested the restore once, which is the only thing that actually matters.

Monitoring. I get a Discord webhook when the worker fails an important job. That's the whole alerting system. Paging myself at 3am over a CPU spike on a side project would be silly.

Updates. Unattended-upgrades handles the OS patching. Docker images get rebuilt against fresh base images whenever I deploy. Nothing fancier than that, and nothing has bitten me yet.

What I'd tell someone thinking about it

Self-hosting has a reputation for being painful. A lot of that reputation comes from people running it the hard way. Bare metal, custom configs, hand-rolled TLS, no orchestration. Modern tools have closed most of that gap. Docker, Caddy, and a sensible firewall config will get you most of the way to a clean production setup with about a weekend of work.

The other thing I learned is that "free" can mean a lot of different things. Vercel free is generous but capped. AWS free is a 12-month timer with surprise bills waiting at the end. Oracle free is, as far as I can tell, actually free. The capacity is real, the constraints are reasonable, and the only price you pay is that the dashboard is a special kind of bureaucratic nightmare.

For anyone sitting on a side project that wants persistent compute, Postgres you actually own, or services that need to hold real models in memory, give Oracle's free tier a real look. Spin up a VM, install Docker, put Caddy in front, lock down the firewall. You'll be surprised how far that gets you.

I've been running production traffic on this thing for a few months now. It costs me nothing every month. As long as that stays true, I'll keep building on it.