Angel Melendez

← All workCase study 01 of 04

01 Own product

BigSkyEats

A food-delivery platform that I build and run myself. One Rails 8 application serves the API for the Expo mobile app and the admin console, PostgreSQL with PostGIS keeps the places and zones, and all of it runs on one ARM server behind Cloudflare.

Role
Owner. Code, server and deploys.
Built with
Ruby 3.3, Rails 8, ActiveAdmin, Expo, PostgreSQL 16 + PostGIS 3.5, Redis 7, Sidekiq
Runs on
One Oracle Cloud ARM server (2 vCPU), nginx, Cloudflare
Status
Demo configuration, test payments only
Address
bigskyeats.app
bigskyeats.app on 19 October 2026, 10:12 EDT. The address opens the API server’s own status page; the mobile app people use talks to the API behind it. Desktop 1440 × 900, phone 390 × 844.

Problem

It ran on a laptop

Until 17 October 2026, BigSkyEats ran on my Mac. The server the old deploy script was written for no longer existed, and the domain pointed at the registrar’s parking page. Moving it meant rebuilding the whole stack somewhere it could stay up without me.

Constraints

A small server, and jobs with side effects

The new home is one ARM server with two virtual CPUs, shared with other sites. The app’s lockfile and its native gems had only ever been built for Macs and for x86 Linux.

aarch64 is the name Linux gives 64-bit ARM. Bundler wants it listed in the lockfile.

The platform also runs in its demo configuration, with test payment keys. Every Rails boot writes about 60 scheduled jobs into Redis (payouts, hotel invoices, menu syncs, AI bots), and they only run inside a Sidekiq process. Starting that process is a deliberate step of its own and never part of a deploy.

What I built

The stack, rebuilt on ARM

Ruby 3.3 and PostgreSQL 16 come from the operating system’s own module streams, PostGIS 3.5 from AppStream with its GEOS library from EPEL. I restored the database from the newest dump with the extension lines taken out of the restore list and the extensions created first, so it went in with zero errors.

Puma runs as its own system user and listens on the loopback interface only. Its config file binds every interface in production, so the service passes the bind on the command line, and I check with ss that it took. nginx serves the compiled assets from disk with a one-year immutable cache, hands the API to Puma and upgrades /cable to a WebSocket for Action Cable.

The config file says what should be listening. ss -ltnp says what is.

Every night a backup writes the database dump, the configuration and the uploaded files into a restic repository that keeps 14 daily and 4 weekly snapshots. I restored the first snapshot and checked it, every uploaded file included, before I trusted it.

A deploy, step by step

  1. Production fetches main from GitHub with a read-only deploy key.
  2. Bundler installs against a lockfile that lists aarch64-linux.
  3. The compiled extensions of sassc and RGeo are copied to where those gems look for them (see below).
  4. Migrations run.
  5. Assets are compiled again only if they changed.
  6. Puma restarts.
  7. /health and /api/v1/restaurants have to answer before the deploy reports success.

How it works

What runs where

Eleven parts, from the phone in someone’s hand to the backup at night. Select one to see what it does and which parts it talks to.

  1. Expo app

    mobile, React Native

    The app people use. It calls the Rails API over HTTPS and keeps a WebSocket open to Action Cable for live updates. React Native’s WebSocket sends the URL’s own origin, which is what Rails checks before it accepts the connection.

  2. Admin console

    ActiveAdmin, in a browser

    ActiveAdmin is part of the same Rails application, so it shares the API’s models and database. There is no second backend to keep in step.

  3. Cloudflare

    DNS, proxy, TLS

    Every request arrives through Cloudflare’s proxy. The hop from Cloudflare to the server is encrypted as well, with a Let’s Encrypt certificate that Cloudflare verifies (the Full strict setting).

  4. nginx

    on the server

    Serves the compiled assets straight from disk with a one-year immutable cache, passes API and admin requests to Puma, upgrades /cable to a WebSocket, and redirects www and plain http to https://bigskyeats.app.

  5. Action Cable

    WebSockets

    Live updates over WebSockets, served by the same Rails processes. Rails only accepts the upgrade from allowed origins, which is why a request with no Origin header gets a 404 instead of a socket.

  6. Rails on Puma

    API + admin

    Two Puma workers, listening on the loopback interface only. Rails serves the JSON API, the admin console and the health checks that every deploy calls.

  7. Redis 7

    sessions, cache, queue

    Sessions, cache and Sidekiq’s queue. Every Rails boot also writes about 60 sidekiq-cron schedules into Redis; they stay inert until a Sidekiq process runs.

  8. PostgreSQL 16

    with PostGIS 3.5

    The main database, with the PostGIS extension for geographic data. On the Ruby side the RGeo gem reads those shapes, and it needs the GEOS library at runtime (it is one of the three things ARM changed, below).

  9. Sidekiq

    scheduled jobs

    Runs the scheduled jobs: payouts, hotel invoices, menu syncs, Stripe Connect refreshes and the AI bots. Some of them move money and some spend on AI APIs, so starting Sidekiq is a separate decision and never part of a deploy.

  10. Nightly backup

    restic

    Every night the database dump, the configuration and the uploaded files go into a restic repository; 14 daily and 4 weekly snapshots are kept. The first one was restored and checked before anything relied on it.

  11. Deploy

    one command

    Fetches main from GitHub with a read-only key, installs gems, puts the native extensions where they are expected, migrates the database, compiles assets when they changed, restarts Puma, then calls /health and /api/v1/restaurants before it reports success.

What broke

Three things ARM changed

  1. The lockfile listed only arm64-darwin and x86_64-linux, so Bundler refused to install on the server until aarch64-linux was added. The change went back to the repository as a pull request.
  2. sassc compiles libsass from source. On two virtual CPUs, bundle install takes more than ten minutes.
  3. sassc and RGeo load their compiled extensions from the gem’s own folder, and RubyGems 3.5 no longer puts them there. Rails died loading libsass, and RGeo::Geos.supported? answered false while an explicit require worked. The deploy now copies each gem’s .so files into place after every install.

What changed

One command, checked

A deploy is one command now, and it doesn’t call itself finished until the health check and a real API endpoint answer. The data has a nightly backup that has been restored at least once. And the scheduled jobs stay a decision rather than a side effect of starting the app.