How to deploy a NestJS API to a free Oracle Cloud VPS: Arm, Caddy, PM2
Piotr Żarów
CEO at Dev and Deliver
2026-09-15
#Development
Time to read
10 mins
In this article
Introduction
What is Oracle Cloud always free?
Short answer
Why move off Railway?
Part 1: The console fights you before any code
Part 2: How to get an oracle arm instance when capacity is out
Part 3: Server setup, and two things every guide gets wrong
Part 4: Caddy, and the 502 that means everything works
Part 5: The GitHub actions deploy
Is Oracle always free actually free?
FAQ
Closing: What Railway was actually selling
Share this article
Introduction
Railway's $5 credit ran out. Here's what it takes to run the same NestJS API for free, forever, on hardware you administer yourself.
What is Oracle Cloud always free?
Oracle Cloud Always Free is a set of cloud resources Oracle provides at no cost with no time limit, including Arm and AMD compute, block storage, and networking. Unlike a free trial it never expires - but the free Arm shapes are capacity-constrained and frequently unavailable. (Oracle)
Short answer
Oracle Cloud's Always Free tier runs a production Node.js API at no cost, permanently - the hard part is getting an instance, not configuring one. The VM.Standard.A1.Flex Arm shape allows up to 4 OCPU and 24 GB of RAM across the tier with no expiry (source: Oracle's published Always Free limits), but capacity is exhausted most of the time.
Getting ours took six hours and 163 rejected launch attempts across all three eu-frankfurt-1 availability domains before one freed up (measured: our own deployment, August 2026).
Once you have the instance, the stack is: Ubuntu 24.04, Node from NodeSource, PM2 for process management, and Caddy as a reverse proxy that obtains and renews TLS certificates automatically. Deploys run through GitHub Actions over SSH.
Budget an afternoon plus the wait. The API idles at 91 MB on a 6 GB instance (measured), so headroom is not the constraint - availability is.
apps/web stays on Vercel. This is only about apps/api - and about what Railway was doing for us that we now have to do by hand.
Why move off Railway?
Article 5 put the API on Railway. It worked, and I'd recommend it - railway.json in the repo root, no Dockerfile, first-class monorepo support. But the free credit is roughly 30 days of low traffic, and then it's $5-10/month.
For a starter repo whose whole point is that readers can clone and run it, "and then it costs money" is a bad ending. Oracle's Always Free tier has no clock on it.
The trade is honest: Railway abstracts the operating system, the reverse proxy, the process manager, TLS, and the deploy pipeline. Move off it and you own all five.
Why Oracle and not GCP or AWS
| Provider | Free tier | Verdict |
|---|---|---|
| Oracle | 4 Arm OCPU / 24 GB, forever | Chosen. Genuinely generous. |
| GCP | e2-micro, 0.25 vCPU / 1 GB, forever | Workable but tight |
| AWS | 12 months, then billed | Not worth writing about |
Our API idles at 91 MB. Any of these would run it. Oracle wins on headroom and on not expiring.
Part 1: The console fights you before any code
Three traps, all before an instance exists.
Create VCN vs. start VCN wizard: why the subnet dropdown is empty
The Virtual Cloud Networks page has Create VCN and Start VCN Wizard side by side.
| Button | What it does |
|---|---|
| Create VCN | The VCN shell only - no subnets, no Internet Gateway, no route rules |
| Start VCN Wizard | VCN + public subnet + private subnet + Internet Gateway + NAT gateway + route tables |
Use Start VCN Wizard → "Create VCN with Internet Connectivity".
Picking the wrong one fails silently. Nothing errors. You discover it two screens later, in the instance form, where the subnet dropdown is empty and the public-IP toggle sits disabled behind: 'You must select a public subnet to assign a public IPv4 address'
Creating the VCN inline from the instance form has the same failure mode - it can hand you a private subnet with no gateway. Build the network separately, first.
Why Oracle says '10.0.0.0/16 is not a valid CIDR block'
The wizard wants three values in three separate fields: {table3}
Put all three into the VCN's CIDR list and you get:
110.0.0.0/16 is not a valid CIDR block
The block is perfectly valid. A VCN's CIDR blocks may not overlap each other, and /16 contains both /24s. The message describes a syntax problem; the actual problem is set arithmetic.
Why Oracle shows ~$2/month for a free instance
The instance form quotes something like $2/month for the boot volume and labels it "Estimated total". On a tier advertised as free.
It's list price. The estimator never subtracts Always Free allowances. Boot volumes count against the 200 GB of block storage Always Free includes, so a ~47 GB volume is covered and you won't be billed. Verify it yourself after a day in Billing → Cost Analysis, which shows real charges rather than estimates, and set a budget alert while you're there.
Instance settings that matter
| Field | Value | Why |
|---|---|---|
| Shape | VM.Standard.A1.Flex, 1 OCPU / 6 GB | Must show "Always Free eligible" |
| Capacity type | On-demand | Preemptible is not Always Free eligible |
| Image | Canonical Ubuntu 24.04 | - |
| Public IPv4 | Assign | Without it you can't SSH in |
| SSH key | Paste your public key | Only chance - no password login |
| Fault domain | Leave unspecified | Unset lets Oracle try all of them |
Ask for 1 OCPU / 6 GB rather than the full 4 / 24 the tier permits. The API needs 91 MB. A smaller request is meaningfully more likely to find capacity, and you can scale up later.
Generate the key before creating the instance, with no passphrase - the GitHub Action in Part 5 can't unlock a protected key non-interactively:
1ssh-keygen -t ed25519 -C "oracle-api" -f ~/.ssh/oracle_turborepo
Part 2: How to get an oracle arm instance when capacity is out
1Out of capacity for shape VM.Standard.A1.Flex in availability domain AD-1.
Then AD-2. Then AD-3. Then the same three again, for hours. As of August 2026 this is the normal state of Oracle's free Arm tier in eu-frankfurt-1, not an outage. Capacity varies by region and over time, so treat the six-hour figure as one data point, not a benchmark.
Two Oracle messages that look like capacity errors and aren't
"You can create Ampere A1 compute instances in any availability domain." This is about eligibility, not availability. The shape carries no AD restriction; that says nothing about whether a host is free. Allowed everywhere, available nowhere is the normal state.
"You can create instances using the VM.Standard.E2.1.Micro shape in AD-3." Always Free micro instances are AD-restricted, and this names the one AD where they're permitted. Only relevant if you take the AMD fallback.
The ladder
- Cycle the ADs. Separate capacity pools, seconds apart. Most regions have one AD; Frankfurt has three.
- Retry off-peak. Roughly 02:00-06:00 local is the widely-reported sweet spot, as other tenants terminate instances. Estimated - a community heuristic we did not verify, not a measurement.
- Automate it. See below.
- Upgrade to Pay As You Go. PAYG is prioritised over trial accounts for A1 capacity. It improves your priority; it does not reserve capacity.
- Fall back to
VM.Standard.E2.1.Micro. AMD, 1 GB RAM. Runs the API fine, but 1 GB will likely OOM duringpnpm install+turbo buildon the box - which changes the deploy model to building in CI and copyingdistacross, or adding swap.
Automating the lottery
Clicking a console at 3am is the wrong use of a human. polls every AD on an interval and claims the first slot that frees.
1 2 3 4 5 6 7brew install oci-cli oci setup config # generates an API signing key # paste the printed public key into Profile → User Settings → API Keys ./deploy/oracle-capacity-retry.sh --discover # prints your OCIDs ./deploy/oracle-capacity-retry.sh --dry-run # resolves config, launches nothing ./deploy/oracle-capacity-retry.sh # the real loop
Poll faster than ~60s and you earn 429 TooManyRequests, which slows you down rather than speeding you up.
How to retry safely without creating duplicate instances
A naive retry loop can produce several and blow past Always Free limits, which is worse than getting none. Five guards:
- Lock directory -
mkdiris atomic, so only one copy can run - Success marker - a completed run refuses to start again
- Pre-flight census - abort if a non-terminated instance already exists
- Post-failure census - re-count after every failure
- Hard exit on success - never loop after a win
The fourth is the subtle one. A launch can succeed on Oracle's side and still return non-zero. A timeout looks identical whether the request landed or not. A loop that trusts the exit code alone will happily create a second instance.
Two bugs worth the whole article
The guard that read as permission to launch.
The OCI CLI prints nothing - exit code 0, empty body - for an empty list. Not empty JSON. So jq emitted an empty string, and this:
1if [ "$count" -gt 0 ]; then
raised integer expression expected, which bash evaluates as false: "no instance exists, go ahead and launch."
It happened to be the right answer when the list really was empty. But any census failure produced the same empty string - a swallowed API error, a network blip, a missing jq. The guard designed to prevent a second instance would have waved it through. Fail-dangerous: the exact inverse of what a guard is for.
The test suite never caught it, because the stubbed CLI always returned well-formed JSON. It only surfaced against the real API.
The census now checks the exit status before interpreting the body, treats an empty body as zero only on a successful call, and returns an explicit error for anything non-numeric. Callers halt on that error rather than guessing.
Aborting a six-hour run on a network blip.
The first long run died at 163 capacity misses on a single:
1RequestException: The connection to endpoint timed out.
Errors were classified capacity-or-fatal, so the most ordinary thing to hit in a multi-hour loop was treated like a bad credential. The whole wait was wasted.
Errors now sort into three buckets:
| Key | Direction |
|---|---|
| oracle_turborepo | you and GitHub Actions → the box |
| id_deploy | the box → GitHub |
Retrying a timed-out launch is only safe because the post-failure census runs first and confirms nothing was created. The two fixes depend on each other.
oracle-capacity-retry.test.sh stubs the CLI and asserts all of this - 19 assertions, no Oracle account needed.
Part 3: Server setup, and two things every guide gets wrong
Why 'ufw: command not found' on Oracle's Ubuntu image
Every guide - including the first draft of our own runbook - says to open ports with:
1sudo ufw allow 80/tcp
On Oracle's Ubuntu 24.04 image:
1sudo: ufw: command not found
Not inactive. Not misconfigured. Absent. The image ships pre-seeded iptables rules instead:
1 2 3 4 5 6num target prot source destination 1 ACCEPT all 0.0.0.0/0 0.0.0.0/0 state RELATED,ESTABLISHED 2 ACCEPT icmp 0.0.0.0/0 0.0.0.0/0 3 ACCEPT all 0.0.0.0/0 0.0.0.0/0 4 ACCEPT tcp 0.0.0.0/0 0.0.0.0/0 state NEW tcp dpt:22 5 REJECT all 0.0.0.0/0 0.0.0.0/0 reject-with icmp-host-prohibited
Rule 5 rejects everything that didn't match above it. And the obvious fix doesn't work either: iptables -A INPUT appends below the REJECT, creating a rule that exists, reads correctly in the output, and never matches.
Insert above it:
1 2 3 4sudo iptables -I INPUT 5 -p tcp --dport 80 -j ACCEPT sudo iptables -I INPUT 5 -p tcp --dport 443 -j ACCEPT sudo iptables -L INPUT -n --line-numbers # 80 and 443 must sit ABOVE the REJECT sudo netfilter-persistent save # or a reboot wipes it
Oracle blocks ports at two layers, not one
Ports must be open in both the VCN Security List (cloud) and the host firewall. Traffic dies if either is closed. This is the single most common reason a freshly-provisioned Oracle instance serves nothing, and it's why people conclude the free tier is broken.
Port 80 is not optional even for an HTTPS-only API - Let's Encrypt validates over it.
Why 'node: command not found' in an SSH deploy (and why nvm causes It)
nvm is built for developer machines, where you switch versions per project. It loads from ~/.bashrc, and Ubuntu's ~/.bashrc returns early for non-interactive shells:
1 2 3 4case $- in *i*) ;; *) return;; esac
An SSH deploy opens exactly that kind of shell. So node -v works when you log in by hand and fails inside CI with node: command not found. Two failed deploys went to this.
Sourcing nvm.sh in the deploy script isn't enough either - that loads nvm without selecting a version, so node still isn't on PATH unless a default alias happens to exist.
Install system-wide instead:
1 2 3 4curl -fsSL https://deb.nodesource.com/setup_22.x | sudo -E bash - sudo apt install -y nodejs sudo corepack enable sudo npm install -g pm2
/usr/bin/node is on PATH for every shell, with nothing to source.
'pm2 startup' prints a command it does not run
This costs nothing until your first reboot, then costs everything:
1 2[PM2] To setup the Startup Script, copy/paste the following command: sudo env PATH=$PATH:/usr/bin /usr/lib/node_modules/pm2/bin/pm2 startup systemd -u ubuntu --hp /home/ubuntu
That output looks like a confirmation. It's an instruction. Copy and run it, then:
1 2pm2 save # writes the process list to ~/.pm2/dump.pm2 systemctl is-enabled pm2-ubuntu # must say: enabled
Both steps are required. The systemd unit resurrects whatever pm2 save recorded, so a unit without a saved dump starts PM2 with zero apps - a service that runs and hosts nothing.
If you installed PM2 under nvm and later moved to a system Node, re-run pm2 startup; the old unit bakes in an nvm path that won't resolve at boot.
Then actually reboot and check, rather than trusting the configuration:
1 2 3sudo reboot # ~60s later, from your own machine: curl -sS https://your.domain/health
Ours came back clean - iptables rules, Caddy, the certificate and the API all unattended. Verifying at your leisure beats discovering it during an unplanned restart.
Part 4: Caddy, and the 502 that means everything works
How Caddy gets HTTPS in three lines
1 2 3turboapi.devanddeliver.com { reverse_proxy localhost:3001 }
From that alone, Caddy requested a Let's Encrypt certificate, proved domain ownership over port 80, installed it, set up renewal, added the HTTP→HTTPS redirect, and enabled HTTP/2 and HTTP/3.
The nginx equivalent is two server blocks, explicit ssl_certificate paths, five proxy_set_header lines, plus certbot, plus a renewal timer - and it has a genuine chicken-and-egg on first setup, because nginx won't start while referencing certificate files that don't exist yet.
Caddy isn't more capable than nginx. nginx wins when you need fine control over caching, rate limiting or load balancing, or when you're inheriting an existing config. Caddy wins for exactly this shape of job: one service, one domain, where certificate management is the only genuinely fiddly part.
Point your DNS A record at the instance before reloading Caddy - validation needs the name to resolve.
On first run, the log contains a no such file or directory error that reads like a failure and isn't. It's Caddy registering an ACME account it hasn't created yet. Look for certificate obtained successfully.
Why a 502 over HTTPS means your setup is working
Configure Caddy and confirm HTTPS returns 502 before deploying the application:
1curl -sI https://your.domain/ | head -1
A 502 here is the correct result. It means DNS, the VCN Security List, the host firewall, the certificate and the reverse proxy are all working, and Caddy is faithfully proxying to a port where nothing is listening yet.
That distinction is worth buying deliberately. Deploy the app first and hit a problem, and you're debugging six layers at once. Get to a clean 502 and everything after it is application-level by definition.
Part 5: The GitHub actions deploy
.github/workflows/deploy-api.yml clones on first run, then fetches, rebuilds and reloads under PM2. Keep it on workflow_dispatch until it's proven - a push-triggered deploy failing on its first attempt is hard to tell apart from a broken repo.
Three failures, in the order we hit them.
1. 'Error: missing server host'
An empty secret. The message names neither the secret nor the cause. The most common reason is adding the values as Environment secrets, which a job without an environment: key cannot see at all.
Worth adding a preflight step that says which secret is missing.
2. 'node: command not found'
The nvm problem from Part 3.
3. A private repo can't be cloned anonymously
The box pulls the repo, so it needs its own read-only credential - a deploy key. This is a second key pointing the opposite way to the one you SSH in with:
| Type | Values |
|---|---|
| Secrets | VPS_HOST, VPS_USER, VPS_SSH_KEY, RESEND_API_KEY, RESEND_AUDIENCE_ID |
| Variables | API_PORT, ALLOWED_ORIGIN, MAIL_FROM, SITE_URL, SITE_NAME |
Conflating them is an easy way to grant more access than intended. Add the public half under repo → Settings → Deploy keys, with Allow write access unchecked.
Also seed known_hosts in the workflow. Git fails host-key verification in a non-interactive shell, because there's no prompt to accept it:
1ssh-keygen -F github.com >/dev/null || ssh-keyscan -t ed25519 github.com >> ~/.ssh/known_hosts
Config belongs in the repo, not on the box
The workflow renders apps/api/.env from repository config on every run. Write it by hand on the instance and the box becomes a machine only one person knows the state of; rebuild it and you're reconstructing environment variables from memory.
Split by sensitivity: {table8}
Variables stay visible in the UI, which makes misconfiguration easy to spot. Secrets don't, which is the point.
ALLOWED_ORIGIN is the one that bites. Leave it unset and the API falls back to localhost:3000, the deploy still reports success, and the failure appears much later as an opaque CORS error in someone's browser. Warn on it loudly.
Use 'reset', not 'pull'
1 2 3git fetch --prune origin git checkout -B "$DEPLOY_REF" "origin/$DEPLOY_REF" git reset --hard "origin/$DEPLOY_REF"
The box is a deploy target, not a place anyone edits. A merge conflict there would wedge every future deploy. .env is gitignored, so the reset never touches it.
Fail the deploy if the app isn't answering
1 2sleep 3 curl -fsS http://localhost:3001/health >/dev/null
Without this, a crashed application still reports a green deploy.
Is Oracle always free actually free?
The instance bills €0 and will continue to.
If A1 capacity never frees for you, Pay As You Go is the reliable fix - and it's worth understanding precisely, or you'll get a surprise bill. Always Free allowances still apply on a PAYG account, so the same A1 instance inside those limits bills €0 exactly as it did on the trial.
What you give up is the trial's hard ceiling. On the trial, overspending is impossible. On PAYG, anything outside the Always Free envelope - a larger shape, a second block volume, a load balancer - bills at list price with nothing to stop it.
Free as long as you stay inside the lines, and no wall stopping you from stepping outside them. Set a budget alert the moment you upgrade.
FAQ
Is Oracle cloud always free really free forever?
Yes, with no time limit, unlike AWS's 12-month tier. Always Free includes up to 4 Arm OCPU and 24 GB of RAM across VM.Standard.A1.Flex instances, 200 GB of block storage, and two AMD VM.Standard.E2.1.Micro instances (Oracle). The instance in this article bills €0 (measured: Billing → Cost Analysis, August 2026). The constraint is availability, not cost.
Why do I keep getting "Out of capacity" on Oracle arm instances?
Free-tier Arm capacity is genuinely exhausted most of the time, especially in older regions like eu-frankfurt-1. Try each availability domain separately, retry off-peak, or script the retry. Upgrading to Pay As You Go raises your priority for the same free instance without making it billable.
Does upgrading to pay as you go make my Oracle instance cost money?
No, provided you stay inside Always Free limits - those allowances still apply on a PAYG account. What changes is that the trial's hard spending ceiling disappears, so provisioning anything outside the free envelope bills at list price. Set a budget alert immediately after upgrading.
Why does 'ufw' not work on my Oracle Ubuntu instance?
It isn't installed. Oracle's Ubuntu images ship pre-seeded iptables rules ending in a catch-all REJECT. Insert rules above that REJECT with iptables -I INPUT 5, not below it with -A, then persist them with netfilter-persistent save.
Why does my SSH deploy say 'node: command not found' when Node is installed?
Because Node was installed with nvm. nvm loads from ~/.bashrc, and Ubuntu's ~/.bashrc returns early for non-interactive shells - which is what an SSH deploy opens. Install Node system-wide from NodeSource so it lives in /usr/bin and is on PATH for every shell.
Do I need nginx, or is Caddy enough?
Caddy is enough for a single service behind a single domain, and it handles certificate issuance and renewal automatically in three lines of config. Choose nginx when you need fine-grained caching, rate limiting or load balancing, or when you're inheriting an existing configuration.
How do I let a VPS clone a private GitHub repository?
Generate a keypair on the server and add the public half as a deploy key on the repository, with write access disabled. This is a separate key from the one you use to SSH into the box, pointing in the opposite direction.
Closing: What Railway was actually selling
Railway abstracts the operating system, the reverse proxy, the process manager, TLS, and the deploy pipeline. That's what $5-10/month buys, and it's a fair price.
Doing it by hand costs a capacity lottery, an afternoon, and knowing that ufw isn't installed and that iptables -A appends below the REJECT. In exchange you get a box that's free forever, room for more services on the same hardware, and a stack you can actually reason about when it breaks.
For a starter repo whose premise is "clone this and read it", that second option is the better ending.
The full runbook lives in , kept accurate as we went rather than reconstructed afterwards. Series:
- [1] Monorepo setup · [2] Landing page · [3] Contentful · [4] Cal.diy embed · [5] Vercel + Railway · [6] Oracle Cloud VPS
Piotr Żarów
CEO at Dev and Deliver
Share this post
Related posts
Want to light up your ideas with us?
Kickstart your new project with us in just 1 step!
Prefer to call or write a traditional e-mail?









