Automationintermediate

Coolify on Oracle ARM in 2026: How I Run 15-20 Apps From One Free Box

The actual install-to-first-deploy walkthrough I use as my empire PaaS. Git-push deploys, PocketBase backends, zero open ports, and the three gotchas that ate hours before I wrote them down.

By··8 min read·Reviewed
Coolify Self-Hosting on Oracle ARM - 2026 Walkthrough (Free Forever)

Coolify is the control plane for my whole stack. One free Oracle A1 box in Mumbai, four OCPUs, 24GB of RAM, Coolify on top deploying 15 to 20 small production apps from a Git push. Every app gets a build, a container, a database if it needs one, a reverse-proxy route, and TLS, all from one dashboard. The monthly platform bill is zero rupees.

This is install-to-first-deploy, not a tour of Oracle's free-tier ceilings. I wrote a separate piece on what the Always Free tier gives you and the capacity wall that stops people launching at all. Read that for the limits. This one gets Coolify running, deploys your first app the way I deploy mine, and covers the three operational traps that cost me real hours and sit in no official doc. Targets: Coolify v4.x, Ubuntu 24.04 LTS, Docker Engine 24+, Oracle's VM.Standard.A1.Flex Arm shape.

Why Coolify, and why this shape of it

Coolify gives you the Heroku flow without the Heroku invoice: connect a repo, push, and it builds and ships. The difference from a managed PaaS is that you own the box, the updates, the disk, and the backups. For a bootstrapped multi-app setup that trade is worth it. Fifteen small services on a paid platform is a four-figure annual line item. On one free A1 box it is zero.

I run one fat 4-OCPU / 24GB instance, not four thin ones. Coolify plus a real database layer wants RAM headroom more than extra nodes, and the first ceiling on this workload is never CPU. It is memory, when too many Node builds run at once.

Before you create the VM

Collect these first:

  • An OCI account with a home region where A1 capacity is available.
  • An Ed25519 SSH key pair.
  • A domain you control plus a DNS provider. I front everything through Cloudflare.
  • A backup target outside the VM: Cloudflare R2, S3, or Backblaze B2. The VM is disposable; your data is not.

Generate a key if you do not have one:

ssh-keygen -t ed25519 -f ~/.ssh/oci_coolify -C "coolify-oracle-a1"
cat ~/.ssh/oci_coolify.pub

Paste only the .pub value into the OCI console. The private key never leaves your machine.

Create the Oracle ARM instance

In the OCI console: Compute, then Instances, then Create instance.

Setting Value I use
Name coolify-a1
Image Ubuntu 24.04 LTS, or 22.04 if 24.04 is unavailable
Shape VM.Standard.A1.Flex
OCPU 4
Memory 24 GB
Boot volume 50 GB, counts against the 200 GB free block allowance
Networking Public subnet, assigned public IPv4

Out of host capacity is a scheduling queue, not your quota, and switching to a paid shape is the wrong reflex. The fix is in the free-tier-limits article. Do not pay your way past it by accident.

SSH in once the VM is running:

ssh -i ~/.ssh/oci_coolify ubuntu@YOUR_VM_IP
sudo apt update && sudo apt upgrade -y
sudo apt install -y curl git ufw fail2ban

The firewall decision that shapes everything after

First fork. Either expose Coolify on public ports 80 and 443 for direct Let's Encrypt, or keep every ingress port shut and route traffic through a Cloudflare Tunnel. I run the tunnel. On a free box with no SLA and a public IP I do not want probed, zero open ingress ports is the correct posture, with Cloudflare's DDoS shield free on top.

If you go the direct route, open exactly what you need:

sudo ufw allow OpenSSH
sudo ufw allow 80/tcp
sudo ufw allow 443/tcp
sudo ufw enable

OCI has its own network layer. If the box does not answer on 80 and 443, the subnet security list or network security group is blocking it. Both ufw and OCI security rules have to allow the traffic, and forgetting the OCI side is the most common reason TLS issuance silently fails. The tunnel route opens neither port. SSH only, and the tunnel section below is where this pays off.

Install Coolify

On the fresh server:

curl -fsSL https://cdn.coollabs.io/coolify/install.sh | sudo bash

The installer pulls Docker Engine 24+, creates /data/coolify, and starts the stack. When it finishes it prints a URL on port 8000. Open it immediately and create the admin user. The first-time registration screen is unauthenticated, so whoever completes setup first owns the panel. Do this in the same minute the installer finishes.

Verify from SSH:

docker ps
docker logs --tail=100 coolify

One trap on Arm: do not use Docker installed through Snap. Coolify's own docs call it unsupported, and the failures are confusing rather than obvious. Let the installer manage Docker.

Wire the Cloudflare Tunnel

Add a tunnel through Cloudflare, then run cloudflared. The clean pattern is fixed hostnames for the Coolify dashboard and a wildcard for your apps:

coolify.example.com   ->  the dashboard
*.apps.example.com    ->  the Coolify proxy, which routes to each app

Cloudflare matches ingress rules in array order, top to bottom. Any specific hostname rule has to sit before the wildcard catch-all, or the wildcard swallows it and you get the wrong backend. The fixed Coolify subdomains go first, your specific app rules next, the * rule last.

If you script any of this against the Cloudflare API rather than the dashboard, read the Cloudflare API token gotchas first: the self-update endpoint silently drops scopes, which wiped one of my tokens twice.

The trap that cost me a full evening

This is the one I have not seen documented, and it is why this article exists.

When you run Coolify, your cloudflared almost certainly runs as a container inside Coolify's own Docker network. Inside that container, localhost and 127.0.0.1 mean the container, not the Oracle host. Point a tunnel ingress rule at http://127.0.0.1:8090 expecting to reach a PocketBase instance on the bare-metal host, and that loopback resolves to the cloudflared container's own loopback. You get a 502 Bad Gateway from the tunnel while every curl on the host works perfectly. You will suspect DNS, the cert, the app, and Cloudflare itself before the network namespace.

The fix is to address the host from inside the container using the Docker bridge gateway:

# cloudflared ingress rule
# WRONG when cloudflared runs inside the Coolify docker network:
#   service: http://127.0.0.1:8090
# RIGHT, reach the bare-metal host service via the bridge gateway:
ingress:
  - hostname: pb.example.com
    service: http://10.0.2.1:8090
  - service: http_status:404

10.0.2.1 is the Oracle host's address on Coolify's Docker bridge network, reachable from any container on that bridge. The host process has to bind where the bridge can reach it, so bind it to 0.0.0.0, not a strict 127.0.0.1.

The rule of thumb, burned in: a containerised tunnel reaches a bare-metal host service through the bridge gateway 10.0.2.1, and a co-networked container through its service name like http://coolify:8080, never through localhost. Get that one line right and the entire zero-open-ports design holds.

Deploy your first app from a Git push

The part Coolify exists for. A tiny Node app with a Dockerfile, listening on the port Coolify hands it through the environment:

// server.ts
import http from "node:http";
const port = Number(process.env.PORT ?? 3000);
http
  .createServer((_req, res) => {
    res.writeHead(200, { "content-type": "text/plain" });
    res.end("Hello from Coolify on Oracle ARM\n");
  })
  .listen(port, "0.0.0.0", () => console.log(`listening on ${port}`));
FROM node:20-alpine
WORKDIR /app
COPY package*.json ./
RUN npm ci
COPY . .
RUN npm run build
EXPOSE 3000
CMD ["npm", "start"]

Push to GitHub. In Coolify: new project, add an application, connect the repo, choose the Dockerfile build, set the exposed port to 3000, set the domain, deploy. Every push to your tracked branch redeploys. The whole empire workflow is that one loop. If your CI builds run on GitHub-hosted runners, point them at this same box instead: a self-hosted GitHub Actions runner on Oracle ARM takes the build off GitHub's free-minutes meter entirely.

If the build fails on Arm, suspect the base image. Official Node, Python, Postgres, Redis, Caddy, and Nginx all ship Arm64 variants. Old images, private vendor images, and native binaries are where day-one failures hide.

Two API quirks that cost me time

Most days I deploy through the dashboard. When I script deploys through the API, two things bit me hard enough to write down.

First, the env-var endpoint. POST /api/v1/applications/{uuid}/envs accepts the flag fields is_buildtime, is_runtime, and is_preview. Send is_build_time with an underscore between build and time and it returns 400 Validation failed: is_build_time field not allowed. The whole API uses single-word is_X flags, never is_X_Y. For framework PUBLIC_* vars read at build and at runtime, set both is_buildtime and is_runtime true. For runtime-only secrets, just is_runtime. Lost a few minutes to this on a live deploy before it clicked.

{ "key": "PUBLIC_API_URL", "value": "https://...", "is_buildtime": true, "is_runtime": true, "is_preview": false }

Second, and worse, the one-click Service type. Coolify v4's Service (one-click PocketBase, Plausible, Umami) auto-generates its COOLIFY_FQDN at service-creation time, and the rendered docker-compose bakes that domain into the proxy labels. Patch the service's domain env vars afterward and Coolify's database updates but the compose never re-renders. Force redeploy does not fix it. Stop and start does not fix it. The service deploys healthy, yet your domain returns a proxy 404 because the routing labels still point at the auto-generated address. For any third-party app needing a custom domain through the API, skip the Service type. Use an Application with a public Docker image, where the domain is accepted at create time and sticks.

My actual layout

The stack that has held for months:

  • One A1 instance, 4 OCPU / 24 GB, Mumbai, 50 GB boot plus an attached data volume
  • Coolify as the PaaS control plane, every app deployed from a Git push
  • A PocketBase instance per project as the backend, each on its own subdomain
  • One Cloudflare Tunnel fronting everything, so zero ingress ports are open on the VM
  • Litestream streaming each PocketBase SQLite file to Cloudflare R2, so a dead instance is a restore, not a loss

That carries 15 to 20 small services without strain. Prune Docker images on a cron, because Coolify accumulates build layers fast and a 50 GB boot disk fills quietly.

When to use this, and when not

Use it for personal APIs, internal tools, PocketBase or SQLite-backed apps, background workers and bots, status pages, Arm64 staging, and the bootstrapped multi-app PaaS above. It is a genuine early production server and an excellent lab.

Do not use it for revenue-critical databases without off-instance backups, sustained heavy-CPU jobs, x86-only stacks, or anything needing a contractual SLA. Free compute has no SLA. The moment an app earns money, pay for its infrastructure and demote the A1 box to staging.

Quick takeaways

  • Coolify turns one free Oracle A1 box into a Git-push PaaS for 15 to 20 small apps at zero platform cost.
  • Create the admin account the instant the installer prints its URL. The first-time screen is unauthenticated.
  • Run a Cloudflare Tunnel and keep every ingress port closed. Correct posture on a free box with a public IP.
  • A containerised tunnel reaches a bare-metal host service through the Docker bridge gateway 10.0.2.1, never localhost. The most expensive gotcha here.
  • The Coolify env API field is is_buildtime, one word, not is_build_time.
  • The v4 one-click Service type locks its FQDN at create and ignores later domain patches. Use an Application with a public Docker image for custom domains.
  • Stream databases off-instance with Litestream to R2. A free VM is a box you should be able to lose without flinching.

Related