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

ProviderFree tierVerdict
Oracle4 Arm OCPU / 24 GB, foreverChosen. Genuinely generous.
GCPe2-micro, 0.25 vCPU / 1 GB, foreverWorkable but tight
AWS12 months, then billedNot 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.

ButtonWhat it does
Create VCNThe VCN shell only - no subnets, no Internet Gateway, no route rules
Start VCN WizardVCN + 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:

1
10.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

FieldValueWhy
ShapeVM.Standard.A1.Flex, 1 OCPU / 6 GBMust show "Always Free eligible"
Capacity typeOn-demandPreemptible is not Always Free eligible
ImageCanonical Ubuntu 24.04-
Public IPv4AssignWithout it you can't SSH in
SSH keyPaste your public keyOnly chance - no password login
Fault domainLeave unspecifiedUnset 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:

1
ssh-keygen -t ed25519 -C "oracle-api" -f ~/.ssh/oracle_turborepo

Part 2: How to get an oracle arm instance when capacity is out

1
Out 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

  1. Cycle the ADs. Separate capacity pools, seconds apart. Most regions have one AD; Frankfurt has three.
  2. 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.
  3. Automate it. See below.
  4. Upgrade to Pay As You Go. PAYG is prioritised over trial accounts for A1 capacity. It improves your priority; it does not reserve capacity.
  5. Fall back to VM.Standard.E2.1.Micro. AMD, 1 GB RAM. Runs the API fine, but 1 GB will likely OOM during pnpm install + turbo build on the box - which changes the deploy model to building in CI and copying dist across, 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
7
brew 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:

  1. Lock directory - mkdir is atomic, so only one copy can run
  2. Success marker - a completed run refuses to start again
  3. Pre-flight census - abort if a non-terminated instance already exists
  4. Post-failure census - re-count after every failure
  5. 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:

1
if [ "$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:

1
RequestException: 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:

KeyDirection
oracle_turborepoyou and GitHub Actions → the box
id_deploythe 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:

1
sudo ufw allow 80/tcp

On Oracle's Ubuntu 24.04 image:

1
sudo: ufw: command not found

Not inactive. Not misconfigured. Absent. The image ships pre-seeded iptables rules instead:

1
2
3
4
5
6
num  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
4
sudo 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
4
case $- 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
4
curl -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
2
pm2 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
3
sudo 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
3
turboapi.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:

1
curl -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:

TypeValues
SecretsVPS_HOST, VPS_USER, VPS_SSH_KEY, RESEND_API_KEY, RESEND_AUDIENCE_ID
VariablesAPI_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:

1
ssh-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
3
git 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
2
sleep 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:

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?