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
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
- Production fetches
mainfrom GitHub with a read-only deploy key. - Bundler installs against a lockfile that lists
aarch64-linux. - The compiled extensions of sassc and RGeo are copied to where those gems look for them (see below).
- Migrations run.
- Assets are compiled again only if they changed.
- Puma restarts.
/healthand/api/v1/restaurantshave 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.
-
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.
Talks to: Cloudflare
-
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.
Talks to: Cloudflare
-
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).
Talks to: Expo app, Admin console, nginx
-
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
/cableto a WebSocket, and redirects www and plain http to https://bigskyeats.app.Talks to: Cloudflare, Rails on Puma, Action Cable
-
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.
Talks to: nginx, Rails on Puma
-
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.
Talks to: nginx, Action Cable, PostgreSQL, Redis, Deploy
-
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.
Talks to: Rails on Puma, Sidekiq
-
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).
Talks to: Rails on Puma, Sidekiq, Deploy, Nightly backup
-
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.
Talks to: Redis, PostgreSQL
-
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.
Talks to: PostgreSQL
-
Deploy
one command
Fetches
mainfrom 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/healthand/api/v1/restaurantsbefore it reports success.Talks to: Rails on Puma, PostgreSQL
What broke
Three things ARM changed
- The lockfile listed only
arm64-darwinandx86_64-linux, so Bundler refused to install on the server untilaarch64-linuxwas added. The change went back to the repository as a pull request. - sassc compiles libsass from source. On two virtual CPUs,
bundle installtakes more than ten minutes. - 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.sofiles 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.